Skip to content

Graph Editor part 2

Refactoring pointerInput

To handle moving and connecting shapes, we'll need to tweak how our pointerInput works.

Move and connect need to handle both taps and drags, while adding shapes only needs to handle taps. A pointerInput can only host a single gesture detector, so we need multiple pointerInputs for move and connect vs a single pointerInput for adding shapes.

We can move our when expression outside the pointerInput to choose which pointerInputs to create.

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

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Graph(
    // ...
) {
    with(LocalDensity.current) {
        // ...
        Scaffold(
            // ...
            modifier = modifier,
        ) { innerPadding ->
//          Canvas(modifier = modifier
            val baseModifier =
                modifier
                    .padding(innerPadding)
                    .fillMaxSize()

            val canvasModifier =
                when(selectedTool) {
                    is ShapeType ->
                        baseModifier
                            .pointerInput(true) {
                                detectTapGestures { finger ->
//                      when(val selectedTool = selectedToolRef) {
//                          is ShapeType -> {
                                    onAddShapeRef(
                                        Shape(
//                                      shapeType = selectedTool,
                                            shapeType = selectedToolRef as ShapeType,
                                            offset = finger - halfShapeOffsetRef
                                        )
                                    )
                                }
                            }

                    DrawLine ->
                        baseModifier
                            .pointerInput(true) { TODO("tap handler") }
                            .pointerInput(true) { TODO("drag handler") }
                    Select ->
                        baseModifier
                            .pointerInput(true) { TODO("tap handler") }
                            .pointerInput(true) { TODO("drag handler") }
                }

            Canvas(modifier = canvasModifier) {
//                                  ))
//                          }
//                          DrawLine -> TODO()
//                          Select -> TODO()
//                      }
//                  }
//              }
//          ) {
                shapes.forEach { shape ->
                    when(shape.shapeType) {
                        // ...
            }
        }
    }
}

Note that because the when is now outside the pointerInput, we have to explicitly cast selectedToolRef to ShapeType as the smart-cast is no longer available when the gesture detector setup is called.

Side discussion - conditional chaining in Kotlin

Note

We'll be using the above refactoring for the rest of this example, but I want to demonstrate how you can add helper functions that work inside chains of function calls.

We can alternatively create a helper function to conditionally chain the modifier creation:

fun Modifier.addIf(
    condition: Boolean,
    block: Modifier.() -> Modifier
) =
    if (condition) {
        block()
    } else {
        this
    }

If the condition evaluates false, we just pass along the existing Modifier. If true, we call the block, which takes the Modifier as a receiver so it can call Modifier extensions, chaining them onto the passed-in Modifier. The last expression in the lambda is returned.

This can be used in a Modifier chain:

val canvasModifier =
    modifier
        .padding(innerPadding)
        .fillMaxSize()
        .addIf(selectedTool is ShapeType) {
            pointerInput(true) {
                detectTapGestures { finger ->
                    onAddShapeRef(
                        Shape(
                            shapeType = selectedToolRef as ShapeType,
                            offset = finger - halfShapeOffset
                        )
                    )
                }
            }
        }
        .addIf(selectedTool == DrawLine) {
            pointerInput(true) { TODO("tap handler") }
            .pointerInput(true) { TODO("drag handler") }
        }
        .addIf(selectedTool == Select) {
            pointerInput(true) { TODO("tap handler") }
            .pointerInput(true) { TODO("drag handler") }
        }

Two things to note here:

  • Because we're no longer inside a when, smart-casting no longer takes effect, so we must explicitly cast the selectedToolRef to ShapeType.
  • We must be careful inside the addIf lambda to ensure we're using a single expression to return the chained pointerInputs.

If we had instead called

.addIf(selectedTool == Select) {
    pointerInput(true) { TODO("tap handler") }
    pointerInput(true) { TODO("drag handler") }
}

(note the missing . on the second pointerInput call), the first pointerInput would create a new Modifier which would be ignored. Only the result of the second pointerInput would be returned.

To make this a little more obvious, instead of passing Modifier as a receiver to the block, we could pass it as a parameter:

fun Modifier.addIf(
    condition: Boolean,
    block: (Modifier) -> Modifier
) =
    if (condition) {
        block(this)
    } else {
        this
    }

And then the block must explicitly use it:

.addIf(selectedTool == DrawLine) {
    it
        .pointerInput(true) { TODO("tap handler") }
        .pointerInput(true) { TODO("drag handler") }
}

or a parameter name:

.addIf(selectedTool == DrawLine) { modifier ->
    modifier
        .pointerInput(true) { TODO("tap handler") }
        .pointerInput(true) { TODO("drag handler") }
}

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.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>,
    onAddShape: (Shape) -> Unit,
    selectedTool: ToolType,
    onSelectedToolChanged: (ToolType) -> Unit,
    modifier: Modifier = Modifier,
    shapeSizeDp: Dp = 48.dp,
    shapeOutlineWidthDp: Dp = 3.dp,
    dashWidthDp: Dp = 6.dp,
    dashGapDp: 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 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,
        ) {
            translate(offset.x, offset.y) {
                drawPath(
                    path = trianglePath,
                    color = triangleColor,
                    style = Fill,
                )
                drawPath(
                    path = trianglePath,
                    color = shapeOutlineColor,
                    style = shapeOutlineStroke,
                )
            }
        }
        fun DrawScope.drawSquare(
            offset: Offset,
            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,
                    style = outlineStroke,
                )
            }
        }
        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,
                )
            }
        }
        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)

        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 ->
// Canvas(modifier = modifier val baseModifier = modifier .padding(innerPadding) .fillMaxSize() val canvasModifier = when(selectedTool) { is ShapeType -> baseModifier .pointerInput(true) { detectTapGestures { finger -> // when(val selectedTool = selectedToolRef) { // is ShapeType -> { onAddShapeRef( Shape( // shapeType = selectedTool, shapeType = selectedToolRef as ShapeType, offset = finger - halfShapeOffsetRef ) ) } } DrawLine -> baseModifier .pointerInput(true) { TODO("tap handler") } .pointerInput(true) { TODO("drag handler") } Select -> baseModifier .pointerInput(true) { TODO("tap handler") } .pointerInput(true) { TODO("drag handler") } } Canvas(modifier = canvasModifier) { // )) // } // DrawLine -> TODO() // Select -> TODO() // } // } // } // ) {
shapes.forEach { shape -> when(shape.shapeType) { Square -> drawSquare(shape.offset) Circle -> drawCircle(shape.offset) Triangle -> drawTriangle(shape.offset) } } } } } }