Gestures in Jetpack compose — All you need to know — Part 2

An In-Depth Guide to Jetpack Compose Gesture Handling.
Nov 22 2023 · 6 min read

Background

Welcome back to the second part of our in-depth exploration of Gestures in Jetpack Compose. In the first part, we delved into the fundamentals, covering a range of Gesture Modifiers and Detectors. We walked through the intricacies of detecting taps, movements like drag and swipe, and scroll gestures, providing a solid foundation to enhance your user interface with interactive elements.

Now, in this part, we will take our understanding of gestures in Jetpack Compose to the next level. We will unravel the complexities of handling multiple gestures concurrently, creating a fling effect, manually tracking interactions, disabling interactions when necessary, and even waiting for specific events.

Sponsored

We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!

How to handle multiple gestures together?

We can handle multi-touch like rotation, panning moving, and zooming on an element using a detector — detectTransformGesture or by high-level Modifier — transformable() Let’s see how.

a. Modifier.transformable

@Composable
fun TransformableStateExample() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }

    val state =
        rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
            scale *= zoomChange
            rotation += rotationChange
            offset += panChange
        })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black), contentAlignment = Alignment.Center
    ) {

        Text(
            text = "Hello World!",
            style = TextStyle(
                fontSize = 35.sp,
                brush = Brush.linearGradient(
                    colors = RainbowColors
                )
            ),
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y,
                    rotationZ = rotation
                ).transformable(state)

        )
    }
}

b. detectTransformGesture

@Composable
fun TransformGestureExample() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }

    Box(
        modifier = Modifier
            .fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center
    ) {

        Text(
            text = "Hello World!",
            style = TextStyle(
                fontSize = 35.sp,
                brush = Brush.linearGradient(
                    colors = RainbowColors
                )
            ),
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y,
                    rotationZ = rotation
                )
                .pointerInput(Unit) {
                    detectTransformGestures { _, pan, zoom, rotationChange ->
                        scale *= zoom
                        offset += pan
                        rotation += rotationChange
                    }
                }
        )

    }
}

The PointerInput.detectTransformGestures provides information about various transformations like scale, pan, and rotation in a single callback. You can use this callback to update your state or perform actions based on the combined gestures.

Both approaches achieve similar functionality, so you can choose the one that fits your coding style and preferences.

How to handle a fling effect with gesture?

By capturing the user’s touch events and calculating the velocity and direction of the swipe, we can create a smooth and responsive fling effect.

We can achieve a fling effect with VelocityTracker in Jetpack Compose. Let’s create a custom scrollable color slider.

@Composable
fun ColorSlider(
    modifier: Modifier = Modifier,
    colors: List<Color> = emptyList(),
) {

    val animatable = remember {
        Animatable(0f)
    }

    val itemWidth = with(LocalDensity.current) { 52.dp.toPx() }

    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Box(
            modifier = Modifier
                .fillMaxWidth(),
            contentAlignment = Alignment.TopCenter,
        ) {

            colors.forEachIndexed { index, color ->
                val offsetX = (index - animatable.value) * itemWidth
                Column(
                    modifier = Modifier
                        .width(50.dp)
                        .graphicsLayer(
                            translationX = offsetX
                        ),
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .background(color)
                    )
                }
            }

        }

        Icon(Icons.Filled.KeyboardArrowUp, tint = Color.White, contentDescription = null)
    }
}

Nothing fancy, just a simple UI that calculates the color box position and renders it. Let’s see the output.

Currently, a slider is not scrollable, let’s make it scrollable with pointerInput

private fun Modifier.fling(
    animatable: Animatable<Float, AnimationVector1D>,
    itemCount: Int,
    itemWidth: Float,
) = pointerInput(Unit) {
    coroutineScope {
        while (true) {
            // 1
            val pointerId = awaitPointerEventScope { awaitFirstDown().id }
            animatable.stop()
            awaitPointerEventScope {
                // 2
                horizontalDrag(pointerId) { change ->
                    val horizontalDragOffset =
                        animatable.value - change.positionChange().x / itemWidth
                    launch {
                        val value = horizontalDragOffset.coerceIn(0f, itemCount.toFloat())
                        // 3
                        animatable.snapTo(value)
                    }
                    // 4
                    change.consume()
                }
            }
        }
    }
}

We have created a custom extension Modifier, which takes animatable, itemCount and itemWidth

  1. It waits for the first pointer-down event and retrieves the id of the pointer.
  2. It waits for horizontal drag events for the specified pointer. Calculates the horizontal drag offset by considering the change in the x position of the pointer relative to the itemWidth.
  3. The snapTo function is called on animatable to snap to the new value.
  4. Finally, consume the changes.

