Drawing with compose

Whether you’re trying to build a painting app or you want to implement a signature pad, drawing on screen has never been easier.

Let’s explore how we can build a simple drawing canvas with Jetpack Compose Canvas and PointerInputScope.

Canvas

Jetpack Compose Canvas exposes DrawScope which can be used to draw shapes and paths. For example, a component that will draw a red rectangle can be defined as follow: Jetpack Compose Canvas exposes DrawScope which can be used to draw shapes and paths. For example, a component that will draw a red rectangle can be defined as follow:

                        @Composable
fun DrawingCanvas(
    modifier: Modifier = Modifier
) {
    Canvas(
        modifier = modifier
    ) {
        drawRect(Color.Red)
    }
}
                    

PointerInput

PointerInputScope is an API that helps detecting gestures such as tap, press, scroll, drag, swipe, etc. It’s possible to handle gesture at a even lower level by listening to pointer event, but it won’t be necessary for what we want to achieve.

Building a drawing canvas

We want to track 2 types of gestures:

  1. Tap or press. This would correspond to a dot on our screen
  2. Drag. This would correspond to the drawing on the screen

We need to record the gestures from the user and draw them on the screen in the canvas. We’re going to use drawPath which takes as main parameter a Path. A path is a list of instructions for drawing. For example, to draw a line:

                        fun DrawLine(from: Offset, to: Offset): Path {
    val path = Path()
    path.moveTo(from.x, from.y) // move to a specific coordinate
    path.lineTo(to.x, to.y) // draw a line to the destination
    return path
}
                    

We’re going to record two types of gestures: detectTapGestures and detectDragGestures. Let’s create a view model class that will track and record our gestures as paths:

                        class DrawingCanvasViewModel: ViewModel() {
    val paths = mutableStateListOf()
    val currentPath = mutableStateOf(null)
    val currentPathRef = mutableIntStateOf(1)
    private val lastOffset = mutableStateOf(null)

    // MARK: - Tap gestures

    fun onTapGesture(offset: Offset) {
        currentPath.value = Path()

        currentPath.value?.moveTo(offset.x, offset.y)
        currentPath.value?.addRect(
            Rect(
                offset.x - 0.5f,
                offset.y - 0.5f,
                offset.x + 0.5f,
                offset.y + 0.5f
            )
        )

        currentPath.value.let { value ->
            if (value != null) {
                paths.add(value)
            }
        }
    }

    // MARK: - Dragging gestures

    fun onDragStart(offset: Offset) {
        currentPath.value = Path()
        currentPath.value?.moveTo(offset.x, offset.y)
        currentPathRef.intValue += 1
        lastOffset.value = offset
    }

    fun onDrag(offset: Offset) {
        if (lastOffset.value != null) {
            val newOffset = Offset(
                lastOffset.value!!.x + offset.x,
                lastOffset.value!!.y + offset.y
            )
            currentPath.value?.lineTo(newOffset.x, newOffset.y)
            currentPathRef.intValue += 1
            lastOffset.value = newOffset
        }
    }

    fun onDragEnd() {
        currentPath.value.let { value ->
            if (value != null) {
                paths.add(value)
                currentPath.value = null
                currentPathRef.intValue = 0
            }
        }
    }

    fun onDragCancel() {
        currentPath.value = null
    }
}
                    

A few explanations:

  1. onTapGesture moves to the position and draw a small rectangle (this is a workaround to handle dots). This represents a Path on its own.
  2. onDragStart creates a new Path and instruct to move to the given position
  3. onDrag applies the dragging offset to the last recorded dragging position and add this instruction to the recording path.
  4. onDragEnd stores the recording path in the path list
  5. onDragCancel deletes the recording path
  6. currentPathRef is an integer that gets incremented as we’re updating the recording path. This will be observed by the canvas to update the drawing on screen as the input updates

Here’s what the composable looks like

                        @Composable
fun DrawingCanvas(
    modifier: Modifier = Modifier,
    vm: DrawingCanvasViewModel
) {
    Canvas(
        modifier = modifier
            .pointerInput(Unit) {
                detectTapGestures { vm.onTapGesture(it) }
            }
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { vm.onDragStart(it) },
                    onDragEnd = { vm.onDragEnd() },
                    onDragCancel = { vm.onDragCancel() },
                    onDrag = { _, amount -> vm.onDrag(amount) }
                )
            }
    ) {
        vm.paths.forEach { drawPath(path = it, color = Color.Black, style = Stroke(10f)) }

        if (vm.currentPath.value != null && vm.currentPathRef.intValue > 0) {
            drawPath(path = vm.currentPath.value!!, color = Color.Black, style = Stroke(10f))
        }
    }
}
                    

Note that the DrawScope has two instructions:

  1. It draws all the paths in the list of paths recorded by the view model
  2. It draws the current path (the one being recorded but not added to the list yet) ensuring the line appears as the user draws around

What to do from here?

There are many things that can be do to improve this code:

  1. We can implement a clear button that would simply remove all the paths
  2. We can implement “undo” and “redo” by removing the last added path and re-adding it
  3. We can implement a color palette
  4. etc.