← Back to list

Multitouch Gestures in Jetpack Compose: The Complete Practical Guide

Multitouch gestures pinching to zoom, rotating with two fingers, and dragging content across the screen, are fundamental interactions that…

Ramadan Sayed · 2025-10-10 21:20 · 13 claps · 20.3 min read paywalled
#jetpack-compose #zoom #scale #rotate #pan
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval 📱 · Mobile Development

Multitouch Gestures in Jetpack Compose: The Complete Practical Guide

Multitouch gestures pinching to zoom, rotating with two fingers, and dragging content across the screen, are fundamental interactions that users expect in modern mobile applications. Whether you’re building a photo gallery, a map viewer, a PDF reader, or any visual application, implementing smooth and intuitive multitouch gestures is essential. Jetpack Compose, Android’s modern UI toolkit, provides elegant and powerful APIs that make implementing these gestures both straightforward and highly performant.

This comprehensive guide takes you through everything you need to know about multitouch handling in Compose, from the foundational concepts to production-ready implementations. You’ll learn how to build responsive, fluid interfaces that feel natural under users’ fingertips.

@Composable
private fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableFloatStateOf(1f) }
    var rotation by remember { mutableFloatStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

Understanding Multitouch Fundamentals

The Three Core Transformations

Multitouch gestures in Compose revolve around three fundamental transformations that users understand intuitively:

1. Zoom (Scaling)

Zooming changes the size of content through the classic “pinch” gesture. In Compose, zoom is expressed as a multiplicative factor rather than an absolute value. When you receive a zoomChange of 1.5, it means "increase the current size by 50%," not "set the size to 150% absolute."

This relative approach is crucial for smooth gestures:

  • Current scale: 1.0 (100%)
  • Zoom change: 1.5
  • New scale: 1.5 (150%)
  • Next zoom change: 1.2
  • Final scale: 1.8 (1.5 × 1.2)

2. Rotate

Rotation involves twisting two or more fingers around a pivot point. Measured in degrees, rotation accumulates over the gesture’s lifetime. A rotationChange of 45 means "rotate 45 degrees clockwise from the current rotation angle."

Example sequence:

  • Initial rotation: 0°
  • First change: +30°
  • Current rotation: 30°
  • Second change: +45°
  • Final rotation: 75°

3. Pan (Translate)

Panning moves content across the screen by dragging with one or more fingers. Measured as pixel offsets on both X and Y axes, pan also accumulates. An offsetChange of Offset(100f, -50f) means "move 100 pixels right and 50 pixels up from the current position."

Example sequence:

  • Initial offset: (0, 0)
  • First change: (50, 30)
  • Current offset: (50, 30)
  • Second change: (100, -50)
  • Final offset: (150, -20)

The Architectural Principle: Separation of Concerns

One of the most elegant aspects of Compose’s multitouch system is its clear separation between gesture detection and transformation application. This isn’t just a nice architectural pattern — it’s a fundamental design decision that provides enormous flexibility.

What This Means:

The transformable modifier detects when users perform multitouch gestures and calculates the changes (zoom multipliers, pan offsets, rotation angles). However, it never directly modifies your UI state. Instead, it reports these changes through callbacks, giving you complete control over how gestures affect your application.

Why This Matters:

  • Flexible Constraints: Easily limit zoom ranges (e.g., 0.5x to 5x), pan boundaries, or rotation angles
  • Conditional Logic: Implement sophisticated behaviors like “only pan when zoomed in” or “disable rotation for small images”
  • Complex Interactions: Combine multiple transformation sources (gestures + buttons + keyboard) seamlessly
  • Performance Control: Debounce, throttle, or batch updates as needed for your specific use case
  • Testability: Test gesture detection and transformation logic independently

Getting Started: Your First Transformable

Let’s build a complete, working multitouch example from scratch, understanding each piece as we go.

Step 1: Set Up State Variables

First, we need state variables to track all three transformations:

@Composable
fun BasicTransformable() {
    // Scale state: 1f = 100% (original size)
    var scale by remember { mutableFloatStateOf(1f) }

    // Rotation state: accumulated rotation in degrees
    var rotation by remember { mutableFloatStateOf(0f) }

    // Offset state: accumulated translation from original position
    var offset by remember { mutableStateOf(Offset.Zero) }

Why these specific state types?

  • mutableFloatStateOf(1f) for scale and rotation: These are primitive float values. Using the specialized float state type is more efficient than boxing primitives in mutableStateOf.
  • mutableStateOf(Offset.Zero) for offset: Offset is a data class containing two floats (x and y), so we use the standard state type.
  • All wrapped in remember: This ensures the state survives recompositions. Without remember, the state would reset on every recomposition.

Step 2: Create the TransformableState

Next, we create the state handler that responds to gestures:

val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        // Apply zoom by multiplication
        scale *= zoomChange

        // Apply rotation by addition
        rotation += rotationChange

        // Apply pan by addition
        offset += offsetChange
    }

Understanding the Mathematics:

  • Zoom: We multiply (scale *= zoomChange) because zoom is relative. If the current scale is 2.0 and we receive a zoom change of 1.5, the new scale should be 3.0 (2.0 × 1.5), not 1.5.
  • Rotation: We add (rotation += rotationChange) because rotation accumulates. If the user has already rotated 45° and rotates another 30°, the total should be 75°, not just 30°.
  • Pan: We add (offset += offsetChange) because panning accumulates. Each drag gesture adds to the total displacement from the original position.

Getting this wrong (using assignment instead of accumulation) is the most common mistake and results in jerky, unnatural gestures.

Step 3: Apply Transformations to UI

Now we apply these transformations to our content:

Box(
        Modifier
            .fillMaxSize()
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            .transformable(state = state)
            .background(Color.Blue)
    )
}

