I work on a real estate app that was written in Kotlin with XML with many custom animations and views. A few months ago, we decided to migrate our application to Jetpack Compose. In our real estate app, we have a unique functionality that allows users to draw specific areas on the map, restricting ads to only those within the selected region. In the previous version of the app, we utilized a Custom View to draw polygons and then transformed screen coordinates to map coordinates.

In this post, I’ll demonstrate how we implemented this feature in Jetpack Compose. It will be a simple view that only can draw lines and nothing else, we will Canvas composable for this.

@Composable
fun MapDrawer() {
Canvas(
modifier = Modifier.fillMaxSize(),
onDraw = {

}
)
}

onDraw – lambda that will be called to perform drawing

In onDraw lambda we can call various method to draw. There are some of them:

drawPath()
drawLine()
drawArc()
drawCircle()
drawRect()

As you can see it’s pretty similar to Canvas that is used in XML based views.

You can read more about drawing:

For now, our attention won’t be spread across all features. Instead, we’ll exclusively focus on the implementation of drawPath in our example.

The next step involves dealing with touch events within our Canvas composable. To address this, we’ll leverage the pointerInput modifier, providing us with the capability to effectively handle all events taking place within our composable.

fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = this then SuspendPointerInputElement(
key1 = key1,
pointerInputHandler = block
)

It receives only 2 parameters: key and block. For key we will just pass Unit. So we have something like this

@Composable
fun MapDrawer() {
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit){

},
onDraw = {

}
)
}

To actively handle events, we need to invoke the awaitEachGesture function. This is a suspend function that runs in a loop while the composable is active. Within this function, we use awaitPointerEvent to manage the current event. It’s essential to call this function inside a while loop to continuously receive events.

So we will have something like this:

@Composable
fun MapDrawer() {
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
do {
val event: PointerEvent = awaitPointerEvent()
} while (event.changes.any { it.pressed })
}
},
onDraw = {}
)
}

Our event variable contains lots of information about happened event. But we will use changes property to get information that we need to draw.

@Composable
fun MapDrawer() {
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
do {
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { changes ->
// contains coordinates where event happened
val position = changes.position
}
} while (event.changes.any { it.pressed })
}
},
onDraw = {}
)
}

Now let’s create a state object which will contains information about touch event.

enum class DrawerMotionEvent { idle, down, up, move }

data class MapPolygonState(
val currentPosition: Offset = Offset.Unspecified,
val event: MotionEvent = MotionEvent.idle
)

Let’s update our composable:

@Composable
fun MapDrawer() {
val path = remember { Path() }
var state by remember { mutableStateOf(MapPolygonState()) }
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
do {
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { changes ->
// Updates state with new event and position
state = state.copy(
currentPosition = changes.position,
event = DrawerMotionEvent.move
)
}
} while (event.changes.any { it.pressed })
}
},
onDraw = {}
)
}

In the code, we update our state object every time when new event handled, which will initiate recomposition of our composable on every touch. We also have path variable here which will holds all points.

Now let’s jump on to onDraw block. Here we will add points to our path and draw it on canvas. We just need to draw our path

onDraw = {
when (state.event) {
DrawerMotionEvent.idle -> Unit
DrawerMotionEvent.up, DrawerMotionEvent.move -> path.lineTo(
state.currentPosition.x,
state.currentPosition.y
)
DrawerMotionEvent.down -> path.moveTo(state.currentPosition.x, state.currentPosition.y)
}
drawPath(
path = path,
brush = brush,
style = Stroke(width = 5f)
)
}

Our composable will looks like this:

And here is the example:

As you can see we have straight line drawn from top left corner. That’s because Path always start from (0,0) point. To fix this we can add this in our awaitEachGesture function before our loop block

awaitPointerEvent().changes.first().also { changes ->
val position = changes.position
state = state.copy(
currentPosition = position,
event = DrawerMotionEvent.down
)
}

By adding this before entering the loop, we ensure that the starting point of the path corresponds to the initial touch position. This adjustment sets the path to begin at the touch point, providing a more intuitive drawing experience.

So on our first touch we will move our path to correct location. Now let’s try again

Yay, we draw in Jetpack Compose!

Here is the full code of Composable:

That wraps up the initial part of our tutorial. Today, we covered the fundamentals of drawing on the Canvas using its methods and also handling touch events in Jetpack Compose.

In the next part our journey will shift towards the transformation of our drawings into Google Maps polygons and extracting their real coordinate bounds.

Part 2:

Feel free to follow me on Twitter and don’t hesitate to ask questions related to Jetpack Compose.

Twitter: https://twitter.com/a_rasul98

Also check out my other post related to Jetpack Compose:

Thanks for reading and see you later!

Source link