Implement Pinch-to-Zoom for your Image Grids by Jetpack Compose (Part 1)

Bình Phạm
9 min readApr 11, 2024

--

In the world of mobile app development, creating an engaging user experience is paramount. One feature that significantly enhances this experience is the “pinch to zoom” functionality, a signature feature of the iOS photo app. This feature allows users to interact with images in a more detailed and immersive way, making it a staple in many applications.

In this series of posts, we aim to bring this signature iOS feature to Android apps using Jetpack Compose Grid layout. Jetpack Compose, the modern toolkit for building native Android UI, offers developers a more concise and intuitive way to design UIs. However, as it’s a relatively new framework, some features are not as straightforward to implement as they are in traditional Android development.

iOS photo app pinch-to-zoom

Pinch-to-zoom in Grid layout

In the first part of this series, we will delve into the step-by-step process of implementing the “pinch to zoom” feature on a grid layout. We will explain the logic behind each decision and provide code snippets to illustrate the implementation.

By the end of this post, you will have a solid understanding of how to implement the “pinch to zoom” feature in a Jetpack Compose Grid layout, and you will be able to apply this knowledge to enhance the user experience of your own apps. Stay tuned for the subsequent parts of this series, where we will explore more advanced topics and further enhance our implementation. Let’s dive in!

Let’s start by creating a grid layout with Jetpack Compose. We will use a LazyVerticalGrid to create a grid with 3 columns and populate it with 100 sample images. Here’s a sample step-by-step plan :

  • Define a LazyVerticalGrid with GridCells.Fixed(3) to create a grid with 3 columns.
  • Use a for loop to generate 100 items.
  • For each item, create an Image composable to display a sample image.

Here’s the Kotlin code to implement this:

@Composable
fun ImageGrid() {
val imageList = List(100) { R.drawable.sample_image } // Replace with your own image resource
    LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize()
) {
items(imageList) { image ->
Image(
painter = painterResource(id = image),
contentDescription = "Sample Image",
modifier = Modifier.padding(4.dp),
contentScale = ContentScale.Crop
)
}
}
}

Please replace R.drawable.sample_image with your own image resource. This code will create a grid of images with 3 columns and 100 items. Each image is displayed with a padding of 4dp.

Now, we will use the following custom detectPinchGestures function to detect pinch-to-zoom gestures in our grid layout.

@Suppress("LongMethod", "ComplexMethod")
suspend fun PointerInputScope.detectPinchGestures(
pass: PointerEventPass = PointerEventPass.Main,
onGestureStart: (PointerInputChange) -> Unit = {},
onGesture: (
centroid: Offset,
zoom: Float
) -> Unit,
onGestureEnd: (PointerInputChange) -> Unit = {}
) {
awaitEachGesture {
var zoom = 1f
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
    val down: PointerInputChange = awaitFirstDown(requireUnconsumed = false, pass = pass)
onGestureStart(down)
var pointer = down
var pointerId = down.id
do {
val event = awaitPointerEvent(pass = pass)
val canceled = event.changes.any { it.isConsumed }
if (!canceled) {
val pointerInputChange = event.changes.firstOrNull { it.id == pointerId } ?: event.changes.first()
pointerId = pointerInputChange.id
pointer = pointerInputChange
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) {
val centroid = event.calculateCentroid(useCurrent = false)
if (zoomChange != 1f) {
onGesture(
centroid,
zoomChange
)
event.changes.forEach { it.consume() }
}
}
}
} while (!canceled && event.changes.any { it.pressed })
onGestureEnd(pointer)
}
}

The function takes several parameters:

  • pass: This is a PointerEventPass which specifies when the gesture detection should happen. We will use PointerEventPass.Initial to allow the grid (which is the ancestor in this context) to consume the pinch gesture first. Any remaining, unconsumed events will then be passed down to its child components. This is particularly useful in scenarios where we want the parent component to have the first opportunity to respond to a gesture. For example, if you have an pager inside the grid that also has its own swiping functionality, using PointerEventPass.Initial for the grid ensures that the grid’s zoom is applied first. If the grid doesn’t consume the gesture (i.e., the gesture doesn’t cause a zoom in the grid), the gesture event will then be available to the pager (the child component).
  • onGestureStart, onGesture, and onGestureEnd: These are lambda functions that handle different stages of the gesture (start, ongoing, end).