Critical: Modifier Order Matters!

The order graphicsLayertransformablebackground is not arbitrary:

  1. graphicsLayer: Applies visual transformations
  2. transformable: Detects gestures in the original, untransformed coordinate space
  3. background: Draws the actual content

If you place transformable before graphicsLayer, the touch-sensitive area would move with the visual transformation, creating a confusing experience where users must "chase" the content to continue gesturing.

The TransformableState Interface

Understanding the Interface

At the heart of the system is the TransformableState interface:

interface TransformableState {
    suspend fun transform(
        transformPriority: MutatePriority = MutatePriority.Default,
        block: suspend TransformScope.() -> Unit,
    )

    val isTransformInProgress: Boolean
}

The transform Function:

This suspend function is the gateway to all transformation operations. Any code that modifies zoom, pan, or rotation must execute within a transform block. This ensures mutual exclusion—only one transformation can occur at a time.

The transformPriority parameter is powerful:

  • MutatePriority.UserInput: Highest priority (gesture input)
  • MutatePriority.Default: Normal priority (programmatic animations)
  • Higher-priority transformations can cancel lower-priority ones

This means a user can interrupt an ongoing programmatic zoom animation with a gesture, and the system smoothly transitions from automatic to manual control.

The isTransformInProgress Property:

This boolean tells you whether a transformation is currently active. Use it to:

  • Show visual feedback during gestures (e.g., different cursor icons)
  • Disable conflicting interactions (e.g., disable scrolling during zoom)
  • Coordinate with other UI elements (e.g., hide controls during transformation)
  • Implement gesture-aware animations

The TransformScope

Inside a transform block, you work within a TransformScope:

interface TransformScope {
    fun transformBy(
        zoomChange: Float = 1f,
        panChange: Offset = Offset.Zero,
        rotationChange: Float = 0f,
    )
}

This method is where transformation deltas are applied:

  • zoomChange: Multiplicative scale factor (1.0 = no change, 1.5 = increase by 50%, 0.8 = decrease by 20%)
  • panChange: Offset containing X and Y pixel changes
  • rotationChange: Degrees to rotate (positive = clockwise, negative = counterclockwise)

Creating TransformableState

Use rememberTransformableState to create and remember state:

val state = rememberTransformableState { zoomChange, panChange, rotationChange ->
    // Your transformation logic here
    scale *= zoomChange
    offset += panChange
    rotation += rotationChange
}

Important: Always use rememberTransformableState in composables, never create TransformableState directly. The remember wrapper ensures the state survives recompositions and configuration changes.

The GraphicsLayer Modifier: Your Performance Secret

The graphicsLayer modifier is absolutely essential for smooth, performant transformations. Understanding why is key to building great multitouch experiences.

Why GraphicsLayer is Essential

1. GPU Acceleration

All transformations happen on the GPU, not the CPU. This is the difference between smooth 60fps animations and janky, stuttering interactions. The GPU is specifically designed for these types of matrix transformations.

2. No Layout Impact

graphicsLayer transforms the visual representation without triggering layout recalculation. The original layout bounds remain unchanged, which means:

  • No expensive remeasurement of child composables
  • No recomposition of parent composables
  • Siblings aren’t affected by the transformation

3. Hardware Layer Caching

The modifier creates an offscreen buffer (hardware layer) that caches the rendered content. Subsequent transformations only need to transform this cached layer, not re-render the entire content tree. This is especially important for complex UIs with many nested composables.

4. Advanced Compositing

The compositingStrategy parameter gives you fine-grained control:

// Auto (default): Creates offscreen buffer when needed
CompositingStrategy.Auto

// Offscreen: Always renders to buffer first (for blend modes)
CompositingStrategy.Offscreen
// ModulateAlpha: Applies alpha without buffer (more efficient but different rendering)
CompositingStrategy.ModulateAlpha

Key Parameters for Multitouch

