How to draw on Jetpack Compose Canvas using touch events?

Issue

This is Q&A-style question since i was looking for a drawing sample with Jetpack Canvas but questions on stackoverflow, this one or another one, i found use pointerInteropFilter for drawing like View’s onTouchEvent MotionEvents which is not advised according to docs as

A special PointerInputModifier that provides access to the underlying
MotionEvents originally dispatched to Compose. Prefer pointerInput
and use this only for interoperation with existing code that consumes
MotionEvents.

While the main intent of this Modifier is to allow arbitrary code to
access the original MotionEvent dispatched to Compose, for
completeness, analogs are provided to allow arbitrary code to interact
with the system as if it were an Android View.

Solution

We need motion states as we have with View’s first

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

Path, current touch position and touch states

val path = remember { Path() }
var motionEvent by remember { mutableStateOf(ACTION_IDLE) }
var currentPosition by remember { mutableStateOf(Offset.Unspecified) }

These are optional for debugging, no need if you don’t want to debug

// color and text are for debugging and observing state changes and position
var gestureColor by remember { mutableStateOf(Color.LightGray) }
var gestureText by remember { mutableStateOf("Touch to Draw") }

Modifier for creating touch events. Modifier.clipToBounds() is to prevent drawing outside of Canvas.

val drawModifier = Modifier
    .fillMaxWidth()
    .height(400.dp)
    .background(gestureColor)
    .clipToBounds()
    .pointerInput(Unit) {
        forEachGesture {
            awaitPointerEventScope {

                // Wait for at least one pointer to press down, and set first contact position
                val down: PointerInputChange = awaitFirstDown().also {
                    motionEvent = ACTION_DOWN
                    currentPosition = it.position
                    gestureColor = Blue400
                }


                do {
                    // This PointerEvent contains details including events, id, position and more
                    val event: PointerEvent = awaitPointerEvent()

                    var eventChanges =
                        "DOWN changedToDown: ${down.changedToDown()} changedUp: ${down.changedToUp()}\n"
                    event.changes
                        .forEachIndexed { index: Int, pointerInputChange: PointerInputChange ->
                            eventChanges += "Index: $index, id: ${pointerInputChange.id}, " +
                                    "changedUp: ${pointerInputChange.changedToUp()}" +
                                    "pos: ${pointerInputChange.position}\n"

                            // This necessary to prevent other gestures or scrolling
                            // when at least one pointer is down on canvas to draw
                            pointerInputChange.consumePositionChange()
                        }

                    gestureText = "EVENT changes size ${event.changes.size}\n" + eventChanges

                    gestureColor = Green400
                    motionEvent = ACTION_MOVE
                    currentPosition = event.changes.first().position
                } while (event.changes.any { it.pressed })

                motionEvent = ACTION_UP
                gestureColor = Color.LightGray

                gestureText += "UP changedToDown: ${down.changedToDown()} " +
                        "changedUp: ${down.changedToUp()}\n"
            }
        }
    }

And apply this modifier to canvas and move or draw based on current state and position

Canvas(modifier = drawModifier) {

    when (motionEvent) {
        ACTION_DOWN -> {
            path.moveTo(currentPosition.x, currentPosition.y)
        }
        ACTION_MOVE -> {

            if (currentPosition != Offset.Unspecified) {
                path.lineTo(currentPosition.x, currentPosition.y)
            }
        }

        ACTION_UP -> {
            path.lineTo(currentPosition.x, currentPosition.y)
            // Change state to idle to not draw in wrong position 
           // if recomposition happens
          motionEvent = ACTION_IDLE
        }

        else -> Unit
    }

    drawPath(
        color = Color.Red,
        path = path,
        style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)
    )
}

Edit

There is also delay after down should be taken into consideration with awaitFirstDown and awaitPoiterEvent. I used 20ms delay with scope.launch{delay(20)} to overcome Canvas missing fast events.

enter image description here

Github repo is here.

Answered By – Thracian

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply

(*) Required, Your email will not be published