android-jetpack-composegesturepinchzoom

How to use detectTransformGestures but not consuming all pointer event


I was making a fullscreen photo viewer which contain a pager (used HorizontalPager) and each page, user can zoom in/out and pan the image, but still able to swipe through pages.

My idea is swiping page will occurs when the image is not zoomed in (scale factor = 1), if it's zoomed in (scale factor > 1) then dragging/swiping will pan the image around.

Here is the code for the HorizontalPager that contain my customized zoomable Image:

@ExperimentalPagerApi
@Composable
fun ViewPagerSlider(pagerState: PagerState, urls: List<String>) {


var scale = remember {
    mutableStateOf(1f)
}
var transX = remember {
    mutableStateOf(0f)
}
var transY = remember {
    mutableStateOf(0f)
}

HorizontalPager(
    count = urls.size,
    state = pagerState,
    modifier = Modifier
        .padding(0.dp, 40.dp, 0.dp, 40.dp),
) { page ->

    Image(
        painter = rememberImagePainter(
            data = urls[page],
            emptyPlaceholder = R.drawable.img_default_post,
        ),
        contentScale = ContentScale.FillHeight,
        contentDescription = null,
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer(
                translationX = transX.value,
                translationY = transY.value,
                scaleX = scale.value,
                scaleY = scale.value,
            )
            .pointerInput(scale.value) {
                detectTransformGestures { _, pan, zoom, _ ->
                    scale.value = when {
                        scale.value < 1f -> 1f
                        scale.value > 3f -> 3f
                        else -> scale.value * zoom
                    }
                    if (scale.value > 1f) {
                        transX.value = transX.value + (pan.x * scale.value)
                        transY.value = transY.value + (pan.y * scale.value)
                    } else {
                        transX.value = 0f
                        transY.value = 0f
                    }
                }
            }
    )
}
}

So my image is zoomed in maximum 3f, and cannot zoom out smaller than 0.

I cannot swipe to change to another page if detectTransformGestures is in my code. If I put the detectTransformGestures based on the factor (scale = 1, make it swipeable to another page if not zoomed in), then it will be a "deadlock" as I cannot zoom in because there is no listener.

I don't know if there is some how to make it possible...

Thank you guys for your time!


Solution

  • I had to do something similar, and came up with this:

    private fun ZoomableImage(
        modifier: Modifier = Modifier,
        bitmap: ImageBitmap,
        maxScale: Float = 1f,
        minScale: Float = 3f,
        contentScale: ContentScale = ContentScale.Fit,
        isRotation: Boolean = false,
        isZoomable: Boolean = true,
        lazyState: LazyListState
    ) {
        val scale = remember { mutableStateOf(1f) }
        val rotationState = remember { mutableStateOf(1f) }
        val offsetX = remember { mutableStateOf(1f) }
        val offsetY = remember { mutableStateOf(1f) }
    
        val coroutineScope = rememberCoroutineScope()
        Box(
            modifier = Modifier
                .clip(RectangleShape)
                .background(Color.Transparent)
                .combinedClickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                    onClick = { /* NADA :) */ },
                    onDoubleClick = {
                        if (scale.value >= 2f) {
                            scale.value = 1f
                            offsetX.value = 1f
                            offsetY.value = 1f
                        } else scale.value = 3f
                    },
                )
                .pointerInput(Unit) {
                    if (isZoomable) {
                        forEachGesture {
                            awaitPointerEventScope {
                                awaitFirstDown()
                                do {
                                    val event = awaitPointerEvent()
                                    scale.value *= event.calculateZoom()
                                    if (scale.value > 1) {
                                        coroutineScope.launch {
                                            lazyState.setScrolling(false)
                                        }
                                        val offset = event.calculatePan()
                                        offsetX.value += offset.x
                                        offsetY.value += offset.y
                                        rotationState.value += event.calculateRotation()
                                        coroutineScope.launch {
                                            lazyState.setScrolling(true)
                                        }
                                    } else {
                                        scale.value = 1f
                                        offsetX.value = 1f
                                        offsetY.value = 1f
                                    }
                                } while (event.changes.any { it.pressed })
                            }
                        }
                    }
                }
    
        ) {
            Image(
                bitmap = bitmap,
                contentDescription = null,
                contentScale = contentScale,
                modifier = modifier
                    .align(Alignment.Center)
                    .graphicsLayer {
                        if (isZoomable) {
                            scaleX = maxOf(maxScale, minOf(minScale, scale.value))
                            scaleY = maxOf(maxScale, minOf(minScale, scale.value))
                            if (isRotation) {
                                rotationZ = rotationState.value
                            }
                            translationX = offsetX.value
                            translationY = offsetY.value
                        }
                    }
            )
        }
    }
    

    It is zoomable, rotatable (if you want it), supports pan if the image is zoomed in, has support for double-click zoom-in and zoom-out and also supports being used inside a scrollable element. I haven't come up with a solution to limit how far can the user pan the image yet.

    It uses combinedClickable so the double-click zoom works without interfering with the other gestures, and pointerInput for the zoom, pan and rotation.

    It uses this extension function to control the LazyListState, but if you need it for ScrollState it shouldn't be hard to modify it to suit your needs:

    suspend fun LazyListState.setScrolling(value: Boolean) {
        scroll(scrollPriority = MutatePriority.PreventUserInput) {
            when (value) {
                true -> Unit
                else -> awaitCancellation()
            }
        }
    }
    

    Feel free to modify it for your needs.