Modifier.graphicsLayer(
    scaleX = 1f,              // Horizontal scaling
    scaleY = 1f,              // Vertical scaling
    alpha = 1f,               // Opacity (0f to 1f)
    translationX = 0f,        // Horizontal offset in pixels
    translationY = 0f,        // Vertical offset in pixels
    rotationZ = 0f,           // 2D rotation in degrees
    transformOrigin = TransformOrigin.Center,  // Pivot point
    clip = false              // Whether to clip to bounds
)

For Standard Multitouch:

  • scaleX, scaleY: Set both to the same value for uniform scaling
  • translationX, translationY: Pan offsets
  • rotationZ: 2D rotation (what users expect)
  • transformOrigin: Default center is usually what you want

Advanced Options:

  • rotationX, rotationY: 3D rotations for perspective effects
  • cameraDistance: Controls 3D perspective depth
  • shadowElevation: Adds elevation shadow
  • renderEffect: Apply visual effects (blur, etc.)

Lambda vs. Parameter Syntax

// Parameter syntax - triggers recomposition when scale changes
Modifier.graphicsLayer(scaleX = scale, scaleY = scale)
// Lambda syntax - only updates layer, no recomposition
Modifier.graphicsLayer {
    scaleX = scale
    scaleY = scale
}

For frequently updating values (like during gestures), the lambda syntax is more performant because it reads state during the draw phase rather than composition phase.

Essential Production Patterns

These patterns are battle-tested and should be in every production app that uses multitouch.

Pattern 1: Implementing Constraints

Never ship a transformable without constraints. Users will zoom to absurd levels or pan into oblivion.

val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
    // Constrain scale to reasonable range (0.5x to 5x)
    scale = (scale * zoomChange).coerceIn(0.5f, 5f)

    // Normalize rotation to 0-360 degrees
    rotation = (rotation + rotationChange).mod(360f)

    // Constrain panning to boundaries
    val maxOffset = 1000f
    offset = Offset(
        x = (offset.x + offsetChange.x).coerceIn(-maxOffset, maxOffset),
        y = (offset.y + offsetChange.y).coerceIn(-maxOffset, maxOffset)
    )
}

Why these specific ranges?

  • 0.5x to 5x zoom: Below 0.5x content becomes too small to see; above 5x it becomes pixelated
  • 0–360° rotation: Normalizing keeps the value manageable and prevents floating-point drift
  • ±1000px pan: Adjust based on your content size; this prevents users from panning too far

Pattern 2: Content-Aware Pan Boundaries

For image viewers and similar apps, calculate boundaries based on actual content size:

@Composable
fun BoundedImageViewer(painter: Painter) {
    var scale by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var imageSize by remember { mutableStateOf(IntSize.Zero) }
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    val state = rememberTransformableState { zoomChange, offsetChange, _ ->
        // Update scale with constraints
        scale = (scale * zoomChange).coerceIn(1f, 5f)

        // Calculate maximum allowed offset based on current zoom level
        val scaledWidth = imageSize.width * scale
        val scaledHeight = imageSize.height * scale

        // When zoomed in, content can be larger than container
        // maxX/Y is how far we can pan before hitting the edge
        val maxX = ((scaledWidth - containerSize.width) / 2f).coerceAtLeast(0f)
        val maxY = ((scaledHeight - containerSize.height) / 2f).coerceAtLeast(0f)

        // Apply pan with calculated boundaries
        offset = Offset(
            x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX),
            y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY)
        )
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .onSizeChanged { containerSize = it }
    ) {
        Image(
            painter = painter,
            contentDescription = null,
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(state = state)
                .onGloballyPositioned { coordinates ->
                    imageSize = coordinates.size
                }
        )
    }
}

The Math Explained:

When content is zoomed in beyond container size:

  • Scaled content width: imageSize.width * scale
  • Overflow: scaledWidth - containerSize.width
  • Max pan in each direction: overflow / 2 (can pan half the overflow in each direction)
  • coerceAtLeast(0f): When zoomed out, no panning should be allowed

Pattern 3: Conditional Panning (Only When Zoomed)

One of the most common patterns is to only allow panning when content is zoomed in. This prevents conflicts with scrollable parent containers:

val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
    // Update scale with constraints
    scale = (scale * zoomChange).coerceIn(1f, 5f)

    // Update rotation
    rotation += rotationChange

    // Only allow panning when zoomed beyond 100%
    if (scale > 1f) {
        offset += offsetChange
    } else {
        // Auto-center when at minimum zoom
        offset = Offset.Zero
    }
}

Why this works:

  • At 1x zoom (100%), content fits in the viewport — no need to pan
  • When zoomed in, panning is necessary to explore the content
  • Auto-resetting to Offset.Zero ensures content re-centers when zooming out
  • Prevents confusing interactions with parent scrollables

Pattern 4: Smooth Reset with Animation

Users expect smooth transitions, especially when resetting transformations:

@Composable
fun TransformableWithReset() {
    var scale by remember { mutableFloatStateOf(1f) }
    var rotation by remember { mutableFloatStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }

    val coroutineScope = rememberCoroutineScope()

    Column {
        Box(
            Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(state = state)
                .fillMaxSize()
                .weight(1f)
                .background(Color.LightGray)
        )

        Button(
            onClick = {
                coroutineScope.launch {
                    // Calculate the deltas needed to return to initial state
                    val targetZoomFactor = 1f / scale  // If scale is 2f, we need 0.5f to get back to 1f
                    val targetRotation = -rotation     // Rotate back by negative of current rotation
                    val targetPan = -offset           // Move back by negative of current offset

                    // Animate all three transformations simultaneously
                    state.animateBy(
                        zoomFactor = targetZoomFactor,
                        rotationDegrees = targetRotation,
                        panOffset = targetPan,
                        zoomAnimationSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy
                        ),
                        panAnimationSpec = spring(
                            dampingRatio = Spring.DampingRatioLowBouncy
                        ),
                        rotationAnimationSpec = tween(durationMillis = 400)
                    )

                    // Update state variables after animation completes
                    scale = 1f
                    rotation = 0f
                    offset = Offset.Zero
                }
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text("Reset All Transformations")
        }
    }
}

Animation Spec Choices:

  • spring() with DampingRatioMediumBouncy for zoom: Creates a pleasant bounce effect
  • spring() with DampingRatioLowBouncy for pan: More pronounced bounce for position
  • tween() for rotation: Linear timing often feels better for rotation

Pattern 5: Double-Tap to Zoom

A standard interaction pattern users expect in image viewers:

@Composable
fun DoubleTapZoomable(painter: Painter) {
    var scale by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    val state = rememberTransformableState { zoomChange, offsetChange, _ ->
        scale = (scale * zoomChange).coerceIn(1f, 4f)
        offset += offsetChange
    }

    val coroutineScope = rememberCoroutineScope()

    Image(
        painter = painter,
        contentDescription = null,
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                translationX = offset.x,
                translationY = offset.y
            )
            .transformable(state = state)
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = { tapOffset ->
                        coroutineScope.launch {
                            if (scale > 1f) {
                                // Currently zoomed in - zoom out with animation
                                state.animateBy(
                                    zoomFactor = 1f / scale,
                                    panOffset = -offset,
                                    rotationDegrees = 0f,
                                    zoomAnimationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                                scale = 1f
                                offset = Offset.Zero
                            } else {
                                // Currently at 100% - zoom to 200%
                                state.animateZoomBy(2f)
                                scale = 2f

                                // Optional: center on tap position
                                // This requires more complex math to calculate the offset
                            }
                        }
                    }
                )
            }
    )
}

User Experience Considerations:

  • Double-tap toggles between 100% and 200% zoom
  • Smooth spring animation makes the transition feel natural
  • Alternative: Triple-tap for additional zoom levels (100% → 200% → 400% → 100%)

Pattern 6: Rotation Lock Toggle

For apps where rotation isn’t always desired:

@Composable
fun RotationLockableViewer(painter: Painter) {
    var scale by remember { mutableFloatStateOf(1f) }
    var rotation by remember { mutableFloatStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var isRotationLocked by remember { mutableStateOf(false) }

    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        offset += offsetChange

        // Only apply rotation if not locked
        if (!isRotationLocked) {
            rotation += rotationChange
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        Image(
            painter = painter,
            contentDescription = null,
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(
                    state = state,
                    lockRotationOnZoomPan = isRotationLocked
                )
        )

        // Lock toggle button
        IconButton(
            onClick = { isRotationLocked = !isRotationLocked },
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp)
        ) {
            Icon(
                imageVector = if (isRotationLocked) 
                    Icons.Default.Lock 
                else 
                    Icons.Default.LockOpen,
                contentDescription = if (isRotationLocked) 
                    "Unlock rotation" 
                else 
                    "Lock rotation"
            )
        }
    }
}

The lockRotationOnZoomPan Parameter:

When true, rotation is only enabled if the user's initial gesture is primarily rotational. If they start with a pinch or pan, rotation is disabled for that gesture. This prevents accidental rotation during zoom operations.

Real-World Complete Examples

Let’s build three complete, production-ready implementations you can use as templates.

Example 1: Professional Image Viewer

A full-featured image viewer with zoom, pan, indicators, and reset:

@Composable
fun ProfessionalImageViewer(
    painter: Painter,
    modifier: Modifier = Modifier
) {
    var scale by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var imageSize by remember { mutableStateOf(IntSize.Zero) }
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    val maxScale = 5f
    val minScale = 1f

    val state = rememberTransformableState { zoomChange, offsetChange, _ ->
        val newScale = (scale * zoomChange).coerceIn(minScale, maxScale)

        // Calculate boundaries
        val scaledWidth = imageSize.width * newScale
        val scaledHeight = imageSize.height * newScale
        val maxX = ((scaledWidth - containerSize.width) / 2f).coerceAtLeast(0f)
        val maxY = ((scaledHeight - containerSize.height) / 2f).coerceAtLeast(0f)

        scale = newScale

        if (scale > 1f) {
            offset = Offset(
                x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX),
                y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY)
            )
        } else {
            offset = Offset.Zero
        }
    }

    val coroutineScope = rememberCoroutineScope()

    Box(
        modifier = modifier
            .fillMaxSize()
            .onSizeChanged { containerSize = it }
    ) {
        Image(
            painter = painter,
            contentDescription = "Zoomable image",
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .transformable(
                    state = state,
                    canPan = { scale > 1f }
                )
                .pointerInput(Unit) {
                    detectTapGestures(
                        onDoubleTap = {
                            coroutineScope.launch {
                                if (scale > 1f) {
                                    state.animateBy(
                                        zoomFactor = 1f / scale,
                                        panOffset = -offset,
                                        rotationDegrees = 0f,
                                        zoomAnimationSpec = spring(
                                            dampingRatio = Spring.DampingRatioMediumBouncy
                                        ),
                                        panAnimationSpec = spring(
                                            dampingRatio = Spring.DampingRatioLowBouncy
                                        )
                                    )
                                    scale = 1f
                                    offset = Offset.Zero
                                } else {
                                    state.animateZoomBy(2f)
                                    scale = 2f
                                }
                            }
                        }
                    )
                }
                .onGloballyPositioned { coordinates ->
                    imageSize = coordinates.size
                }
        )

        // Zoom indicator
        AnimatedVisibility(
            visible = scale > 1f,
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp),
            enter = fadeIn() + scaleIn(),
            exit = fadeOut() + scaleOut()
        ) {
            Surface(
                shape = RoundedCornerShape(8.dp),
                color = Color.Black.copy(alpha = 0.6f)
            ) {
                Text(
                    text = "${(scale * 100).toInt()}%",
                    modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
                    color = Color.White,
                    fontSize = 14.sp
                )
            }
        }

        // Reset button
        AnimatedVisibility(
            visible = scale > 1f || offset != Offset.Zero,
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp),
            enter = fadeIn() + scaleIn(),
            exit = fadeOut() + scaleOut()
        ) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch {
                        state.animateBy(
                            zoomFactor = 1f / scale,
                            panOffset = -offset,
                            rotationDegrees = 0f
                        )
                        scale = 1f
                        offset = Offset.Zero
                    }
                }
            ) {
                Icon(Icons.Default.Restore, "Reset zoom")
            }
        }
    }
}

Features Implemented:

  • Content-aware pan boundaries
  • Zoom percentage indicator
  • Animated reset button
  • Double-tap to toggle zoom
  • Smooth spring animations
  • Clean, intuitive UI

Example 2: Interactive Map Viewer

A map interface with rotation support and visual controls:

@Composable
fun InteractiveMapViewer(
    mapContent: @Composable () -> Unit
) {
    var scale by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var rotation by remember { mutableFloatStateOf(0f) }
    var isRotationLocked by remember { mutableStateOf(false) }

    // Handles both touch and Ctrl+Scroll zoom automatically
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale = (scale * zoomChange).coerceIn(0.5f, 10f)
        offset += offsetChange

        if (!isRotationLocked) {
            rotation = (rotation + rotationChange).mod(360f)
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // Main map content
        Box(
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y,
                    transformOrigin = TransformOrigin.Center
                )
                .transformable(
                    state = state,
                    lockRotationOnZoomPan = isRotationLocked
                )
        ) {
            mapContent()
        }

        // Control panel
        Column(
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            // Rotation lock toggle
            Surface(
                shape = CircleShape,
                color = if (isRotationLocked) 
                    MaterialTheme.colorScheme.primary 
                else 
                    MaterialTheme.colorScheme.surface,
                onClick = { isRotationLocked = !isRotationLocked },
                modifier = Modifier.size(48.dp)
            ) {
                Box(contentAlignment = Alignment.Center) {
                    Icon(
                        imageVector = if (isRotationLocked) 
                            Icons.Default.Lock 
                        else 
                            Icons.Default.LockOpen,
                        contentDescription = "Toggle rotation lock"
                    )
                }
            }

            // Compass indicator (shows north direction)
            Surface(
                shape = CircleShape,
                color = MaterialTheme.colorScheme.surface,
                modifier = Modifier.size(48.dp)
            ) {
                Box(
                    // Counter-rotate to always point north
                    modifier = Modifier.graphicsLayer(rotationZ = -rotation),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(
                        imageVector = Icons.Default.Navigation,
                        contentDescription = "North indicator",
                        tint = MaterialTheme.colorScheme.primary
                    )
                }
            }

            // Zoom level indicator
            Surface(
                shape = RoundedCornerShape(8.dp),
                color = MaterialTheme.colorScheme.surface
            ) {
                Text(
                    text = "${(scale * 100).toInt()}%",
                    modifier = Modifier.padding(8.dp),
                    style = MaterialTheme.typography.bodySmall
                )
            }
        }
    }
}

