Komito
Komito

Reputation: 103

Zoom Lazycolumn item

I am trying to zoom to an item in a Lazycolumn. I have tried various ways but they all have a problem and I don't know how to fix it. On the first try, I tried to scale and detect the gesture from the image but lose the ability to scroll the list and the second item overlaps. On the second try, I placed the image inside a Box so that it would grow when zoomed in and the image would adapt to the box. Now the second item doesn't overlap when zoom but I can't scroll.

Is there a way to zoom and scroll in a list without overlapping the items?

Thanks

Try 1


var scale = remember { mutableStateOf(1f) }

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
    ) {
        items(items = list) { item ->

            if (item != null) {

                Image(
                    modifier = Modifier
                        .fillMaxSize()
                        .pointerInput(Unit) {
                            detectTransformGestures { _, _, zoom, _ ->
                                scale.value *= zoom

                            }
                        }
                        .graphicsLayer {
                            scaleX = maxOf(1f, minOf(3f, scale.value));
                            scaleY = maxOf(1f, minOf(3f, scale.value))
                        }, bitmap = item, contentDescription = ""
                )
            }


        }
    }

Try 2


var scale = remember { mutableStateOf(1f) }



    Box(modifier = Modifier
        .fillMaxSize()
        .graphicsLayer {
            scaleX = maxOf(1f, minOf(3f, scale.value));
            scaleY = maxOf(1f, minOf(3f, scale.value))
        }) {


        LazyColumn(
             modifier = Modifier
                .fillMaxSize()
        ) {
            items(items = list) { item ->

                if (item != null) {

                    Image(
                        modifier = Modifier
                            .fillMaxSize().pointerInput(Unit) {
                                detectTransformGestures { _,_, zoom, _ ->
                                    scale.value *= zoom

                                }
                            }, bitmap = item, contentDescription = ""
                    )
                }


            }
        }
    }

Upvotes: 1

Views: 1653

Answers (3)

Rock Lee
Rock Lee

Reputation: 1

this is answer

 var scale by remember { mutableStateOf(1f) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
    scale *= zoomChange
}
val scaleAnim :Float by animateFloatAsState(targetValue = scale, animationSpec = tween(durationMillis = if(state.isTransformInProgress) 0 else  250))
LaunchedEffect(state.isTransformInProgress) {
    if(!state.isTransformInProgress){
        scale = 1f
    }
}
Box(
    modifier = modifier 
        .graphicsLayer {
            scaleX = scaleAnim
            scaleY = scaleAnim
        }
        .zIndex(scaleAnim)
        .transformable(state) 
) 

Upvotes: 0

Thracian
Thracian

Reputation: 67189

This is the result you get with the answer below

enter image description here

Each class should hold zoom value to not zoom every item in the list with a fixed number

class Snack(
    val imageUrl: String
) {
    var zoom = mutableStateOf(1f)
}

In the answer below zoom is calculated only when user touches an Image with 2 fingers/pointers and since you didn't have any translating, i mean moving image, i didn't add any but i can for instance when zoom is not 1 when image is touched user can translate position of image.

@Composable
private fun ZoomableList(snacks: List<Snack>) {

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
    ) {
        itemsIndexed(items = snacks) { index, item ->

            Image(
                painter = rememberAsyncImagePainter(model = item.imageUrl),
                contentDescription = null,
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f)
                    .border(2.dp, Color.Blue)
                    .clipToBounds()
                    .graphicsLayer {
                        scaleX = snacks[index].zoom.value
                        scaleY = snacks[index].zoom.value
                    }
                    .pointerInput(Unit) {
                        forEachGesture {
                            awaitPointerEventScope {
                                // Wait for at least one pointer to press down
                                awaitFirstDown()
                                do {

                                    val event = awaitPointerEvent()
                                    // Calculate gestures and consume pointerInputChange
                                    // only size of pointers down is 2
                                    if (event.changes.size == 2) {
                                        var zoom = snacks[index].zoom.value
                                        zoom *= event.calculateZoom()
                                        // Limit zoom between 100% and 300%
                                        zoom = zoom.coerceIn(1f, 3f)
                                        snacks[index].zoom.value = zoom


                                        /*
                                            Consumes position change if there is any
                                            This stops scrolling if there is one set to any parent Composable
                                         */
                                        event.changes.forEach { pointerInputChange: PointerInputChange ->
                                            pointerInputChange.consume()
                                        }
                                    }
                                } while (event.changes.any { it.pressed })
                            }
                        }
                    }
            )
        }
    }
}

