How to use detectTransformGestures but not consuming all pointer event
Asked Answered
C

5

11

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!

Cattegat answered 17/2, 2022 at 10:4 Comment(5)
You can copy detectTransformGestures source code and remove consumeAllChanges lineCell
I didn't have consumeAllChanges in my codeCattegat
I'm talking about detectTransformGestures source codeCell
Oh I got it now, I'll try and give you update if it works. Thanks for the suggestionCattegat
Sadly it didn't work. I can only swipe the pages, cannot zoom/panCattegat
P
9

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.

Partida answered 17/2, 2022 at 23:56 Comment(0)
F
3

Solution for HorizontalPager with better ux on swipe:

val pagerState = rememberPagerState()
val scrollEnabled = remember { mutableStateOf(true) }
HorizontalPager(
   count = ,
   state = pagerState,
   userScrollEnabled = scrollEnabled.value,
) { }


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomablePagerImage(
    modifier: Modifier = Modifier,
    painter: Painter,
    scrollEnabled: MutableState<Boolean>,
    minScale: Float = 1f,
    maxScale: Float = 5f,
    contentScale: ContentScale = ContentScale.Fit,
    isRotation: Boolean = false,
) {
    var targetScale by remember { mutableStateOf(1f) }
    val scale = animateFloatAsState(targetValue = maxOf(minScale, minOf(maxScale, targetScale)))
    var rotationState by remember { mutableStateOf(1f) }
    var offsetX by remember { mutableStateOf(1f) }
    var offsetY by remember { mutableStateOf(1f) }
    val configuration = LocalConfiguration.current
    val screenWidthPx = with(LocalDensity.current) { configuration.screenWidthDp.dp.toPx() }
    Box(
        modifier = Modifier
            .clip(RectangleShape)
            .background(Color.Transparent)
            .combinedClickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = { },
                onDoubleClick = {
                    if (targetScale >= 2f) {
                        targetScale = 1f
                        offsetX = 1f
                        offsetY = 1f
                        scrollEnabled.value = true
                    } else targetScale = 3f
                },
            )
            .pointerInput(Unit) {
                forEachGesture {
                    awaitPointerEventScope {
                        awaitFirstDown()
                        do {
                            val event = awaitPointerEvent()
                            val zoom = event.calculateZoom()
                            targetScale *= zoom
                            val offset = event.calculatePan()
                            if (targetScale <= 1) {
                                offsetX = 1f
                                offsetY = 1f
                                targetScale = 1f
                                scrollEnabled.value = true
                            } else {
                                offsetX += offset.x
                                offsetY += offset.y
                                if (zoom > 1) {
                                    scrollEnabled.value = false
                                    rotationState += event.calculateRotation()
                                }
                                val imageWidth = screenWidthPx * scale.value
                                val borderReached = imageWidth - screenWidthPx - 2 * abs(offsetX)
                                scrollEnabled.value = borderReached <= 0
                                if (borderReached < 0) {
                                    offsetX = ((imageWidth - screenWidthPx) / 2f).withSign(offsetX)
                                    if (offset.x != 0f) offsetY -= offset.y
                                }
                            }
                        } while (event.changes.any { it.pressed })
                    }
                }
            }

    ) {
        Image(
            painter = painter,
            contentDescription = null,
            contentScale = contentScale,
            modifier = modifier
                .align(Alignment.Center)
                .graphicsLayer {
                    this.scaleX = scale.value
                    this.scaleY = scale.value
                    if (isRotation) {
                        rotationZ = rotationState
                    }
                    this.translationX = offsetX
                    this.translationY = offsetY
                }
        )
    }
}
Franctireur answered 28/9, 2022 at 8:27 Comment(1)
how can i deal with children (items of a lazyverticalgrid) that i want to be clickable, but then also have pan/zoom on the entire layout (a box that contains aforementioned lazyverticalgrid)? it always registers the pan/zoom gesture as a click on the children :(Perseid
F
1

If you can create a mutable state variable that keeps track of the zoom factor, you can add the pointerInput modifier when the zoom factor is greater than one and leave it out when it is greater than one. Something like this:

var zoomFactorGreaterThanOne by remember { mutableStateOf(false) }

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,
        )
        .run {
            if (zoomFactorGreaterThanOne != 1.0f) {
                this.pointerInput(scale.value) {
                    detectTransformGestures { _, pan, zoom, _ ->
                        zoomFactorGreaterThanOne = scale.value > 1
                        
                        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
                        }
                    }
                }
            } else {
                this
            }
        }

)
Fluoro answered 17/2, 2022 at 10:13 Comment(2)
With this approach, it will be either zoom/pan or swipe available because if zoomFactorGreaterThanOne is false at first, it will reach else and then it will only swipeable, cannot zoom, and if I set zoomFactorGreaterThanOne initial value to true, it will reach the pointerInput which means I cannot swipe.Cattegat
Well you don't need to use a boolean state. Just add multiple states that you use to determine when to include and not include pointerInput.Fluoro
M
1

Fixes the bugs the previous answer had

