Graph Editor part 1

Dropping the correct shapes

To fix our shape-selection problem, we can use rememberUpdatedState()

show in full file app/src/main/java/com/androidbyexample/graph/Graph.kt
// ...

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
    // ...
) {
    with(LocalDensity.current) {
        // ...
        }

        val selectedToolRef by rememberUpdatedState(selectedTool)
        val onAddShapeRef by rememberUpdatedState(onAddShape)

        Scaffold(
            // ...
    }
}

This creates a slot in the Compose user-interface tree that we can reference from our gesture detector. Note that we also did this for onAddShape, as it's referenced from the gesture detector as well. (It's highly unlikely that onAddShape will change, as its value is a function ref from the view model, but we want to keep Composable functions as pure as possible, not assuming what will be passed in or how often (if ever) the values will change, so it's best to use rememberUpdatedState for it as well.)

Every time rememberUpdatedState() is called, it simply updates that slot in the tree. When we use it

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 = modifier
                .padding(innerPadding)
                .fillMaxSize()
                .pointerInput(true) {
                    detectTapGestures { finger ->
//                      when(selectedTool) {
                        when(val selectedTool = selectedToolRef) {
                            is ShapeType -> {
//                              onAddShape(Shape(selectedTool, finger))
                                onAddShapeRef(Shape(selectedTool, finger))
                            }
                            Line -> TODO()
                            Select -> TODO()
                        }
//                  }
//              }
                    }
                }
            ) {
                // ...
            }
        }
    }
}

the new value is seen every time. This fixes our problem.

Note

If we use selectedToolRef in Shape(selectedToolRef, finger), Kotlin will give you an error. The rememberUpdatedState() could be called any time, even when the gesture detector is running, causing a race condition. The value could change between the time the when looks at it and when we use it to create the shape. This prevents Kotlin from being able to smart-cast it to ShapeType, so it looks like a ToolType when we try to pass it to the Shape constructor, causing the error.

To fix this, we lock down the value we see at the point when looks at it by creating a property selectedTool that only lives during the when expression.

Proper shapes dropped

You won't need to do this too often. Most likely you'll need it when

  • You capture a lambda context that you don't want to recreate

  • You have a side effect, such as a LaunchedEffect that you don't want to restart when a property that it uses changes


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.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.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>,
    onAddShape: (Shape) -> Unit,
    selectedTool: ToolType,
    onSelectedToolChanged: (ToolType) -> Unit,
    modifier: Modifier = Modifier,
    shapeSizeDp: Dp = 48.dp,
    shapeOutlineWidthDp: Dp = 3.dp,
    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 trianglePath = Path().apply {
            moveTo(shapeSizePx/2, 0f)
            lineTo(shapeSizePx, shapeSizePx)
            lineTo(0f, shapeSizePx)
            close()
        }

        val shapeOutlineStroke = remember(shapeOutlineWidthPx) {
            Stroke(shapeOutlineWidthPx)
        }

        fun DrawScope.drawTriangle(
            offset: Offset,
        ) {
            translate(offset.x, offset.y) {
                drawPath(
                    path = trianglePath,
                    color = triangleColor,
                    style = Fill,
                )
                drawPath(
                    path = trianglePath,
                    color = shapeOutlineColor,
                    style = shapeOutlineStroke,
                )
            }
        }
        fun DrawScope.drawSquare(
            offset: Offset,
        ) {
            translate(offset.x, offset.y) {
                drawRect(
                    topLeft = Offset.Zero,
                    size = shapeSize,
                    color = squareColor,
                    style = Fill,
                )
                drawRect(
                    topLeft = Offset.Zero,
                    size = shapeSize,
                    color = shapeOutlineColor,
                    style = shapeOutlineStroke,
                )
            }
        }
        fun DrawScope.drawCircle(
            offset: Offset,
        ) {
            translate(offset.x, offset.y) {
                drawCircle(
                    center = halfShapeOffset,
                    radius = halfShapeSizePx,
                    color = circleColor,
                    style = Fill,
                )
                drawCircle(
                    center = halfShapeOffset,
                    radius = halfShapeSizePx,
                    color = shapeOutlineColor,
                    style = shapeOutlineStroke,
                )
            }
        }

val selectedToolRef by rememberUpdatedState(selectedTool) val onAddShapeRef by rememberUpdatedState(onAddShape)
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, ) } ) }, modifier = modifier, ) { innerPadding -> Canvas(modifier = modifier .padding(innerPadding) .fillMaxSize() .pointerInput(true) { detectTapGestures { finger ->
// when(selectedTool) { when(val selectedTool = selectedToolRef) { is ShapeType -> { // onAddShape(Shape(selectedTool, finger)) onAddShapeRef(Shape(selectedTool, finger)) } Line -> TODO() Select -> TODO() } // } // }
} } ) { shapes.forEach { shape -> when(shape.shapeType) { Square -> drawSquare(shape.offset) Circle -> drawCircle(shape.offset) Triangle -> drawTriangle(shape.offset) } } } } } }