Let's break down how touch events work, there is a detailed answer here, and i also have a tutorial that covers gestures in detail here.

A basic DOWN, MOVE, and UP process can be summed as

val pointerModifier = Modifier
    .pointerInput(Unit) {
        forEachGesture {

            awaitPointerEventScope {
                
                awaitFirstDown()
               // ACTION_DOWN here
               
                do {
                    
                    //This PointerEvent contains details including
                    // event, id, position and more
                    val event: PointerEvent = awaitPointerEvent()
                    // ACTION_MOVE loop

                    // Consuming event prevents other gestures or scroll to intercept
                    event.changes.forEach { pointerInputChange: PointerInputChange ->
                        pointerInputChange.consume()
                    }
                } while (event.changes.any { it.pressed })

                // ACTION_UP is here
            }
        }
}

You can get first down with awaitFirstDown() and move event details with awaitPointerEvent(). Consuming is basically saying other pointerInput events above or in parent or other gestures such as scroll to say stop, event is done here.

Also order of Modifiers matter for Modifier.graphicsLayer{} and Modifier.pointerInput() too. If you don't place graphics layer before pointerInput when your scale, rotation or translation change these changes won't be reflected to Modifier.pointerInput() unless you use Modifier.pointerInput(zoom, translation, rotation) like params and these will reset this modifier on each recomposition so, unless you explicitly need initial results of Modifier.graphicsLayer put it first.

val snacks = listOf(
    Snack(
        imageUrl = "https://source.unsplash.com/pGM4sjt_BdQ",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/Yc5sL-ejk6U",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/-LojFX9NfPY",
    ),
    Snack(

        imageUrl = "https://source.unsplash.com/AHF_ZktTL6Q",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/rqFm0IgMVYY",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/qRE_OpbVPR8",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/33fWPnyN6tU",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/aX_ljOOyWJY",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/7meCnGCJ5Ms",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/m741tj4Cz7M",
    ),
    Snack(

        imageUrl = "https://source.unsplash.com/iuwMdNq0-s4",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/qgWWQU1SzqM",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/9MzCd76xLGk",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/1d9xXWMtQzQ",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/wZxpOw84QTU",
    ),
    Snack(

        imageUrl = "https://source.unsplash.com/okzeRxm_GPo",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/l7imGdupuhU",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/bkXzABDt08Q",
    ),
    Snack(

        imageUrl = "https://source.unsplash.com/y2MeW00BdBo",
    ),
    Snack(
        imageUrl = "https://source.unsplash.com/1oMGgHn-M8k",
    ),
    Snack(

        imageUrl = "https://source.unsplash.com/TIGDsyy0TK4",
    )
)

Upvotes: 4

Phil Dukhov
Phil Dukhov

Reputation: 88082

detectTransformGestures detects not only zoom, but also drag gestures. And to detect a drag correctly, it consumes the event which prevents scroll view from receiving it.

You can create zoom only gesture, which will consume pointer events only during zoom. I've took detectTransformGestures source code as a base and remove all non-zoom code:

suspend fun PointerInputScope.detectZoom(
    onGesture: (zoom: Float) -> Unit,
) {
    forEachGesture {
        awaitPointerEventScope {
            var zoom = 1f
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop

            awaitFirstDown(requireUnconsumed = false)
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.fastAny { it.isConsumed }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize

                        if (zoomMotion > touchSlop) {
                            pastTouchSlop = true
                        }
                    }

                    if (pastTouchSlop) {
                        if (zoomChange != 1f) {
                            onGesture(zoomChange)
                            event.changes.fastForEach {
                                if (it.positionChanged()) {
                                    it.consume()
                                }
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
        }
    }
}

To solve overlapping problem, Modifier.zIndex can be used. In my example I just use scale for this value, I expect that in real world you're not gonna zoom more than one item at once - e.g. you can disable both list scrolling using LazyColumn.userScrollEnabled parameter and other cells zoom detection while one of the items being zoomed.

val list = List(10) { "https://picsum.photos/id/${237 + it}/400/400" }

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
) {
    items(items = list) { item ->
        var scale by remember { mutableStateOf(1f) }
        Image(
            painter = rememberAsyncImagePainter(model = item),
            contentDescription = null,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .pointerInput(Unit) {
                    detectZoom { zoom ->
                        scale *= zoom
                    }
                }
                .graphicsLayer {
                    scaleX = maxOf(1f, minOf(3f, scale));
                    scaleY = maxOf(1f, minOf(3f, scale))
                }
                .zIndex(scale)
        )
    }
}

Upvotes: 1

Related Questions