DEV Community 👩‍💻👨‍💻

Alex
Alex

Posted on • Updated on

Touch interactions in Jetpack Compose

After we learned how to draw different shapes on a Canvas in the first part of the series, let’s have a look at how to handle touch interaction in this second part.

Just as a reminder, with this topic we will be able to make our Labeled Range Slider draggable.

Interactive Labeled Range Slider

To keep it simple we will create a Scribble Composable, to explore how we can handle touch interactions.

Drawing Canvas

Touch Interaction

For a regular interactive Composable we would normally use either the Modifier clickable:

Canvas(
   modifier = Modifier.clickable {
      Log.i("Tag", "Canvas clicked")
   }
) {
   // draw something
}
Enter fullscreen mode Exit fullscreen mode

or the experimental combinedClickable:

Canvas(
   modifier = Modifier.combinedClickable(
      onClick = { Log.i("Tag", "Canvas clicked") },
      onLongClick = { Log.i("Tag", "Canvas long clicked") },
      onDoubleClick = { Log.i("Tag", "Canvas double clicked") }
   )
) {
   // draw something
}
Enter fullscreen mode Exit fullscreen mode

Both work great if we just want to handle a click on the whole Composable.

But for our example, we want to have a little bit more control. We need to know the exact location where the click happens and also if the user drags the finger across the screen or not. Luckily Compose offers us the Modifier pointerInput, which provides the information we need.

Gesture detectors

The easiest way to use pointerInput is to implement one of several already provided gesture detectors.

  • detectTapGestures: This is most similar to the combinedClickable Modifier, handling tap, double tap, press and long press. The difference this Modifier provides us with the exact position the user taped within our Composable.

  • detectDragGestures: With this detector we can detect the start, the end and the path a finger is dragged along in our Composable.

  • detectHorizontalDragGestures: As the name suggests, this is similar to detectDragGestures, but only provides us with the update for horizontal drag movements.

  • detectVerticalDragGestures: This is like detectHorizontalDragGestures, but only provides vertical drag movements.

  • detectDragGesturesAfterLongPress: Using this detector we get the same information as detectDragGestures, but only after the user performed a long press.

  • detectTransformGestures: The transformation gesture detector provides us with updated position information for rotating, panning and zooming gestures.

If these predefined detectors are still not enough and you want full control over how a touch is handled, you can use awaitPointerEventScope.

For our example of a Scribble Composable, we will focus on using detectDragGestures. Since this detector lets us know where the user is starting the touch and also constant updates while the finger is dragged across our Composable.

Canvas(
   modifier = modifier
      .pointerInput(key1 = Unit) {
         detectDragGestures(
            onDragStart = { touch ->
               Log.i("Tag", "Start of the interaction is $touch")
            },
            onDrag = { change, dragAmount ->
               Log.i("Tag", "Dragged $dragAmount; Result $change")
            }
         )
      }
) {
   // draw something
}
Enter fullscreen mode Exit fullscreen mode

Here we registered a drag gesture detector within pointerInput. For the parameter key1 we simply provide Unit. Since we don’t want our detection to be canceled when key1 changes. In pointerInput we then call detectDragGestures. This gives us the information where the user starts the interaction in the callback onDragStart, as well as continuous updates of the path the finger takes in onDrag.

The onDrag callback has two parameters, the second one gives us the delta to the last drag we got. This is not the absolute position of where the finger is right now, but only the change to the last position update. The first parameter on the other hand provides us with more details, like historical data and also the absolute position of the finger on our Composable.

To keep it simple we will be using the first parameter change to get the absolute position for creating a line drawing.

var points by remember { mutableStateOf<List<Offset>>(emptyList()) }

...
         detectDragGestures(
            onDragStart = { touch ->
               points = listOf(touch)
            },
            onDrag = { change, _ ->
               val newPoint = change.position
               points = points + newPoint
            }
         )
...
Enter fullscreen mode Exit fullscreen mode

First we create a mutable state points that can hold a list of Offsets, that will hold the path the finger was dragged along on our Canvas. When the drag gesture begins we create a new list and add the touch point to it. After that we simply can look at change.position, which gives us the absolute position of the touch on our Canvas and we can add this to our path.

This way works and we can get a decent result when drawing our path right now. But if we want to have a smoother result we should consider the historical data as well.

...
onDrag = { change, _ ->
   val pointsFromHistory = change.historical
      .map { it.position }
      .toTypedArray()
   val newPoints = listOf(*pointsFromHistory, change.position)
   points = points + newPoints
}
...
Enter fullscreen mode Exit fullscreen mode

Here we iterate over the historical data, which represents points between two onDrag updates, and get its positions. With that, we simply add the historical positions and the current position to our path list.

All that is now left to do is to draw the path on our Canvas, using the drawPath method we saw in the first part of this series.

...
if (points.size > 1) {
   val path = Path().apply {
      val firstPoint = points.first()
      val rest = points.subList(1, points.size - 1)

      moveTo(firstPoint.x, firstPoint.y)
      rest.forEach {
         lineTo(it.x, it.y)
      }
   }

   drawPath(path, color, style = Stroke(width = lineWidth.toPx()))
}
...
Enter fullscreen mode Exit fullscreen mode

When we have more than one point in our list we create a new Path object, move the start of the Path to our first point and then add all lines we have in our list to the Path. Since we want just lines to be draw without filling the area, we draw our path with the style Stroke.

In this implementation the tightness of the line to be drawn is parametrized as lineWidth of type Dp, but Stoke wants pixels as its width parameter. Luckily the DrawScope of our Canvas offers us with the extension function toPx, with which we can convert Dp to pixels.

Conclusion

We are now able to handle touch interactions on our custom Composable and translate them to actions. With that, we can draw our Labeled Range Slider and also interact with it.

Labeled Range Slider

But before we get into implementing it, we take a small excursion to the new Kotlin Context Receivers in part 3.

The whole code of the Scribble Composable can be found on GitHub.

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.