Open Source a Compose Picture viewer library ImageViewer, supporting multiple gestures

ZhangKe
6 min readAug 5, 2024

--

Early Sunday Morning

Some time ago, I needed a feature in my project for viewing large images in full screen. I checked around but couldn’t find a suitable library. Some were okay to use, but I realized there was a problem with scrolling conflict when they were put in Pager. So I decided to write one myself. The final effect is really good, and it basically supports all kinds of gesture operations.

Having used it for nearly half a year now without issues, now that I have some time, I decided to extract a part of the “viewing large images” functionality and open-source it. My hope is that it may help others out there. Currently it supports the following features:

  • Double-click to zoom in/zoom out
  • Two-finger scaling
  • Slide down to close
  • Click to close
  • Zoom in for drag viewing
  • Embedded in Pager

Using ImageViewer

First, add dependencies:

implementation("com.github.0xZhangKe:ImageViewer:1.0.3")

The dependency package repository is on JitPack, so you might also need to configure and add the JitPack repository. If you already have it, simply ignore it.

repositories {
maven { setUrl("<https://jitpack.io>") }
}

Repository address:

https://github.com/0xZhangKe/ImageViewer

The simplest way to use it is as follows:

ImageViewer {
Image(
painter = painterResource(R.drawable.vertical_demo_image),
contentDescription = "Sample Image",
contentScale = ContentScale.FillBounds,
)
}

ImageViewer is the only Composable function provided by this project. It’s an image container that will support all the above gesture operations. It contains a function content of type Composable as an argument. Considering that image frameworks differ by project, it is not dependent on any image framework. All you need to do is write the image code in the content function that's part of the argument.

For example, the code uses Image, but it can be substituted with any other framework, like Coil’s AsyncImage, etc.

In addition, you don’t usually need to set a size for the above Image. The internal rules will adjust it dynamically. After setting the size, some strange changes might occur. If you want to control the size, you can set ImageViewer. It’s best to also set contentScale to FillBounds.

ImageViewer accepts ImageViewerState to control some behavior.

val imageViewerState = rememberImageViewerState(
minimumScale = 1.0F,
maximumScale = 3F,
onDragDismissRequest = {
finish()
},
)

The onDragDismissRequest is an automatic exit mechanism when the image slides down. By default it’s null, which means this function doesn’t need to be enabled. Sliding the image down will not exit it. But, if you assign a value to onDragDismissRequest, the image will exceed the threshold and call this function when slid down.

Another thing to note is that the content of ImageViewer can only contain one Composable. Having more than one will give a crash.

The above is a usage introduction of ImageViewer. It is very easy to use. Next, I’ll briefly describe its implementation.

Implementation

ImageViewer is implemented through two custom Layouts, it itself is also a container. The outer Layout is used to monitor gestures, and the inner Layout is used to control size and displacement.

Gesture Monitoring

There are three parts to gesture monitoring. The first part monitors single and double click events:

pointerInput(state) {
detectTapGestures(
onDoubleTap = {
if (state.exceed) {
coroutineScope.launch {
state.animateToStandard()
}
} else {
coroutineScope.launch {
state.animateToBig(it)
}
}
},
onTap = {
state.startDismiss()
},
)
}

This is fairly simple: a single click ends, and a double click enlarges or reduces.

The second part listens to drag events and calculates the drag speed to implement the release animation.