Original: Gallery for Android

Extension of Modifier to support both taps and gesture operation without overlapping each other in a scrollable container like HorizontalPager in case of a HorizontalPager if we consume all gesture, then the swiping gesture of the Pager will also be ignored; on the other hand, if we don't consume any gesture fixes the previous issue, but a new one is found: both transform and tap gestures are overlapping, which gives a pretty bad UX in a case when the user wants to zoom then pan an image, the tap/double tap gesture is also registered

!(scrollEnabled logic value changes should be implemented manually for each individual case)

fun Modifier.tapAndGesture(
    key: Any? = Unit,
    onTap: ((Offset) -> Unit)? = null,
    onDoubleTap: ((Offset) -> Unit)? = null,
    onGesture: ((centeroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit)? = null,
    scrollEnabled: MutableState<Boolean> = mutableStateOf(false)
) = composed(
    factory = {
        val scope = rememberCoroutineScope()

        val gestureModifier = Modifier.pointerInput(key) {
            /**
             * [detectTransformGestures]
             * @author SmartToolFactory
             * href: https://github.com/SmartToolFactory/Compose-Extended-Gestures
             */
            detectTransformGestures(
                consume = false,
                onGesture = { centroid: Offset,
                              pan: Offset,
                              zoom: Float,
                              rotation: Float,
                              _: PointerInputChange,
                              changes: List<PointerInputChange> ->
                    scope.launch {
                        onGesture?.invoke(centroid, pan, zoom, rotation)
                    }
                    changes.forEach {
                        // Consume if scroll gestures are not possible
                        if (!scrollEnabled.value) it.consume()
                    }
                }
            )
        }
        val tapModifier = Modifier.pointerInput(key) {
            detectTapGestures(
                onDoubleTap = onDoubleTap,
                onTap = onTap
            )
        }
        then(gestureModifier.then(tapModifier))
    },
    inspectorInfo = debugInspectorInfo {
        name = "tapAndGesture"
        properties["key"] = key
        properties["onTap"] = onTap
        properties["onDoubleTap"] = onDoubleTap
        properties["onGesture"] = onGesture
    }
)
Mellen answered 25/4, 2023 at 22:23 Comment(1)
This is the only solution that actually worked for me. Android needs to add a consume flag for the detectTransformGestures modifier.Dibri
S
0

As an option, it's also possible to handle both swipe and zoom events in the HorizontalPager itself.

        @Composable
    fun PicturesPager(
        urls: List<String>,
    

contentScale: ContentScale = ContentScale.Fit,
    modifier: Modifier = Modifier
) {
    val pagerState = rememberPagerState(pageCount = { urls.size })
    val canSwipe = remember { mutableStateOf(true) }
    var scale by remember { mutableStateOf(1f) }
    val offset = remember { mutableStateOf(Offset.Zero) }
    val size = remember { mutableStateOf(Size.Zero) }

    HorizontalPager(
        state = pagerState, modifier = modifier
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, _ ->
                    val newScale = scale * zoom
                    scale = newScale.coerceAtLeast(1f)
                    // you can set value to == 1 but it's not always easy for the 
                    // user to zoom out image exactly to 1 so personally I 
                    // find setting it to 1.05 more convenient
                    canSwipe.value = scale < 1.05f
                        val maxOffsetX = (size.value.width * scale - size.value.width) / 2
                        val newOffsetX =
                            (offset.value.x + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
                        val maxOffsetY = (size.value.height * scale - size.value.height) / 2
                        val newOffsetY =
                            (offset.value.y + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
                        offset.value = Offset(newOffsetX, newOffsetY)
                    }
                // enable/disable page swipe based on scale factor
                }, userScrollEnabled = canSwipe.value
        ) { page ->
            ZoomableImage(
                imageUrl = urls[page],
                scale = scale,
                offset = offset,
                size = size,
                contentScale = contentScale
            )
        }
    }
    
    @OptIn(ExperimentalResourceApi::class)
    @Composable
        fun ZoomableImage(
            imageUrl: String,
            scale: Float,
            offset: MutableState<Offset>,
            size: MutableState<Size>,
            contentScale: ContentScale,
        ) {
            //The box here is not a requirement so you can remove it and set graphicsLayer to the image directly
            Box(
                modifier = Modifier
                    .graphicsLayer(
                        scaleX = maxOf(1f, scale),
                        scaleY = maxOf(1f, scale),
                        translationX = offset.value.x,
                        translationY = offset.value.y
                    )
            ) {
                KamelImage(
                    resource = asyncPainterResource(imageUrl),
                    contentDescription = stringResource(Res.string.hint_auction_image),
                    contentScale = contentScale,
                    // Here we are getting an updated image size and passing it back to HorizontalPager
                    modifier = Modifier.fillMaxWidth().onSizeChanged { newSize ->
                        size.value = Size(newSize.width.toFloat(), newSize.height.toFloat())
                    },
                    onLoading = { _ ->
                        CircularProgressIndicator()
                    }
                )
            }
        }
Striptease answered 10/5 at 8:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.