Features Implemented:

  • Full rotation support with toggle lock
  • Compass that always points north
  • Wide zoom range (50% to 1000%)
  • Rotation lock prevents accidental twisting
  • Clean control panel UI

Example 3: Photo Gallery with Pager

A swipeable gallery where each image can be zoomed:

@Composable
fun PhotoGalleryWithZoom(images: List<Painter>) {
    val pagerState = rememberPagerState(pageCount = { images.size })

    HorizontalPager(
        state = pagerState,
        modifier = Modifier.fillMaxSize()
    ) { page ->
        // Each page has its own transformation state
        var scale by remember { mutableFloatStateOf(1f) }
        var offset by remember { mutableStateOf(Offset.Zero) }

        val state = rememberTransformableState { zoomChange, offsetChange, _ ->
            scale = (scale * zoomChange).coerceIn(1f, 5f)

            // Only allow panning when zoomed in
            if (scale > 1f) {
                offset += offsetChange
            } else {
                offset = Offset.Zero
            }
        }

        Box(modifier = Modifier.fillMaxSize()) {
            Image(
                painter = images[page],
                contentDescription = "Image ${page + 1} of ${images.size}",
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer(
                        scaleX = scale,
                        scaleY = scale,
                        translationX = offset.x,
                        translationY = offset.y
                    )
                    .transformable(
                        state = state,
                        canPan = { scale > 1f }
                    )
            )

            // Page indicator
            Text(
                text = "${page + 1} / ${images.size}",
                modifier = Modifier
                    .align(Alignment.TopCenter)
                    .padding(16.dp)
                    .background(
                        Color.Black.copy(alpha = 0.6f),
                        RoundedCornerShape(16.dp)
                    )
                    .padding(horizontal = 16.dp, vertical = 8.dp),
                color = Color.White
            )
        }
    }
}

Key Design Decision:

Each page maintains its own transformation state. This means:

  • Zooming one image doesn’t affect others
  • Users can compare images at different zoom levels
  • State is preserved when swiping back and forth
  • Prevents confusion when navigating between pages

The canPan predicate prevents conflict: when zoomed out (scale = 1f), horizontal drags navigate between pages. When zoomed in (scale > 1f), horizontal drags pan the image.

Advanced Features

The canPan Parameter

The canPan parameter is crucial for integrating transformable content with scrollable containers:

fun Modifier.transformable(
    state: TransformableState,
    canPan: (Offset) -> Boolean,  // This parameter!
    lockRotationOnZoomPan: Boolean = false,
    enabled: Boolean = true,
)

How it works:

The lambda receives the proposed pan offset and returns whether panning should be allowed:

// Only allow panning when zoomed in
canPan = { scale > 1f }

// Only allow horizontal panning
canPan = { offset -> offset.y == 0f }
// Allow panning only within bounds
canPan = { offset ->
    val newX = currentOffset.x + offset.x
    val newY = currentOffset.y + offset.y
    newX in -maxX..maxX && newY in -maxY..maxY
}

Why this matters:

Without canPan, a transformable image inside a LazyColumn would consume all pan gestures, preventing scrolling. With canPan = { scale > 1f }, pan gestures only work when zoomed in, allowing the parent to scroll normally at 1x zoom.

Lock Rotation on Zoom/Pan

The lockRotationOnZoomPan parameter prevents accidental rotation:

Modifier.transformable(
    state = state,
    lockRotationOnZoomPan = true  // Rotation only if detected first
)

How it works:

When true, the system checks which gesture type exceeds touch slop first:

  • If rotation is detected first → all gestures work normally
  • If zoom or pan is detected first → rotation is disabled for this gesture

This prevents the common frustration of accidentally rotating while trying to pinch-zoom.

Desktop Support: Automatic Ctrl+Scroll Zoom

One of the best features: Ctrl+Scroll zoom works automatically on desktop:

// This handles both touch gestures AND Ctrl+Scroll
Modifier
    .graphicsLayer(/* transformations */)
    .transformable(state = state)

The zoom formula: 2^(scrollPixels / 545)

This logarithmic formula ensures:

  • Equal scroll distances produce equal zoom ratios
  • Natural, predictable feel
  • Consistent with other desktop apps

No configuration needed! The system automatically detects mouse input and enables Ctrl+Scroll handling.

Programmatic Animations

You can trigger smooth transformations programmatically, not just from gestures.

Individual Transformation Animations

Zoom Animation:

val coroutineScope = rememberCoroutineScope()

Button(onClick = {
    coroutineScope.launch {
        // Animate to 2.5x zoom
        transformableState.animateZoomBy(
            zoomFactor = 2.5f,
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioMediumBouncy,
                stiffness = Spring.StiffnessLow
            )
        )
        // Update your state variable
        scale *= 2.5f
    }
}) {
    Text("Zoom to 250%")
}