The function starts by calling awaitEachGesture, which starts a loop that waits for each gesture. Inside this loop, it first waits for a down event (the start of a gesture) using awaitFirstDown. This down event is then passed to the onGestureStart lambda function.

val down: PointerInputChange = awaitFirstDown(requireUnconsumed = false, pass = pass)
onGestureStart(down)

The function then enters a loop where it waits for each pointer event. For each event, it calculates the zoom change using event.calculateZoom(). If the zoom change exceeds a certain threshold (the touch slop), it considers the gesture to have started.

val zoomChange = event.calculateZoom()
...
if (zoomMotion > touchSlop) {
pastTouchSlop = true
}

If the gesture has started, it calculates the centroid of the gesture and calls the onGesture lambda function, passing it the centroid and the zoom change. It also consumes the event.

val centroid = event.calculateCentroid(useCurrent = false)
if (zoomChange != 1f) {
onGesture(
centroid,
zoomChange
)
event.changes.forEach { it.consume() }
}

Finally, if the gesture ends or is cancelled, it calls the onGestureEnd lambda function.

Then we will use this in your grid layout code by attaching it to a composable via a Modifier.pointerInput call. Inside the detectPinchGestures call, we will define what should happen when a pinch-to-zoom gesture is detected.

@Composable
fun ImageGrid() {
val imageList = List(100) { R.drawable.sample_image } // Replace with your own image resource
    LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectPinchGestures(
pass = PointerEventPass.Initial,
onGesture = { centroid: Offset, newZoom: Float ->
...
},
onGestureEnd = { ... }
)
}
) {
...
}
}

Zoom In and Zoom Out Transitions for Grid Layout

Next, we will implement zoom in & zoom out transition for our image grid. Let say we will set a maximum zoom-in level of 0.75 and a maximum zoom-out level of 1.25. When the user pinches to zoom in or out, we will check if the new zoom level is within these limits. If it is, we will trigger a layout change in our grid.

  • First, we need to define a state to track the current zoom level. We will use a mutableStateOf to create a mutable state that can be observed by Compose. We will initialize this state with a value of 1, representing no zoom.
  • Next, we will modify our detectPinchGestures function. Inside the onGesture lambda function, we will update our zoom state with the new zoom level. We will also check if the new zoom level is within our defined limits. If it isn’t, we will trigger a layout change in our grid.
  • Finally we will modify our LazyVerticalGrid to respond to changes in the zoom level, if you just need to change the grid columns using the default animation then you can use Modifier.animateItemPlacement(tween(durationMillis = 400)).
@Composable
fun ImageGrid() {
val imageList = List(100) { R.drawable.sample_image } // Replace with your own image resource
var zoom by remember(displayMode) { mutableStateOf(1f) }
var collums by remember { mutableStateOf(3) }
LazyVerticalGrid(
columns = GridCells.Fixed(collums),
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectPinchGestures(
pass = PointerEventPass.Initial,
onGesture = { centroid: Offset, newZoom: Float ->
val newScale = (zoom * newZoom)
if (newScale > 1.25f) {
collum = collum.dec().coerceIn(1, 4)
} else if (newScale < 0.75f) {
collum = collum.inc().coerceIn(1, 4)
} else {
zoom = newScale
}
},
onGestureEnd = { zoom = 1f }
)
}
) {
...
}
}

But if we want to have something similar to the photos app on iOS, we need to do some more work. Since Compose Grid doesn’t provide any customization for item transitions, we need to use a scaling animation using AnimatedVisibility.

  • We need to have another Composable to wrap like 3 ImageGrid. It uses a level state to track the current zoom level. The level state is initialized to 1, representing the default zoom level. Suppose we will have levels 0, 1 & 2 corresponding to 1-column, 2-column and 4-column grids.
