Android Zoom and Pan Image Transformation
Example of how to implement a zoom and pan feature in Android Compose.

In this post I will show you how I implemented a zoom and pan feature to an image in Android Compose using the pointerInput modifier to capture gestures.

Android Zoom and Pan Images

This sample I am showing is directly derived from one of shown in the Android Developer examples that can be viewed in the link below.

I am using the Landscapist Glide library for this example. I have recently utilized this library in another project and it looks promising.

One of the benefits of it is that you can add custom composables. They can be added to the loading and error methods to display while the work is being processed. This is not necessary for this example below. I wanted to point out one of the features I believe is useful.

Inside the Glide composable, I am capturing the screen size in a mutable state. The pointerInput modifier is used to capture the double tap gesture. I included this in the image composable so the double tap gestures do not get comsumed by the parent composable.

Next inside the parent composable, in this case a Box, I am capturing the scale and offset values using the pointerInput modifier to adjust the zoom and pan values.

Zoom And Pan Composable Image

First add the Landscapist Glide library, add this to build.gradle.

    implementation(libs.landscapist.glide)

Additionally add this to the libs.versions.toml.

[versions]
...

landscapistGlide = "2.4.7"

[libraries]
...

landscapist-glide = { module = "com.github.skydoves:landscapist-glide", version.ref = "landscapistGlide" }

Next we create the custom composable named ImageTransform.
Basically we are capturing the scale and pan values from the gestures inside both composables. These values are processed and retained in a mutable state.

@Composable
fun ImageTransform(modifier: Modifier = Modifier, image: Int) {

    var scale by remember { mutableStateOf(1.0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    var screenPos by remember { mutableStateOf(IntSize.Zero) }
    Box(modifier = modifier
        .pointerInput(Unit) {
            detectTransformGestures { _, pan, zoom, _ ->
                scale *= zoom
                offset = offset.copy(x = offset.x + pan.x, y = offset.y + pan.y)
            }

        }
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
            translationX = offset.x
            translationY = offset.y
        }, contentAlignment = Alignment.Center
    ) {
        GlideImage(
            modifier = Modifier.fillMaxSize()
                .onGloballyPositioned {
                screenPos = it.size
            }.
            pointerInput(Unit){
                detectTapGestures(
                    onDoubleTap = {
                        scale *= 1.5f
                        offset = offset.copy(x = -(it.x - (screenPos.width / 2)) * scale, y = -(it.y - (screenPos.height / 2)) * scale)
                    }
                )
            },
            imageModel = { image },
            imageOptions = ImageOptions(
                contentScale = ContentScale.Fit,
                alignment = Alignment.Center
            ),
            loading = {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) { CircularProgressIndicator() }
            },
            failure = { GlideImage(imageModel = { R.mipmap.ic_launcher }) }
        )
    }

}

Finally this is an example of how to call this Composable into view from the MainActivity.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SampleImageTransformationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    ImageTransform(
                        modifier = Modifier
                            .padding(innerPadding),
                        R.mipmap.ic_launcher
                    )
                }
            }
        }
    }
}

Hope this was useful.