Graph Editor part 2
Connecting Shapes
Finally, we connect our shapes to make the graph!
We need a class to keep track of each line we create. Each line will always have a starting shape. When we first create it, we won't have an ending shape, as the user will be dragging their finger to the end shape.
show in full file app/src/main/java/com/androidbyexample/graph/Line.kt
package com.androidbyexample.graph
data class Line(
val start: Shape,
val end: Shape? = null,
)
In the view model, we need to track and manage the lines. We keep track of the current line-in-progress as the user is dragging toward the end shape so we can update it while dragging and when they select the end shape.
show in full file app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
// ...
class GraphViewModel: ViewModel() {
val shapes: Flow<List<Shape>>
field = MutableStateFlow<List<Shape>>(emptyList())
val lines: Flow<List<Line>>
field = MutableStateFlow<List<Line>>(emptyList())
val selectedTool: Flow<ToolType>
field = MutableStateFlow<ToolType>(Square)
// ...
}
}
private var lineInProgress: Line? = null
fun startLine(finger: Offset, shapeSizePx: Float) {
shapes.value
.findAt(finger, shapeSizePx)
?.let { shape ->
lines.value += Line(shape).apply {
lineInProgress = this
}
}
}
fun endLine(finger: Offset, shapeSizePx: Float) {
lineInProgress?.let { line ->
shapes.value
.findAt(finger, shapeSizePx)
?.let { endShape ->
lines.value = lines.value - line + line.copy(end = endShape)
}
?: run {
lines.value -= line
}
lineInProgress = null
}
}
fun cancelLine() {
lineInProgress?.let { line ->
lines.value -= line
lineInProgress = null
}
}
}
We add some new parameters to our 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?,
lines: List<Line>,
highlightedShapeType: ShapeType?,
onAddShape: (Shape) -> Unit,
// ...
onDragEnd: () -> Unit,
onTap: () -> Unit,
onLineStart: (Offset, Float) -> Unit,
onLineEnd: (Offset, Float) -> Unit,
onLineCancel: () -> Unit,
modifier: Modifier = Modifier,
shapeSizeDp: Dp = 48.dp,
// ...
dashGapDp: Dp = 3.dp,
shapeHighlightColor: Color = Color.Magenta,
lineColor: Color = Color.DarkGray,
shapeOutlineColor: Color = Color.Black,
triangleColor: Color = Color.Red,
// ...
) {
// ...
}
And define the gesture detectors for drawing a line. We're tracking tempLineEnd as the user
drags their finger from the start shape. This location will be used as the end point for the line
as the user drags.
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
// ...
) {
with(LocalDensity.current) {
// ...
val onDragEndRef by rememberUpdatedState(onDragEnd)
val onTapRef by rememberUpdatedState(onTap)
val onLineStartRef by rememberUpdatedState(onLineStart)
val onLineEndRef by rememberUpdatedState(onLineEnd)
val onLineCancelRef by rememberUpdatedState(onLineCancel)
var tempLineEnd by remember { mutableStateOf(Offset.Zero) }
Scaffold(
// ...
) { innerPadding ->
// ...
val canvasModifier =
when(selectedTool) {
// ...
DrawLine ->
baseModifier
// .pointerInput(true) { TODO("tap handler") }
// .pointerInput(true) { TODO("drag handler") }
.pointerInput(true) {
detectTapGestures(
onPress = {
onLineStartRef(it, shapeSizePxRef)
tempLineEnd = it
},
)
}
.pointerInput(true) {
detectDragGestures(
onDrag = { change, _ ->
tempLineEnd = change.position
},
onDragCancel = {
onLineCancelRef
tempLineEnd = Offset.Zero
},
onDragEnd = {
onLineEndRef(tempLineEnd, shapeSizePxRef)
tempLineEnd = Offset.Zero
},
)
}
Select ->
baseModifier
// ...
}
// ...
}
}
}
Collect the lines from the view model and pass in our new parameters to Graph
show in full file app/src/main/java/com/androidbyexample/graph/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
val selectedTool by viewModel.selectedTool.collectAsStateWithLifecycle(Square)
val highlightedShapeType by viewModel.highlightedShapeType.collectAsStateWithLifecycle(null)
val lines by viewModel.lines.collectAsStateWithLifecycle(emptyList())
GraphTheme {
Graph(
shapes = shapes,
lines = lines,
selectedTool = selectedTool,
onSelectedToolChanged = viewModel::selectTool,
// ...
onTap = viewModel::highlight,
highlightedShapeType = highlightedShapeType,
onLineStart = viewModel::startLine,
onLineEnd = viewModel::endLine,
onLineCancel = viewModel::cancelLine,
)
}
}
}
}
Now we can actually draw the lines on the Canvas. We choose to draw the lines first,
from the center of the start shape to the end shape (or tempLineEnd if the end has not yet
been selected) so the shapes will cover the overlap. If we drew the lines after the shapes,
we'd need to determine where to clip the lines. It's much simpler to take advantage of the
painter's algorithm and just draw over the overlapping part of the lines.
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) {
lines.forEach { line ->
drawLine(
start = line.start.offset + halfShapeOffset,
end = line.end?.let { it.offset + halfShapeOffset} ?: tempLineEnd,
color = lineColor,
strokeWidth = shapeOutlineWidthPx,
)
}
shapes.forEach { shape ->
val outlineColor =
// ...
}
}
}
}
When we run, we can now add lines by dragging from a start shape to an end shape.

