feat(viewer): add pinch-to-zoom for photos on Android

Add pinch/pan/double-tap zoom to the full-screen photo viewer, mirroring the
web viewer semantics:

- scale is a multiplier of the natural size, capped at 200%; the fitted view
  is the lower bound
- double tap toggles fitted view <-> 100% natural anchored at the tap
- the original loads on zoom, with the preview kept as a placeholder so
  entering zoom never flashes a black surface
- the pager is disabled while zoomed (drags pan the image) and the zoom resets
  when leaving a photo
- video and 360 panoramas are unaffected

Implement PhotoZoom (zoom math) and ZoomableImage (gestures), wire it into
ViewerContent/MediaPage, and cover it with unit, Compose and device tests.

OpenSpec change: add-mobile-photo-zoom
This commit is contained in:
iu
2026-10-13 12:30:49 +03:00
parent 43f5520236
commit f6df9e23f2
14 changed files with 949 additions and 10 deletions
@@ -0,0 +1,125 @@
package com.galo.android.domain.viewer
/**
* Zoom and pan state for a single photo in the full-screen viewer, mirroring the
* web `Viewer.svelte` semantics: [scale] is a multiplier of the natural image
* size, [fitScale] is the largest scale that keeps the whole image inside the
* viewport (never above 1x), and zoom is capped at [MAX_SCALE] (200% of the
* natural size). Pan offsets are clamped so the image never detaches from the
* viewport edges.
*
* Kept free of Android/Compose types so the zoom math is unit-testable.
*/
class PhotoZoom(
naturalWidth: Float = 0f,
naturalHeight: Float = 0f,
viewportWidth: Float = 0f,
viewportHeight: Float = 0f,
) {
private var naturalWidth = naturalWidth
private var naturalHeight = naturalHeight
private var viewportWidth = viewportWidth
private var viewportHeight = viewportHeight
/** Current scale as a multiplier of the natural image size. */
var scale: Float = 1f
private set
/** Horizontal pan of the image centre from the viewport centre, in pixels. */
var offsetX: Float = 0f
private set
/** Vertical pan of the image centre from the viewport centre, in pixels. */
var offsetY: Float = 0f
private set
init {
reset()
}
/** Largest scale that fits the whole image inside the viewport, capped at 1x. */
val fitScale: Float
get() {
if (naturalWidth <= 0f || naturalHeight <= 0f || viewportWidth <= 0f || viewportHeight <= 0f) {
return 1f
}
return minOf(viewportWidth / naturalWidth, viewportHeight / naturalHeight, 1f)
}
/** True when the image is drawn larger than the fitted view. */
val isZoomed: Boolean
get() = scale > fitScale + ZOOMED_EPSILON
/** Displayed width/height for the current scale. */
val displayWidth: Float get() = naturalWidth * scale
val displayHeight: Float get() = naturalHeight * scale
/** `graphicsLayer` scale relative to the fitted image (1.0 == fitted view). */
val layerScale: Float
get() = if (fitScale > 0f) scale / fitScale else 1f
fun setViewport(width: Float, height: Float) {
if (width == viewportWidth && height == viewportHeight) return
viewportWidth = width
viewportHeight = height
reset()
}
fun setNaturalSize(width: Float, height: Float) {
if (width == naturalWidth && height == naturalHeight) return
naturalWidth = width
naturalHeight = height
reset()
}
fun reset() {
scale = fitScale
offsetX = 0f
offsetY = 0f
}
/** Scale by [factor] keeping the viewport-relative focus point fixed. */
fun scaleBy(factor: Float, focusX: Float, focusY: Float) {
scaleTo(scale * factor, focusX, focusY)
}
/**
* Set [scale] to [target] (clamped to [fitScale]..[MAX_SCALE]) keeping the
* point under [focusX],[focusY] - offsets from the viewport centre - fixed.
*/
fun scaleTo(target: Float, focusX: Float = 0f, focusY: Float = 0f) {
val clamped = target.coerceIn(fitScale, MAX_SCALE)
if (clamped == scale) return
val ratio = clamped / scale
offsetX = focusX - (focusX - offsetX) * ratio
offsetY = focusY - (focusY - offsetY) * ratio
scale = clamped
clampOffsets()
}
/** Pan the (already zoomed) image by a viewport-relative delta. */
fun panBy(dx: Float, dy: Float) {
offsetX += dx
offsetY += dy
clampOffsets()
}
/** Double tap: toggle fitted view <-> 100% natural, anchored at the tap. */
fun toggleDoubleTap(focusX: Float = 0f, focusY: Float = 0f) {
if (isZoomed) reset() else scaleTo(1f, focusX, focusY)
}
private fun clampOffsets() {
val maxX = ((displayWidth - viewportWidth) / 2f).coerceAtLeast(0f)
val maxY = ((displayHeight - viewportHeight) / 2f).coerceAtLeast(0f)
offsetX = offsetX.coerceIn(-maxX, maxX)
offsetY = offsetY.coerceIn(-maxY, maxY)
}
companion object {
/** Upper zoom bound: 200% of the natural image size. */
const val MAX_SCALE = 2f
private const val ZOOMED_EPSILON = 1e-3f
}
}
@@ -18,11 +18,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -31,7 +32,6 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_VIEWER_PAGER = "viewer_pager"
@@ -84,9 +84,11 @@ fun ViewerContent(
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
) { photos.size }
val current = photos.getOrNull(pagerState.currentPage)
// A 360 panorama wraps horizontally forever, so on panorama pages the pager
// must not steal horizontal drags: they rotate the view instead.
val pagerScrollEnabled = current?.isPanorama != true
// Reset the zoom when the current photo changes; while an image is zoomed the
// pager must not steal horizontal drags - they pan the image instead. A 360
// panorama wraps forever, so its pages keep the drag to rotate the view.
var zoomed by remember(current?.id) { mutableStateOf(false) }
val pagerScrollEnabled = current?.isPanorama != true && !zoomed
Scaffold(
topBar = {
@@ -116,14 +118,22 @@ fun ViewerContent(
key = { index -> photos.getOrNull(index)?.id ?: index },
) { page ->
val photo = photos.getOrNull(page) ?: return@HorizontalPager
MediaPage(photo)
MediaPage(
photo = photo,
active = pagerState.currentPage == page,
onZoomChanged = { zoomed = it },
)
}
}
}
}
@Composable
private fun MediaPage(photo: Photo) {
private fun MediaPage(
photo: Photo,
active: Boolean,
onZoomChanged: (Boolean) -> Unit,
) {
when {
photo.kind == MediaKind.VIDEO -> {
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
@@ -144,10 +154,14 @@ private fun MediaPage(photo: Photo) {
}
else -> {
GaloAsyncImage(
url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
ZoomableImage(
previewUrl = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
hiResUrl = photo.originalUrl,
contentDescription = photo.originalFilename,
contentScale = ContentScale.Fit,
naturalWidth = photo.width,
naturalHeight = photo.height,
onZoomChanged = onZoomChanged,
active = active,
modifier = Modifier.fillMaxSize(),
)
}
@@ -0,0 +1,193 @@
package com.galo.android.ui.viewer
import android.content.Context
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntSize
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.galo.android.data.image.ObjectCacheKey
import com.galo.android.domain.viewer.PhotoZoom
const val TAG_ZOOMABLE_IMAGE = "zoomable_image"
/**
* Image source shown for the current zoom state: the original when the image is
* zoomed and an original URL is available, otherwise the fitted preview.
*/
internal fun zoomSource(previewUrl: String?, hiResUrl: String?, zoomed: Boolean): String? =
if (zoomed && !hiResUrl.isNullOrBlank()) hiResUrl else previewUrl
/**
* Coil request for [source], cached by the stable object path. When [source]
* differs from [previewUrl] (i.e. the original is loading on zoom) the preview
* is wired as a placeholder, so the surface never flashes empty/black.
*/
internal fun zoomImageRequest(context: Context, source: String?, previewUrl: String?): ImageRequest {
val previewKey = ObjectCacheKey.keyFor(previewUrl)
return ImageRequest.Builder(context)
.data(source)
.apply {
ObjectCacheKey.keyFor(source)?.let {
memoryCacheKey(it)
diskCacheKey(it)
}
if (source != previewUrl) {
previewKey?.let { placeholderMemoryCacheKey(it) }
}
}
.crossfade(true)
.build()
}
/**
* Photo that can be zoomed and panned, mirroring the web viewer semantics (see
* `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to
* [hiResUrl] (the original) so details stay sharp.
*
* While zoomed, horizontal drags pan the image instead of paging the album.
* [onZoomChanged] reports the zoom state so the surrounding pager can disable
* itself; [active] resets the zoom when the page leaves focus.
*/
@Composable
fun ZoomableImage(
previewUrl: String?,
hiResUrl: String?,
contentDescription: String?,
naturalWidth: Int,
naturalHeight: Int,
onZoomChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
active: Boolean = true,
) {
val zoom = remember { PhotoZoom() }
var viewport by remember { mutableStateOf(IntSize.Zero) }
var natural by remember { mutableStateOf(IntSize(naturalWidth, naturalHeight)) }
var layerScale by remember { mutableFloatStateOf(1f) }
var panX by remember { mutableFloatStateOf(0f) }
var panY by remember { mutableFloatStateOf(0f) }
var zoomed by remember { mutableStateOf(false) }
fun sync() {
layerScale = zoom.layerScale
panX = zoom.offsetX
panY = zoom.offsetY
val nowZoomed = zoom.isZoomed
if (nowZoomed != zoomed) {
zoomed = nowZoomed
onZoomChanged(nowZoomed)
}
}
LaunchedEffect(viewport, natural) {
zoom.setNaturalSize(natural.width.toFloat(), natural.height.toFloat())
zoom.setViewport(viewport.width.toFloat(), viewport.height.toFloat())
sync()
}
// Leaving the page resets the zoom so returning shows the fitted photo.
LaunchedEffect(active) {
if (!active) {
zoom.reset()
sync()
}
}
val source = zoomSource(previewUrl, hiResUrl, zoomed)
Box(
modifier = modifier
.onSizeChanged { viewport = it }
.testTag(TAG_ZOOMABLE_IMAGE)
.pointerInput(viewport) {
if (viewport.width <= 0 || viewport.height <= 0) return@pointerInput
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val pressed = event.changes.count { it.pressed }
if (pressed >= 2) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
if (zoomChange != 1f || panChange != Offset.Zero) {
val centroid = event.calculateCentroid(useCurrent = true)
zoom.scaleBy(
zoomChange,
focusX = centroid.x - viewport.width / 2f,
focusY = centroid.y - viewport.height / 2f,
)
zoom.panBy(panChange.x, panChange.y)
sync()
event.changes.forEach { it.consume() }
}
} else if (pressed == 1 && zoom.isZoomed) {
val panChange = event.calculatePan()
if (panChange != Offset.Zero) {
zoom.panBy(panChange.x, panChange.y)
sync()
event.changes.forEach { it.consume() }
}
}
} while (event.changes.any { it.pressed })
}
}
.pointerInput(viewport) {
detectTapGestures(
onDoubleTap = { position ->
zoom.toggleDoubleTap(
focusX = position.x - viewport.width / 2f,
focusY = position.y - viewport.height / 2f,
)
sync()
},
)
},
) {
val context = LocalContext.current
val request = remember(source, previewUrl, context) {
zoomImageRequest(context, source, previewUrl)
}
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
onSuccess = { state ->
if (natural.width <= 0 || natural.height <= 0) {
val drawable = state.result.drawable
if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) {
natural = IntSize(drawable.intrinsicWidth, drawable.intrinsicHeight)
}
}
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = layerScale
scaleY = layerScale
translationX = panX
translationY = panY
},
)
}
}