Search code examples
kotlinandroid-jetpack-composecompose-desktop

How to preserve canvas state in Jetpack Compose for Desktop


I'd like to implement a simple drawing app with Compose for Desktop and the basics seem to be easy:

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState


const val ACTION_IDLE = 0
const val ACTION_DOWN = 1
const val ACTION_MOVE = 2
const val ACTION_UP = 3

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "Compose for Desktop",
        state = rememberWindowState(width = 500.dp, height = 500.dp)
    ) {
        MaterialTheme {
            var motionEvent by remember { mutableStateOf(ACTION_IDLE) }
            var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
            Canvas(
                modifier = Modifier.fillMaxSize()
                    .background(Color.LightGray)
                    .pointerInput(Unit) {
                        forEachGesture {
                            awaitPointerEventScope {
                                awaitFirstDown().also {
                                    motionEvent = ACTION_DOWN
                                    currentPosition = it.position
                                }
                            }
                        }
                    }
            ) {
                fun draw() =
                    drawCircle(
                        color = Color.Magenta,
                        center = Offset(x = currentPosition.x, y = currentPosition.y),
                        radius = size.minDimension / 4,
                        style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)
                    )

                when (motionEvent) {
                    ACTION_DOWN -> draw()
                }
            }
        }
    }
}

The app, however, does not behave the way I'd anticipate: it draws a new circle dismissing the previous one every time, e.g. as if the canvas doesn't preserve state and reset on every new input. I'd like to keep all the figures though. Is there anything I'm missing to achieve that?


Solution

  • The canvas is not preserved across recompositions (this, for example, allows to change what you're drawing without clearing it every time). This makes sense in the Compose mental model where composable invocations are idempotent; if the canvas weren't cleared every time the CanvasScope is run, the function would rely on global state (the in-memory buffer backing the canvas, in rather inaccurate terms).

    In your case, if you want to draw a line as the user drags their finger around, you will need to save the points as they're received from the interactionsource, and them paint them in order. That will unfortunately get pretty expensive the more a user drags their finger across the canvas.

    I don't have a strong answer on how to solve this problem, but a quick Google search turned up this library: https://github.com/akshay2211/DrawBox which seems to do what you want to (and more). If anything, you could study how that's implemented, if not use it directly.