When we drag the shapes, something a little unexpected happens:

The problem here is that Line directly references two Shapes. As we drag a Shape,
we create a new copies of it. The initial, stale instance, is still referenced from the Line,
so the line draws its endpoint from that stale instance.
We'll fix this in the next step.
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
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?,
lines: List<Line>,
highlightedShapeType: ShapeType?,
onAddShape: (Shape) -> Unit,
selectedTool: ToolType,
onSelectedToolChanged: (ToolType) -> Unit,
onPress: (Offset, Float) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onTap: () -> Unit,
onLineStart: (Offset, Float) -> Unit,
onLineEnd: (Offset, Float) -> Unit,
onLineCancel: () -> Unit,
modifier: Modifier = Modifier,
shapeSizeDp: Dp = 48.dp,
shapeOutlineWidthDp: Dp = 3.dp,
dashWidthDp: Dp = 6.dp,
dashGapDp: Dp = 3.dp,
shapeHighlightColor: Color = Color.Magenta,
lineColor: Color = Color.DarkGray,
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 = 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 = 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 = 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)
val onLineStartRef by rememberUpdatedState(onLineStart)
val onLineEndRef by rememberUpdatedState(onLineEnd)
val onLineCancelRef by rememberUpdatedState(onLineCancel)
var tempLineEnd by remember { mutableStateOf(Offset.Zero) }
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") }
.pointerInput(true) {
detectTapGestures(
onPress = {
onLineStartRef(it, shapeSizePxRef)
tempLineEnd = it
},
)
}
.pointerInput(true) {
detectDragGestures(
onDrag = { change, _ ->
tempLineEnd = change.position
},
onDragCancel = {
onLineCancelRef
tempLineEnd = Offset.Zero
},
onDragEnd = {
onLineEndRef(tempLineEnd, shapeSizePxRef)
tempLineEnd = Offset.Zero
},
)
}
Select ->
baseModifier
.pointerInput(true) {
detectTapGestures(
onPress = { onPressRef(it, shapeSizePxRef) },
onTap = { onTapRef() }
)
}
.pointerInput(true) {
detectDragGestures(
onDragStart = onDragRef,
onDrag = { change, _ ->
onDragRef(change.position)
},
onDragCancel = onDragEndRef,
onDragEnd = onDragEndRef,
)
}
}
Canvas(modifier = canvasModifier) {
lines.forEach { line ->
drawLine(
start = line.start.offset + halfShapeOffset,
end = line.end?.let { it.offset + halfShapeOffset} ?: tempLineEnd,
color = lineColor,
strokeWidth = shapeOutlineWidthPx,
)
}
shapes.forEach { shape ->
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 lines: Flow<List<Line>>
field = MutableStateFlow<List<Line>>(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
}
}
private var lineInProgress: Line? = null
fun startLine(finger: Offset, shapeSizePx: Float) {
shapes.value
.findAt(finger, shapeSizePx)
?.let { shape ->
lines.value += Line(shape).apply {
lineInProgress = this
}
}
}
fun endLine(finger: Offset, shapeSizePx: Float) {
lineInProgress?.let { line ->
shapes.value
.findAt(finger, shapeSizePx)
?.let { endShape ->
lines.value = lines.value - line + line.copy(end = endShape)
}
?: run {
lines.value -= line
}
lineInProgress = null
}
}
fun cancelLine() {
lineInProgress?.let { line ->
lines.value -= line
lineInProgress = null
}
}
}
ADDED: app/src/main/java/com/androidbyexample/graph/Line.kt
package com.androidbyexample.graph
data class Line(
val start: Shape,
val end: Shape? = 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)
val lines by viewModel.lines.collectAsStateWithLifecycle(emptyList())
GraphTheme {
Graph(
shapes = shapes,
lines = lines,
selectedTool = selectedTool,
onSelectedToolChanged = viewModel::selectTool,
onAddShape = viewModel::addShape,
onPress = viewModel::press,
onDrag = viewModel::drag,
onDragEnd = viewModel::endDrag,
onTap = viewModel::highlight,
highlightedShapeType = highlightedShapeType,
onLineStart = viewModel::startLine,
onLineEnd = viewModel::endLine,
onLineCancel = viewModel::cancelLine,
)
}
}
}
}