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:
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user