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",
)
)