var level by remember { mutableStateOf(1) }
  • The ImageList function contains three AnimatedVisibility blocks, each corresponding to a different zoom level. The visible parameter of AnimatedVisibility is set to true when the current zoom level (level) matches the level for that block. When visible is true, the enter animation (scale in and fade in) is played. When visible is false, the exit animation (scale out and fade out) is played.
AnimatedVisibility(
visible = level == 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
ImageGrid(imageList, 1, 0) {
level = it
}
}
  • The ImageGrid function is a Composable function that displays a grid of images. It takes a list of images, number of columns, the next and previous zoom levels, and a lambda function onZoomLevelChange as parameters. The onZoomLevelChange function is called when a pinch-to-zoom gesture is detected and the zoom level needs to change:
fun ImageGrid(imageList : List<Drawable>, collumns: Int, nextLevel: Int, previousLevel: Int, onZoomLevelChange: (Int) -> Unit)
  • The ImageGrid function uses the detectPinchGestures function to detect pinch-to-zoom gestures. When a gesture is detected, it calculates the new zoom level (newScale). Let say if the new zoom level is greater than 1.25, it calls onZoomLevelChange with the previous zoom level, causing the grid to zoom in. If the new zoom level is less than 0.75, it calls onZoomLevelChange with the next zoom level, causing the grid to zoom out. If the new zoom level is between 0.75 and 1.25, it updates the zoom state with the new zoom level, causing the grid to zoom in or out without changing the layout.
  • Beside that, we will uses the graphicsLayer modifier to apply a scale transformation to the grid. The scale factor is determined by the zoomTransition state, which is animated using animateFloatAsState. This creates a smooth zooming effect when the user pinches to zoom in or out
@Composable
fun ImageList(){
var level by remember { mutableStateOf(1) }
val imageList = List(100) { R.drawable.sample_image } // Replace with your own image resource
AnimatedVisibility(
visible = level == 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
ImageGrid(imageList, 1, 1, 0) {
level = it
}
}
AnimatedVisibility(
visible = level == 1,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
ImageGrid(imageList, 2, 2, 0) {
level = it
}
}
AnimatedVisibility(
visible = level == 2,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
ImageGrid(imageList, 4, 2, 1) {
level = it
}
}
}

@Composable
fun ImageGrid(imageList : List<Drawable>, collumns: Int, nextLevel: Int, previousLevel: Int, onZoomLevelChange: (Int) -> Unit) {
var zoom by remember(displayMode) { mutableStateOf(1f) }
val zoomTransition: Float by animateFloatAsState(
zoom,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow)
)
LazyVerticalGrid(
columns = GridCells.Fixed(collumns),
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectPinchGestures(
pass = PointerEventPass.Initial,
onGesture = { centroid: Offset, newZoom: Float ->
val newScale = (zoom * newZoom)
if (newScale > 1.25f) {
onZoomLevelChange.invoke(previousLevel)
} else if (newScale < 0.75f) {
onZoomLevelChange.invoke(nextLevel)
} else {
zoom = newScale
}
},
onGestureEnd = { zoom = 1f }
)
}
.graphicsLayer {
scaleX = zoomTransition
scaleY = zoomTransition
}
) {
...
}
}

Now that we’ve gone through the code and discussed the implementation details, let’s see the pinch-to-zoom feature in action. In the video below, you can see how the image grid responds to pinch gestures. Notice how the grid layout changes as we zoom in and out. This is the result of our hard work and it’s quite similar to the iOS Photos app’s behavior.

As you can see, implementing a pinch-to-zoom feature in a Jetpack Compose grid layout involves several steps and considerations. However, the end result is a smooth and intuitive user experience that’s worth the effort. In the next part of this series, we will explore more advanced stuff and further enhance our implementation for this feature. Stay tuned!

If you have any questions or need further clarification on any part of this tutorial, please feel free to leave a comment below. I will do my best to respond as soon as possible. Your feedback and questions are always welcome!

--

--