Reputation: 103
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
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
Reputation: 67189
This is the result you get with the answer below
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
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