Output:

You can see, the scrolling is not smooth. To fix it we’ll use decay animation with a velocity tracker. This improvement prevents the animation from coming to a sudden stop at arbitrary positions. Let’s modify the above Modifier to add decay animation first.

private fun Modifier.fling(
    animatable: Animatable<Float, AnimationVector1D>,
    itemCount: Int,
    itemWidth: Float,
) = pointerInput(Unit) {
    val decay = splineBasedDecay<Float>(this)
    val decayAnimationSpec = FloatSpringSpec(
        dampingRatio = Spring.DampingRatioLowBouncy,
        stiffness = Spring.StiffnessLow,
    )
 ....

Now let’s add VelocityTracker and track the position changes.

       ...
        val pointerId = awaitPointerEventScope { awaitFirstDown().id }
        animatable.stop()
        val tracker = VelocityTracker()
        awaitPointerEventScope {
            horizontalDrag(pointerId) { change ->
                ...
                tracker.addPosition(change.uptimeMillis, change.position)
                ...
            }
        }
    // rest of the code

tracker.addPosition(change.uptimeMillis, change.position) records the position of the pointer at the given time in the VelocityTracker. This information is crucial for estimating the velocity of the drag.

Now let’s calculate velocity and animate the value when the drag event is complete.

coroutineScope {
        ...
        val tracker = VelocityTracker()
        awaitPointerEventScope {
            ....
        }

        launch {
            val velocity = tracker.calculateVelocity().x / itemCount
            val targetValue = decay.calculateTargetValue(animatable.value, -velocity)
            val target = targetValue.roundToInt().coerceIn(0, itemCount).toFloat()
            animatable.animateTo(
                target, initialVelocity = velocity,
                animationSpec = decayAnimationSpec
            )
        }
    }
}

The decay.calculateTargetValue function is used to calculate the target value for the decay animation. It takes the current value of the animatable, and the negation of the velocity, indicating the direction of the decay.

Finally, the animatable is animated to the calculated target value using animateTo.

Now when we release the pointer after dragging, our color slider will animate to the target value and stop according to the velocity.

Output:

How to collect interaction manually?

Using MutableInteractionSource we can track the interaction event of the user such as when the button is pressed or released etc…

MutableInteractionSource provide stream of interactions of component.

Let’s see how we can collect interactions

@Composable
fun TrackInteractionDemo() {
    val interactionSource = remember { MutableInteractionSource() }
    var color by remember { mutableStateOf(Color.White) }
    Button({}, interactionSource = intSource) {
        Text("Click me!", color = color)
    }
    LaunchedEffect(Unit) {
        interactionSource.interactions.collect {
            color = when (it) {
                is PressInteraction.Press -> Color.Red
                else -> Color.White
            }
        }
    }
}

The LaunchedEffect observes interaction events, updating the color state accordingly. The result is a button that dynamically changes color based on user presses and releases.

Output:

Also, we can collect specific interaction as interaction state, something like this,

    val intSource = remember { MutableInteractionSource() }
    val isPressed = intSource.collectIsPressedAsState()
    val isDragged = intSource.collectIsDraggedAsState()
    val isFocused = intSource.collectIsFocusedAsState()
    val isHovered = intSource.collectIsHoveredAsState()

How to disable child composable interaction?

Suppose you have a complex UI composed of various interactive elements, and you want to disable user interactions when a certain condition is met, such as when data is loading or during a specific state.

The approach is to consume all the pointer events by the parent or top composable, something like this.

fun Modifier.gesturesDisabled(disabled: Boolean = true) =
    if (disabled) {
        pointerInput(Unit) {
            awaitPointerEventScope {
                while (true) {
                    awaitPointerEvent()
                        .changes
                        .forEach(PointerInputChange::consume)
                }
            }
        }
    } else {
        this
    }

Conclusion

Wrapping up Part 2 of our exploration into gestures in Jetpack Compose.

In this part, we went beyond the basics and explored how to do things like handling lots of gestures at once, making fling effects, and keeping track of what users are doing more manually. We also checked out how to watch what users are doing without taking over, stopping interaction when needed, and waiting for specific events.

Related Useful Articles


 


radhika-s image
Radhika saliya
Android developer | Sharing knowledge of Jetpack Compose & android development


radhika-s image
Radhika saliya
Android developer | Sharing knowledge of Jetpack Compose & android development

contact-footer
Say Hello!
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.