Tectes
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:
- Tap or press. This would correspond to a dot on our screen
- 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:
onTapGesture
moves to the position and draw a small rectangle (this is a workaround to handle dots). This represents a Path on its own.onDragStart
creates a new Path and instruct to move to the given positiononDrag
applies the dragging offset to the last recorded dragging position and add this instruction to the recording path.onDragEnd
stores the recording path in the path listonDragCancel
deletes the recording pathcurrentPathRef
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:
- It draws all the paths in the list of paths recorded by the view model
- 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:
- We can implement a clear button that would simply remove all the paths
- We can implement “undo” and “redo” by removing the last added path and re-adding it
- We can implement a color palette
- etc.