Skip to content

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)
            // ...
        }
    }
}

Drag with lines works!


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(), )