private fun Modifier.draggableInfinity(
enabled: Boolean,
onDrag: (dragAmount: Offset) -> Unit,
onDragStopped: (velocity: Velocity) -> Unit,
): Modifier {
val velocityTracker = VelocityTracker()
return Modifier.pointerInput(enabled) {
if (enabled) {
detectDragGestures(
onDrag = { change, dragAmount ->
velocityTracker.addPointerInputChange(change)
onDrag(dragAmount)
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
onDragCancel = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
)
} else {
detectVerticalDragGestures(
onVerticalDrag = { change, dragAmount ->
velocityTracker.addPointerInputChange(change)
onDrag(Offset(x = 0F, y = dragAmount))
},
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
onDragCancel = {
val velocity = velocityTracker.calculateVelocity()
onDragStopped(velocity)
},
)
}
} then this
}

Here, in order to accommodate the event conflict problem of horizontal Pager, different drag events are listened to by setting images. When the picture is in the standard size state, and has not yet been scaled, listen to the vertical direction of the drag event. At this time, you can drag up and down without affecting the horizontal sliding event of Pager.

When the image is in the scaling state, listen to the drag events in all directions. At this time, you can slide the image in any direction to view the details.

Then, during dragging, use VelocityTracker to calculate the sliding speed and set the automatic sliding animation after releasing the hand.

The third part of the event is the double-finger scaling event:

pointerInput(state) {
detectZoom { centroid, zoom ->
state.zoom(centroid, zoom)
}
}

detectZoom is a function I wrote. Although Compose’s own detectTransformGestures can also listen to the two-finger zoom event, it will consume events and cause nested sliding conflicts. So, I wrote a function that only listens to the zoom gesture.

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

awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { 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) {
val centroid = event.calculateCentroid(useCurrent = false)
if (zoomChange != 1f) {
onGesture(centroid, zoomChange)
}
event.changes.forEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
}
}

Using this function can return the zoom ratio and center point without causing sliding conflicts.

Size and Offset

All scaling, offset, and animation of ImageViewer are implemented by controlling the size and offset of the second Layout.

First, the first Layout does not restrict the size of the second-layer Layout during the layout phase. It will give it an infinite constraint, allowing it to have enough size according to its own needs, thereby supporting several times of enlargement.

{ measurables, constraints ->
val placeable = measurables.first().measure(infinityConstraints)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.placeRelative(0, 0)
}
}

You can see that the constraints given in the above measure phase are infinityConstraints.

Next is the layout phase of the second layout. It will obtain the inherent width-to-height ratio of the embedded image by giving a fixed value.

{ measurables, constraints ->
if (measurables.size > 1) {
throw IllegalStateException("ImageViewer is only allowed to have one children!")
}
val firstMeasurable = measurables.first()
val placeable = firstMeasurable.measure(constraints)
val minWidth = firstMeasurable.minIntrinsicWidth(100)
val minHeight = firstMeasurable.minIntrinsicHeight(100)
if (minWidth > 0 && minHeight > 0) {
state.setImageAspectRatio(minWidth / minHeight.toFloat())
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.placeRelative(0, 0)
}
}

Here, you can use minIntrinsicWidth/minIntrinsicHeight to get the inherent width-to-height ratio of the picture. Because the results measured directly are inaccurate, they will always be the size of the second layout.

Then, you only need to apply the size and offset in ImageViewerState to the second Layout.

modifier = Modifier
.offset(
x = state.currentOffsetXPixel.pxToDp(density),
y = state.currentOffsetYPixel.pxToDp(density),
)
.width(state.currentWidthPixel.pxToDp(density))
.height(state.currentHeightPixel.pxToDp(density)),

The several fields in ImageViewerState are State types, so they naturally support animation. We only need to modify these several values in ImageViewerState internally with AnimationState, and the UI part will move with them.

Basically, ImageViewerState is given to listen to gestures and layout information. In actuality, more calculations and processing logic are within ImageViewerState. Its overall internal structure is to modify the following several values according to the information of the UI layer:

private var _currentWidthPixel = mutableFloatStateOf(0F)
private var _currentHeightPixel = mutableFloatStateOf(0F)
private var _currentOffsetXPixel = mutableFloatStateOf(0F)
private var _currentOffsetYPixel = mutableFloatStateOf(0F)
internal val currentWidthPixel: Float by _currentWidthPixel
internal val currentHeightPixel: Float by _currentHeightPixel
internal val currentOffsetXPixel: Float by _currentOffsetXPixel
internal val currentOffsetYPixel: Float by _currentOffsetYPixel

I won’t introduce the specific internal details here, you can go and see the code directly.

Thank you for reading this article. If you’re interested or need it, please go to GitHub and give it a Star.

--

--