Graph Editor part 2
Fixing the drag
We need to either update all lines each time a shape is moved (highly inefficient) or reference
the Shapes indirectly from the Lines. We'll go with the indirect references.
Start by adding an id to the Shape. This will uniquely identify the shape. When we make copies of
it, we won't update the id, so it will stay consistent.
show in full file app/src/main/java/com/androidbyexample/graph/Shape.kt
// ...
data class Shape(
val shapeType: ShapeType,
val offset: Offset,
val id: String = UUID.randomUUID().toString(),
)
Next, change Line to reference shapes by id instead of directly pointing at the Shape.
show in full file app/src/main/java/com/androidbyexample/graph/Line.kt
// ...
data class Line(
// val start: Shape,
// val end: Shape? = null,
val startId: String,
val endId: String? = null,
)
View Model Changes
Now we need some way to look up shapes in the Graph so we can draw the lines. We can use a Map
to store id/Shape pairs and look them up. We could create a separate map to track ids, and
pass that along with the Shapes list, but then we have two separate data structures to manage
for the same information.
Instead, we can convert our list of Shapes to a LinkedHashMap of id/Shape pairs.
LinkedHashMap is a Map that keeps track of the order of its values based on insertion order.
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 shapes: Flow<Map<String, Shape>>
field = MutableStateFlow<Map<String, Shape>>(LinkedHashMap())
val lines: Flow<List<Line>>
field = MutableStateFlow<List<Line>>(emptyList())
// ...
fun addShape(shape: Shape) {
// shapes.value += shape
shapes.value += shape.id to shape
}
fun selectTool(tool: ToolType) {
// ...
}
Note
shape.id to shape creates a Pair which is added to the Map. The to function is an
'infix operator' function in Kotlin, which allows it to be used like an operator between
two operands. This is one of the features that helps enable "Domain-Specific Languages"
(DSLs) in Kotlin. You can find out more about DSLs in my lecture videos from a
DSL class I ran at Johns Hopkins
The findAt function needs to be tweaked to walk the values property of the map. Note that
values is a Collection, so we cannot use asReversed() because Collection doesn't have any
way to directly index items. This means we'll create a copy of the list in reverse order. This
isn't that big of a deal as we only do this when clicking to move or draw lines.
show in full file app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
// ...
class GraphViewModel: ViewModel() {
// ...
}
// fun List<Shape>.findAt(offset: Offset, shapeSizePx: Float) =
// asReversed().find { shape ->
fun Map<String, Shape>.findAt(offset: Offset, shapeSizePx: Float) =
values.reversed().find { shape ->
val normalized = offset - shape.offset
normalized.x in 0f..shapeSizePx &&
normalized.y in 0f..shapeSizePx
}
// ...
}
The drag function in the view model needs a bit more tweaking to work properly.
show in full file app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
// ...
class GraphViewModel: ViewModel() {
// ...
fun drag(finger: Offset) {
selectedShape?.let { shape ->
val newShape = shape.copy(offset = finger - dragShapeOffset).apply {
selectedShape = this
}
// shapes.value = shapes.value - shape + newShape
shapes.value = shapes.value - shape.id + (newShape.id to newShape)
}
}
// ...
}
We had
shapes.value = shapes.value - shape + newShape
To remove the shape, we instead pass its id. The Map.minus extension function that is used
for the - operator takes a key to remove an entry. We then add a new pair at the end,
(newShape.id to newShape).
When starting and ending lines, we need to pass the shape ids instead of the enture shape.
show in full file app/src/main/java/com/androidbyexample/graph/GraphViewModel.kt
// ...
class GraphViewModel: ViewModel() {
// ...
fun startLine(finger: Offset, shapeSizePx: Float) {
shapes.value
.findAt(finger, shapeSizePx)
?.let { shape ->
// lines.value += Line(shape).apply {
lines.value += Line(shape.id).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)
lines.value = lines.value - line + line.copy(endId = endShape.id)
}
?: run {
lines.value -= line
}
lineInProgress = null
}
}
// ...
}
That takes care of our view model.
Graph changes
First, we change the parameter from List to Map. We don't care about the actual type of Map
passed in.
show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
// shapes: List<Shape>,
// lines: List<Line>,
shapes: Map<String, Shape>,
lines: List<Line>,
highlightedShapeType: ShapeType?,
// ...
) {
// ...
}
To draw the lines, we need to lookup which shapes are at the ends. To draw shapes, we need to
walk through shapes.values instead of just 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) {
lines.forEach { line ->
drawLine(
// start = line.start.offset + halfShapeOffset,
// end = line.end?.let { it.offset + halfShapeOffset} ?: tempLineEnd,
start = shapes[line.startId]?.let { it.offset + halfShapeOffset}
?: throw IllegalStateException("Shape id not found"),
end = shapes[line.endId]?.let { it.offset + halfShapeOffset} ?: tempLineEnd,
color = lineColor,
strokeWidth = shapeOutlineWidthPx,
)
}
shapes.values.forEach { shape ->
// shapes.forEach { shape ->
val outlineColor =
if (shape.shapeType == highlightedShapeType) {
// ...
}
}
}
}
}
In MainActivity we just need to change the initial value when collecting shapes
show in full file app/src/main/java/com/androidbyexample/graph/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
enableEdgeToEdge()
setContent {
// val shapes by viewModel.shapes.collectAsStateWithLifecycle(emptyList())
val shapes by viewModel.shapes.collectAsStateWithLifecycle(emptyMap())
val selectedTool by viewModel.selectedTool.collectAsStateWithLifecycle(Square)
val highlightedShapeType by viewModel.highlightedShapeType.collectAsStateWithLifecycle(null)
// ...
}
}
}

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>,
// lines: List<Line>,
shapes: Map<String, Shape>,
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) {
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,
start = shapes[line.startId]?.let { it.offset + halfShapeOffset}
?: throw IllegalStateException("Shape id not found"),
end = shapes[line.endId]?.let { it.offset + halfShapeOffset} ?: tempLineEnd,
color = lineColor,
strokeWidth = shapeOutlineWidthPx,
)
}
shapes.values.forEach { shape ->
// 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 shapes: Flow<Map<String, Shape>>
field = MutableStateFlow<Map<String, Shape>>(LinkedHashMap())
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
shapes.value += shape.id to 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
shapes.value = shapes.value - shape.id + (newShape.id to newShape)
}
}
fun endDrag() {
selectedShape = null
dragShapeOffset = Offset.Zero
}
// fun List<Shape>.findAt(offset: Offset, shapeSizePx: Float) =
// asReversed().find { shape ->
fun Map<String, Shape>.findAt(offset: Offset, shapeSizePx: Float) =
values.reversed().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 {
lines.value += Line(shape.id).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)
lines.value = lines.value - line + line.copy(endId = endShape.id)
}
?: run {
lines.value -= line
}
lineInProgress = null
}
}
fun cancelLine() {
lineInProgress?.let { line ->
lines.value -= line
lineInProgress = null
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/graph/Line.kt
package com.androidbyexample.graph
data class Line(
// val start: Shape,
// val end: Shape? = null,
val startId: String,
val endId: String? = 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 shapes by viewModel.shapes.collectAsStateWithLifecycle(emptyMap())
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,
)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/graph/Shape.kt
package com.androidbyexample.graph
import androidx.compose.ui.geometry.Offset
import java.util.UUID
data class Shape(
val shapeType: ShapeType,
val offset: Offset,
val id: String = UUID.randomUUID().toString(),
)