androidandroid-jetpack-composeandroid-viewpagermodifierandroid-jetpack-compose-gesture

How to handle horizontal scroll gesture combined with transform gestures in Jetpack Compose


Hi I want to have a zoomable image in a horizontal pager. I already implemented transform gestures and double tap but now, I can't scroll my pager with scrolling over the image. I guess panning and scrolling mixes each other. Can you help me about that ?

This is my Zoomable Image Composable:


@Composable
fun DoubleTapZoom(
) {
    var zoomed by remember { mutableStateOf(false) }
    var transforming by remember { mutableStateOf(false) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var zoomFloat by remember { mutableStateOf(1f) }
    var angle by remember { mutableStateOf(0f) }

    Image(
        painter = painterResource(id = R.drawable.ic_launcher_foreground),
        contentDescription = null,
        modifier = Modifier
            .size(500.dp)
            .background(Color.Red)
            .pointerInput(Unit) {

                detectTapGestures(
                    onDoubleTap = { tapOffset ->
                        offset = if (zoomed) Offset.Zero else
                            calculateOffsetFromClick(size, tapOffset)
                        zoomed = !zoomed
                    }
                )
            }
            .pointerInput(Unit) {

                detectTransformGestures { centroid, pan, gestureZoom, gestureRotate ->
                    transforming = true
                    val oldScale = zoomFloat
                    val newScale = zoomFloat * gestureZoom
                    
                    offset = (offset + centroid / oldScale).rotateBy(gestureRotate) -
                            (centroid / newScale + pan / oldScale)
                    zoomFloat = newScale
                    angle += gestureRotate
                    transforming = false
                }
            }
            .graphicsLayer {
                scaleX = zoomFloat
                scaleY = zoomFloat
                translationX = -offset.x * zoomFloat
                translationY = -offset.y * zoomFloat
                rotationZ = angle
                transformOrigin = TransformOrigin(0f, 0f)
            }
    )
}

And here is the my usage in horizontal pager:


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalPagerWithZoomableContent() {
    val pagerState = rememberPagerState()
    
    HorizontalPager(
        state = pagerState,
        modifier = Modifier
            .border(4.dp, Color.Red, RoundedCornerShape(36.dp))
            .clip(RoundedCornerShape(36.dp)),
        pageCount = 4,
    ) { pageIndex ->
        Box (modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center){

            DoubleTapZoom()
        }
    }
}

Solution

  • In jetpack Compose events are propagated from descendant to ancestor by default with default pass(PointerEventPass.Main). If a gesture is consumed and parent/child checking if it has been consumed before it never gets that event.

    You can refer this answer for more details.

    By default detectTransformGestures consume PointerInputChanges when there is a move event. To overcome this you can add an optional parameter to consume conditionally such as zoom being bigger than 1 or number of pointers down bigger than 1.

    I wrote a detecTransformGesture that checks consume, and returns pointer that are down on screen as

    suspend fun PointerInputScope.detectCustomTransformGestures(
        panZoomLock: Boolean = false,
        consume: Boolean = true,
        pass: PointerEventPass = PointerEventPass.Main,
        onGestureStart: (PointerInputChange) -> Unit = {},
        onGesture: (
            centroid: Offset,
            pan: Offset,
            zoom: Float,
            rotation: Float,
            mainPointer: PointerInputChange,
            changes: List<PointerInputChange>
        ) -> Unit,
        onGestureEnd: (PointerInputChange) -> Unit = {}
    ) {
        awaitEachGesture {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false
    
            // Wait for at least one pointer to press down, and set first contact position
            val down: PointerInputChange = awaitFirstDown(
                requireUnconsumed = false,
                pass = pass
            )
            onGestureStart(down)
    
            var pointer = down
            // Main pointer is the one that is down initially
            var pointerId = down.id
    
            do {
                val event = awaitPointerEvent(pass = pass)
    
                // If any position change is consumed from another PointerInputChange
                // or pointer count requirement is not fulfilled
                val canceled =
                    event.changes.any { it.isConsumed }
    
                if (!canceled) {
    
                    // Get pointer that is down, if first pointer is up
                    // get another and use it if other pointers are also down
                    // event.changes.first() doesn't return same order
                    val pointerInputChange =
                        event.changes.firstOrNull { it.id == pointerId }
                            ?: event.changes.first()
    
                    // Next time will check same pointer with this id
                    pointerId = pointerInputChange.id
                    pointer = pointerInputChange
    
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()
    
                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange
    
                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion =
                            abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()
    
                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }
    
                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(
                                centroid,
                                panChange,
                                zoomChange,
                                effectiveRotation,
                                pointer,
                                event.changes
                            )
                        }
    
                        if (consume) {
                            event.changes.forEach {
                                if (it.positionChanged()) {
                                    it.consume()
                                }
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })
            onGestureEnd(pointer)
        }
    }
    

    As can be from snippet above i only consume change only if consume param is set to true

    if (consume) {
        event.changes.forEach {
            if (it.positionChanged()) {
                it.consume()
            }
        }
    }
    

    unlike default gesture.

    This gesture can be used as

    @Composable
    fun DoubleTapZoom(
    ) {
        var zoomed by remember { mutableStateOf(false) }
        var transforming by remember { mutableStateOf(false) }
        var offset by remember { mutableStateOf(Offset.Zero) }
        var zoomFloat by remember { mutableStateOf(1f) }
        var angle by remember { mutableStateOf(0f) }
        
        val context = LocalContext.current
    
        Image(
            painter = painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = null,
            modifier = Modifier
                .size(500.dp)
                .background(Color.Red)
                .pointerInput(Unit) {
    
                    detectTapGestures(
                        onDoubleTap = { tapOffset ->
                            Toast
                                .makeText(context, "DoubleTap", Toast.LENGTH_SHORT)
                                .show()
    //                        offset = if (zoomed) Offset.Zero else
    //                        calculateOffsetFromClick(size, tapOffset)
    //                        zoomed = !zoomed
                        }
                    )
                }
                .pointerInput(Unit) {
    
                    detectCustomTransformGestures(
                        consume = false,
                        onGesture = { centroid, pan, gestureZoom, gestureRotate, _, changes ->
                            transforming = true
                            val oldScale = zoomFloat
                            val newScale = zoomFloat * gestureZoom
    
                            offset = (offset + centroid / oldScale).rotateBy(gestureRotate) -
                                    (centroid / newScale + pan / oldScale)
                            zoomFloat = newScale
                            angle += gestureRotate
                            transforming = false
    
                            // If more than 1 pointer is down consume event
                            // to prevent Pager from scrolling
                            if (changes.size > 1) {
                                changes.forEach { it.consume() }
                            }
                        }
                    )
                }
                .graphicsLayer {
                    scaleX = zoomFloat
                    scaleY = zoomFloat
                    translationX = -offset.x * zoomFloat
                    translationY = -offset.y * zoomFloat
                    rotationZ = angle
                    transformOrigin = TransformOrigin(0f, 0f)
                }
        )
    }
    

    Also available as gesture library that extends capabilities of default gestures available here

    https://github.com/SmartToolFactory/Compose-Extended-Gestures/tree/master/gesture