Graph Editor part 2
Highlighting Like Shapes
What if we'd like to do something very contrived, like tapping a shape to see all shapes of the same type highlighted?
We'll do this while the "select" tool is active, but activate it if the tap detector tells us the user performed a "tap". This means that the user didn't drag the finger more than touch slop, and the drag detector won't be activated.
The view model supports highlighting by exposing a flow that holds the type of shape to highlight.
We define a highlight() function that changes the highlight type inside a coroutine. We set the
type and then to null three times to make the shapes blink.
show in full file app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
// ...
class GraphViewModel: ViewModel() {
// ...
val selectedTool: Flow<ToolType>
field = MutableStateFlow<ToolType>(Square)
val highlightedShapeType: Flow<ShapeType?>
field = MutableStateFlow<ShapeType?>(null)
fun addShape(shape: Shape) {
// ...
}
fun highlight() {
selectedShape?.let { shape ->
viewModelScope.launch(Dispatchers.Default) {
repeat(3) {
highlightedShapeType.value = shape.shapeType
delay(300.milliseconds)
highlightedShapeType.value = null
delay(300.milliseconds)
}
}
selectedShape = null
}
}
}
Add some new parameters to Graph
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
shapes: List<Shape>,
highlightedShapeType: ShapeType?,
onAddShape: (Shape) -> Unit,
selectedTool: ToolType,
// ...
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onTap: () -> Unit,
modifier: Modifier = Modifier,
shapeSizeDp: Dp = 48.dp,
// ...
dashWidthDp: Dp = 6.dp,
dashGapDp: Dp = 3.dp,
shapeHighlightColor: Color = Color.Magenta,
shapeOutlineColor: Color = Color.Black,
triangleColor: Color = Color.Red,
// ...
) {
// ...
}
Collect and pass the highlighted shape type
show in full file app/src/main/java/com/androidbyexample/graph/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
val shapes by viewModel.shapes.collectAsStateWithLifecycle(emptyList())
val selectedTool by viewModel.selectedTool.collectAsStateWithLifecycle(Square)
val highlightedShapeType by viewModel.highlightedShapeType.collectAsStateWithLifecycle(null)
GraphTheme {
Graph(
// ...
onDrag = viewModel::drag,
onDragEnd = viewModel::endDrag,
onTap = viewModel::highlight,
highlightedShapeType = highlightedShapeType,
)
}
}
}
}
Wire up the onTap
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
// ...
) {
with(LocalDensity.current) {
// ...
val onDragRef by rememberUpdatedState(onDrag)
val onDragEndRef by rememberUpdatedState(onDragEnd)
val onTapRef by rememberUpdatedState(onTap)
Scaffold(
// ...
) { innerPadding ->
// ...
val canvasModifier =
when(selectedTool) {
// ...
Select ->
baseModifier
.pointerInput(true) {
detectTapGestures(
// ...
onPress = { onPressRef(it, shapeSizePxRef) },
onTap = { onTapRef() }
)
}
.pointerInput(true) {
// ...
}
}
// ...
}
}
}
Pass an optional outline color to the draw functions
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
// ...
) {
with(LocalDensity.current) {
// ...
fun DrawScope.drawTriangle(
offset: Offset,
outlineColor: Color = shapeOutlineColor,
) {
translate(offset.x, offset.y) {
// ...
drawPath(
path = trianglePath,
// color = shapeOutlineColor,
color = outlineColor,
style = shapeOutlineStroke,
)
}
}
// ...
}
}
drawCircle() and drawTriangle()` are similar.
Finally, select the outline color to use when drawing shapes
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
// ...
) {
with(LocalDensity.current) {
// ...
Scaffold(
// ...
) { innerPadding ->
// ...
Canvas(modifier = canvasModifier) {
shapes.forEach { shape ->
// when(shape.shapeType) {
// Square -> drawSquare(shape.offset)
// Circle -> drawCircle(shape.offset)
// Triangle -> drawTriangle(shape.offset)
val outlineColor =
if (shape.shapeType == highlightedShapeType) {
shapeHighlightColor
} else {
shapeOutlineColor
}
when(shape.shapeType) {
Square -> drawSquare(
offset = shape.offset,
outlineColor = outlineColor,
)
Circle -> drawCircle(
offset = shape.offset,
outlineColor = outlineColor,
)
Triangle -> drawTriangle(
offset = shape.offset,
outlineColor = outlineColor,
)
}
}
}
}
}
}

All code changes
CHANGED: app/src/main/java/com/androidbyexample/graph/Graph.kt
package com.androidbyexample.graph
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
shapes: List<Shape>,
highlightedShapeType: ShapeType?,
onAddShape: (Shape) -> Unit,
selectedTool: ToolType,
onSelectedToolChanged: (ToolType) -> Unit,
onPress: (Offset, Float) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onTap: () -> Unit,
modifier: Modifier = Modifier,
shapeSizeDp: Dp = 48.dp,
shapeOutlineWidthDp: Dp = 3.dp,
dashWidthDp: Dp = 6.dp,
dashGapDp: Dp = 3.dp,
shapeHighlightColor: Color = Color.Magenta,
shapeOutlineColor: Color = Color.Black,
triangleColor: Color = Color.Red,
circleColor: Color = Color.Blue,
squareColor: Color = Color.Green,
selectedToolColor: Color = MaterialTheme.colorScheme.secondary,
) {
with(LocalDensity.current) {
val shapeSizePx = shapeSizeDp.toPx()
val halfShapeSizePx = shapeSizePx/2
val shapeOutlineWidthPx = shapeOutlineWidthDp.toPx()
val shapeSize = Size(shapeSizePx, shapeSizePx)
val halfShapeOffset = Offset(halfShapeSizePx, halfShapeSizePx)
val dashWidthPx = dashWidthDp.toPx()
val dashGapPx = dashGapDp.toPx()
val trianglePath = Path().apply {
moveTo(shapeSizePx/2, 0f)
lineTo(shapeSizePx, shapeSizePx)
lineTo(0f, shapeSizePx)
close()
}
val shapeOutlineStroke = remember(shapeOutlineWidthPx) {
Stroke(shapeOutlineWidthPx)
}
val dashedOutlineStroke =
remember(shapeOutlineWidthPx, dashWidthPx, dashGapPx) {
Stroke(
width = shapeOutlineWidthPx,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(dashWidthPx, dashGapPx),
phase = 0f,
)
)
}
fun DrawScope.drawTriangle(
offset: Offset,
outlineColor: Color = shapeOutlineColor,
) {
translate(offset.x, offset.y) {
drawPath(
path = trianglePath,
color = triangleColor,
style = Fill,
)
drawPath(
path = trianglePath,
// color = shapeOutlineColor,
color = outlineColor,
style = shapeOutlineStroke,
)
}
}
fun DrawScope.drawSquare(
offset: Offset,
outlineColor: Color = shapeOutlineColor,
outlineStroke: Stroke = shapeOutlineStroke,
fill: Boolean = true,
) {
translate(offset.x, offset.y) {
if (fill) {
drawRect(
topLeft = Offset.Zero,
size = shapeSize,
color = squareColor,
style = Fill,
)
}
drawRect(
topLeft = Offset.Zero,
size = shapeSize,
// color = shapeOutlineColor,
color = outlineColor,
style = outlineStroke,
)
}
}
fun DrawScope.drawCircle(
offset: Offset,
outlineColor: Color = shapeOutlineColor,
) {
translate(offset.x, offset.y) {
drawCircle(
center = halfShapeOffset,
radius = halfShapeSizePx,
color = circleColor,
style = Fill,
)
drawCircle(
center = halfShapeOffset,
radius = halfShapeSizePx,
// color = shapeOutlineColor,
color = outlineColor,
style = shapeOutlineStroke,
)
}
}
fun DrawScope.drawLine(
offset: Offset,
) {
translate(offset.x, offset.y) {
drawLine(
start = Offset(0f, halfShapeSizePx),
end = Offset(shapeSizePx, halfShapeSizePx),
color = circleColor,
strokeWidth = shapeOutlineWidthPx,
)
}
}
val selectedToolRef by rememberUpdatedState(selectedTool)
val onAddShapeRef by rememberUpdatedState(onAddShape)
val halfShapeOffsetRef by rememberUpdatedState(halfShapeOffset)
val shapeSizePxRef by rememberUpdatedState(shapeSizePx)
val onPressRef by rememberUpdatedState(onPress)
val onDragRef by rememberUpdatedState(onDrag)
val onDragEndRef by rememberUpdatedState(onDragEnd)
val onTapRef by rememberUpdatedState(onTap)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.graph)) },
actions = {
ToolButton(
draw = { drawSquare(it) },
shapeSizeDp = shapeSizeDp,
tool = Square,
selectedTool = selectedTool,
selectionColor = selectedToolColor,
onSelectTool = onSelectedToolChanged,
)
ToolButton(
draw = { drawCircle(it) },
shapeSizeDp = shapeSizeDp,
tool = Circle,
selectedTool = selectedTool,
selectionColor = selectedToolColor,
onSelectTool = onSelectedToolChanged,
)
ToolButton(
draw = { drawTriangle(it) },
shapeSizeDp = shapeSizeDp,
tool = Triangle,
selectedTool = selectedTool,
selectionColor = selectedToolColor,
onSelectTool = onSelectedToolChanged,
)
ToolButton(
draw = { drawLine(it) },
shapeSizeDp = shapeSizeDp,
tool = DrawLine,
selectedTool = selectedTool,
selectionColor = selectedToolColor,
onSelectTool = onSelectedToolChanged,
)
ToolButton(
draw = {
drawSquare(
offset = it,
outlineStroke = dashedOutlineStroke,
fill = false,
)
},
shapeSizeDp = shapeSizeDp,
tool = Select,
selectedTool = selectedTool,
selectionColor = selectedToolColor,
onSelectTool = onSelectedToolChanged,
)
}
)
},
modifier = modifier,
) { innerPadding ->
val baseModifier =
modifier
.padding(innerPadding)
.fillMaxSize()
val canvasModifier =
when(selectedTool) {
is ShapeType ->
baseModifier
.pointerInput(true) {
detectTapGestures { finger ->
onAddShapeRef(
Shape(
shapeType = selectedToolRef as ShapeType,
offset = finger - halfShapeOffsetRef
)
)
}
}
DrawLine ->
baseModifier
.pointerInput(true) { TODO("tap handler") }
.pointerInput(true) { TODO("drag handler") }
Select ->
baseModifier
.pointerInput(true) {
detectTapGestures(
// onPress = { onPressRef(it, shapeSizePxRef) }
onPress = { onPressRef(it, shapeSizePxRef) },
onTap = { onTapRef() }
)
}
.pointerInput(true) {
detectDragGestures(
onDragStart = onDragRef,
onDrag = { change, _ ->
onDragRef(change.position)
},
onDragCancel = onDragEndRef,
onDragEnd = onDragEndRef,
)
}
}
Canvas(modifier = canvasModifier) {
shapes.forEach { shape ->
// when(shape.shapeType) {
// Square -> drawSquare(shape.offset)
// Circle -> drawCircle(shape.offset)
// Triangle -> drawTriangle(shape.offset)
val outlineColor =
if (shape.shapeType == highlightedShapeType) {
shapeHighlightColor
} else {
shapeOutlineColor
}
when(shape.shapeType) {
Square -> drawSquare(
offset = shape.offset,
outlineColor = outlineColor,
)
Circle -> drawCircle(
offset = shape.offset,
outlineColor = outlineColor,
)
Triangle -> drawTriangle(
offset = shape.offset,
outlineColor = outlineColor,
)
}
}
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
package com.androidbyexample.graph
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class GraphViewModel: ViewModel() {
val shapes: Flow<List<Shape>>
field = MutableStateFlow<List<Shape>>(emptyList())
val selectedTool: Flow<ToolType>
field = MutableStateFlow<ToolType>(Square)
val highlightedShapeType: Flow<ShapeType?>
field = MutableStateFlow<ShapeType?>(null)
fun addShape(shape: Shape) {
shapes.value += shape
}
fun selectTool(tool: ToolType) {
selectedTool.value = tool
}
// handle shape drags
private var selectedShape: Shape? = null
private var dragShapeOffset = Offset.Zero
fun press(finger: Offset, shapeSizePx: Float) {
selectedShape = shapes.value.findAt(finger, shapeSizePx)?.apply {
dragShapeOffset = finger - offset
}
}
fun drag(finger: Offset) {
selectedShape?.let { shape ->
val newShape = shape.copy(offset = finger - dragShapeOffset).apply {
selectedShape = this
}
shapes.value = shapes.value - shape + newShape
}
}
fun endDrag() {
selectedShape = null
dragShapeOffset = Offset.Zero
}
fun List<Shape>.findAt(offset: Offset, shapeSizePx: Float) =
asReversed().find { shape ->
val normalized = offset - shape.offset
normalized.x in 0f..shapeSizePx &&
normalized.y in 0f..shapeSizePx
}
fun highlight() {
selectedShape?.let { shape ->
viewModelScope.launch(Dispatchers.Default) {
repeat(3) {
highlightedShapeType.value = shape.shapeType
delay(300.milliseconds)
highlightedShapeType.value = null
delay(300.milliseconds)
}
}
selectedShape = null
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/graph/MainActivity.kt
package com.androidbyexample.graph
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.graph.ui.theme.GraphTheme
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<GraphViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val shapes by viewModel.shapes.collectAsStateWithLifecycle(emptyList())
val selectedTool by viewModel.selectedTool.collectAsStateWithLifecycle(Square)
val highlightedShapeType by viewModel.highlightedShapeType.collectAsStateWithLifecycle(null)
GraphTheme {
Graph(
shapes = shapes,
selectedTool = selectedTool,
onSelectedToolChanged = viewModel::selectTool,
onAddShape = viewModel::addShape,
onPress = viewModel::press,
onDrag = viewModel::drag,
onDragEnd = viewModel::endDrag,
onTap = viewModel::highlight,
highlightedShapeType = highlightedShapeType,
)
}
}
}
}