Rotation Animation:

Button(onClick = {
    coroutineScope.launch {
        // Rotate 90 degrees clockwise
        transformableState.animateRotateBy(
            degrees = 90f,
            animationSpec = tween(
                durationMillis = 300,
                easing = FastOutSlowInEasing
            )
        )
        rotation += 90f
    }
}) {
    Text("Rotate 90°")
}

Pan Animation:

Button(onClick = {
    coroutineScope.launch {
        // Move 200px right, 100px up
        transformableState.animatePanBy(
            offset = Offset(x = 200f, y = -100f),
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioNoBouncy,
                stiffness = Spring.StiffnessMedium
            )
        )
        offset += Offset(200f, -100f)
    }
}) {
    Text("Pan Right")
}

Combined Animations

Animate multiple transformations simultaneously:

Button(onClick = {
    coroutineScope.launch {
        // Zoom, pan, and rotate all at once
        transformableState.animateBy(
            zoomFactor = 2f,
            panOffset = Offset(-100f, -100f),
            rotationDegrees = 45f,
            zoomAnimationSpec = spring(stiffness = Spring.StiffnessMedium),
            panAnimationSpec = tween(durationMillis = 400),
            rotationAnimationSpec = spring(dampingRatio = Spring.DampingRatioHighBouncy)
        )

        // Update state variables
        scale *= 2f
        offset += Offset(-100f, -100f)
        rotation += 45f
    }
}) {
    Text("Zoom & Rotate")
}

Each transformation can have its own animation spec, allowing for creative effects like fast zoom with slow rotation.

Instant Transformations (No Animation)

For immediate changes:

Button(onClick = {
    coroutineScope.launch {
        transformableState.zoomBy(2f)
        scale *= 2f
    }
}) {
    Text("Instant Zoom")
}

Button(onClick = {
    coroutineScope.launch {
        transformableState.rotateBy(90f)
        rotation += 90f
    }
}) {
    Text("Instant Rotate")
}
Button(onClick = {
    coroutineScope.launch {
        transformableState.panBy(Offset(100f, 0f))
        offset += Offset(100f, 0f)
    }
}) {
    Text("Instant Pan")
}

Performance Optimization

Use Lambda Syntax for GraphicsLayer

// ✅ Better - only updates layer, no recomposition
Modifier.graphicsLayer { 
    scaleX = scale
    scaleY = scale
    translationX = offset.x
    translationY = offset.y
}

// ❌ Worse - triggers recomposition when state changes
Modifier.graphicsLayer(
    scaleX = scale,
    scaleY = scale,
    translationX = offset.x,
    translationY = offset.y
)

Why this matters: The lambda version reads state during the draw phase rather than composition phase, avoiding expensive recompositions on every gesture frame.

Choose the Right Compositing Strategy

// For simple transformations without transparency
Modifier.graphicsLayer(
    scaleX = scale,
    scaleY = scale,
    compositingStrategy = CompositingStrategy.ModulateAlpha
)

// ❌ Worse - triggers recomposition when state changes
Modifier.graphicsLayer(
    scaleX = scale,
    scaleY = scale,
    translationX = offset.x,
    translationY = offset.y
)

Keep Transformation Callbacks Lightweight

// ✅ Good - fast callback
val state = rememberTransformableState { zoomChange, panChange, rotationChange ->
    scale *= zoomChange
    offset += panChange
    rotation += rotationChange
}

// ❌ Bad - blocks gesture handling
val state = rememberTransformableState { zoomChange, panChange, rotationChange ->
    scale *= zoomChange
    offset += panChange
    rotation += rotationChange

    // Don't do expensive work here!
    processImage()
    updateDatabase()
    performComplexCalculation()
}

The transformation callback runs on every gesture frame (potentially 60+ times per second). Keep it fast — just update state variables. Defer heavy work to other places.

Use Efficient State Types

// ✅ More efficient for primitives
var scale by remember { mutableFloatStateOf(1f) }
var rotation by remember { mutableFloatStateOf(0f) }

// ❌ Less efficient (boxing overhead)
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
// ✅ Correct for objects
var offset by remember { mutableStateOf(Offset.Zero) }

Troubleshooting Guide

Issue 1: Jerky or Laggy Transformations

Symptoms: Gestures don’t feel smooth; visible stuttering during zoom/pan.

Solutions:

  1. Ensure you’re using graphicsLayer for transformations
  2. Use lambda syntax: .graphicsLayer { scaleX = scale }
  3. Remove expensive operations from transformation callbacks
  4. Check that you’re not triggering recomposition unnecessarily

Verification:

// Add logging to check frame rate
val state = rememberTransformableState { z, p, r ->
    Log.d("Transform", "Frame at ${System.currentTimeMillis()}")
    scale *= z
    // If logs show gaps > 16ms, investigate performance
}

Issue 2: Transformations Jump or Reset Unexpectedly

Symptoms: Content suddenly jumps to a different position or scale during gestures.

Solutions:

  1. Always use rememberTransformableState, never create directly
  2. Ensure correct mathematical operations:
  • scale *= zoomChange (multiply, not assign)
  • offset += offsetChange (add, not assign)

Wrap state in remember:

// ✅ Correct
var scale by remember { mutableFloatStateOf(1f) }
val state = rememberTransformableState { z, p, r ->
    scale *= z  // Multiply!
}

// ❌ Wrong - will jump
var scale by remember { mutableFloatStateOf(1f) }
val state = rememberTransformableState { z, p, r ->
    scale = z  // Don't assign directly!
}

Issue 3: Can’t Pan Content

Symptoms: Pan gestures don’t work or parent container scrolls instead.

Solutions:

Check canPan predicate returns true:

.transformable(state = state, canPan = { scale > 1f })

Verify state is being updated:

val state = rememberTransformableState { z, p, r ->
    scale *= z
    if (scale > 1f) {
        offset += p  // Make sure this line executes
    }
}

Check for parent scrollables consuming events:

// Disable parent scroll during zoom
val scrollEnabled = scale <= 1f
LazyColumn(userScrollEnabled = scrollEnabled) {
    // ...
}

Issue 4: Touch Targets Don’t Match Visual Position

Symptoms: Touch-sensitive area doesn’t follow the transformed content; gestures feel disconnected.

Solution: Ensure correct modifier order:

// ✅ Correct - touch area stays stable
Modifier
    .graphicsLayer(/* transformations */)  // First
    .transformable(state)                  // Second
    .background(Color.Blue)

// ❌ Wrong - touch area moves with visual
Modifier
    .transformable(state)                  // Wrong order!
    .graphicsLayer(/* transformations */)

Issue 5: Ctrl+Scroll Zoom Not Working on Desktop

Symptoms: Mouse wheel with Ctrl doesn’t zoom.

Solutions:

Ensure enabled = true (default):

.transformable(state = state, enabled = true)

Verify composable fills space and receives events:

Box(
    Modifier
        .fillMaxSize()  // Must have size to receive events
        .graphicsLayer(/* ... */)
        .transformable(state)
)

Check no parent is consuming scroll events

Best Practices Summary

Core Principles

  1. Separation of Concerns: Gestures detect, you decide how to transform
  2. Delta-Based Updates: Always multiply zoom, add pan/rotation
  3. Modifier Order: graphicsLayer before transformable, always
  4. GPU Acceleration: Use graphicsLayer for smooth 60fps performance
  5. Accessibility First: Provide button controls for all users

Implementation Checklist

State Management:

  • ✅ Use rememberTransformableState in composables
  • ✅ Multiply zoom: scale *= zoomChange
  • ✅ Add rotation: rotation += rotationChange
  • ✅ Add pan: offset += offsetChange
  • ✅ Use mutableFloatStateOf for primitives
  • ✅ Wrap everything in remember

Constraints:

  • ✅ Always implement coerceIn for scale
  • ✅ Calculate content-aware boundaries
  • ✅ Reset offset when at minimum zoom
  • ✅ Normalize rotation to 0–360°

Performance:

  • ✅ Use lambda syntax: .graphicsLayer { scaleX = scale }
  • ✅ Keep callbacks lightweight
  • ✅ Choose appropriate CompositingStrategy
  • ✅ Profile on real devices

User Experience:

  • ✅ Smooth animations for programmatic changes
  • ✅ Visual feedback (zoom percentage, etc.)
  • ✅ Reset button with animation
  • ✅ Double-tap to zoom toggle
  • ✅ Accessibility controls

Conclusion

Multitouch gesture handling in Jetpack Compose provides a sophisticated yet approachable system for creating natural, responsive user interfaces. The key to success is understanding the fundamentals and applying the patterns consistently.

Key Takeaways

  1. Start Simple: Begin with basic transformable + graphicsLayer and add features incrementally
  2. Always Constrain: Never ship without reasonable limits on zoom, pan, and rotation
  3. Think Accessibility: Provide button controls and semantic information for all users
  4. Optimize Wisely: Use lambda syntax for graphicsLayer and keep callbacks fast
  5. Test on Devices: Real gestures on actual hardware reveal issues emulators miss

Resources:


메타데이터
post_id
eeebe83066fe
slug
multitouch-gestures-in-jetpack-compose-the-complete-practical-guide-eeebe83066fe
url
https://medium.com/@ramadan123sayed/multitouch-gestures-in-jetpack-compose-the-complete-practical-guide-eeebe83066fe
canonical_url
https://medium.com/@ramadan123sayed/multitouch-gestures-in-jetpack-compose-the-complete-practical-guide-eeebe83066fe
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-07-16 22:20:29