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,67 @@
package com.galo.android
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pinch
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.ui.theme.GaloTheme
import com.galo.android.ui.viewer.TAG_ZOOMABLE_IMAGE
import com.galo.android.ui.viewer.ZoomableImage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicBoolean
/**
* Device check for the photo zoom: a real pinch enlarges the image and a double
* tap returns it to the fitted view (the 200% cap and pan clamp are covered by
* the unit tests for `PhotoZoom`).
*/
@RunWith(AndroidJUnit4::class)
class ZoomGestureTest {
@get:Rule
val compose = createComposeRule()
@Test
fun pinchZoomsAndDoubleTapResets() {
val zoomed = AtomicBoolean(false)
compose.setContent {
GaloTheme(dynamicColor = false) {
Box(Modifier.fillMaxSize()) {
ZoomableImage(
previewUrl = null,
hiResUrl = null,
contentDescription = null,
naturalWidth = 2000,
naturalHeight = 1000,
onZoomChanged = { zoomed.set(it) },
)
}
}
}
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
pinch(
start0 = center + Offset(-60f, 0f),
end0 = center + Offset(-260f, 0f),
start1 = center + Offset(60f, 0f),
end1 = center + Offset(260f, 0f),
)
}
compose.waitForIdle()
assertTrue("pinch should zoom the image", zoomed.get())
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() }
compose.waitForIdle()
assertFalse("double tap should return to the fitted view", zoomed.get())
}
}
@@ -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
},
)
}
}
@@ -0,0 +1,94 @@
package com.galo.android.domain.viewer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PhotoZoomTest {
// 2000x1000 image in a 1000x1000 viewport -> fitScale = min(0.5, 1, 1) = 0.5.
private fun zoom() = PhotoZoom(
naturalWidth = 2000f,
naturalHeight = 1000f,
viewportWidth = 1000f,
viewportHeight = 1000f,
)
private fun imageX(z: PhotoZoom, focusX: Float): Float = (focusX - z.offsetX) / z.scale
@Test
fun `fit scale fits the image and never upscales`() {
assertEquals(0.5f, zoom().fitScale, 1e-4f)
val small = PhotoZoom(100f, 100f, 1000f, 1000f)
assertEquals(1f, small.fitScale, 1e-4f)
}
@Test
fun `starts fitted and not zoomed`() {
val z = zoom()
assertEquals(z.fitScale, z.scale, 1e-4f)
assertEquals(1f, z.layerScale, 1e-4f)
assertFalse(z.isZoomed)
}
@Test
fun `scale is clamped to the fitted view on the low end`() {
val z = zoom()
z.scaleTo(0.1f)
assertEquals(z.fitScale, z.scale, 1e-4f)
}
@Test
fun `scale is clamped to 200 percent on the high end`() {
val z = zoom()
z.scaleTo(5f)
assertEquals(PhotoZoom.MAX_SCALE, z.scale, 1e-4f)
z.scaleBy(10f, focusX = 0f, focusY = 0f)
assertEquals(PhotoZoom.MAX_SCALE, z.scale, 1e-4f)
}
@Test
fun `zoom keeps the focus point under the finger`() {
val z = zoom()
val focusX = 200f
val before = imageX(z, focusX)
z.scaleTo(1f, focusX = focusX, focusY = 0f)
assertEquals(1f, z.scale, 1e-4f)
assertEquals(before, imageX(z, focusX), 1e-3f)
}
@Test
fun `double tap toggles between fitted view and 100 percent`() {
val z = zoom()
z.toggleDoubleTap(focusX = 100f, focusY = 0f)
assertEquals(1f, z.scale, 1e-4f)
assertTrue(z.isZoomed)
z.toggleDoubleTap()
assertEquals(z.fitScale, z.scale, 1e-4f)
assertEquals(0f, z.offsetX, 1e-4f)
assertEquals(0f, z.offsetY, 1e-4f)
assertFalse(z.isZoomed)
}
@Test
fun `pan is clamped so the image stays attached to the viewport`() {
val z = zoom()
z.scaleTo(1f)
// displayWidth = 2000 in a 1000 wide viewport -> max offset 500.
z.panBy(10_000f, 10_000f)
assertEquals(500f, z.offsetX, 1e-3f)
assertEquals(0f, z.offsetY, 1e-3f)
z.panBy(-10_000f, -10_000f)
assertEquals(-500f, z.offsetX, 1e-3f)
}
@Test
fun `pan is a no-op in the fitted view`() {
val z = zoom()
z.panBy(100f, 100f)
assertEquals(0f, z.offsetX, 1e-4f)
assertEquals(0f, z.offsetY, 1e-4f)
}
}
@@ -1,10 +1,12 @@
package com.galo.android.ui.viewer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pinch
import androidx.compose.ui.test.swipeLeft
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
@@ -107,4 +109,34 @@ class ViewerContentTest {
// Paging is disabled on panorama pages so the drag can rotate the view.
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
}
@Test
fun `horizontal swipe does not page away from a zoomed photo`() {
compose.setContent {
GaloTheme(dynamicColor = false) {
ViewerContent(
photos = listOf(photo("p1", "first.jpg"), photo("p2", "second.jpg")),
initialIndex = 0,
onBack = {},
)
}
}
compose.onNodeWithText("first.jpg").assertIsDisplayed()
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
pinch(
start0 = center + Offset(-40f, 0f),
end0 = center + Offset(-160f, 0f),
start1 = center + Offset(40f, 0f),
end1 = center + Offset(160f, 0f),
)
}
compose.waitForIdle()
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() }
compose.waitForIdle()
// Paging is disabled while an image is zoomed so the drag pans it instead.
compose.onNodeWithText("first.jpg").assertIsDisplayed()
}
}
@@ -0,0 +1,38 @@
package com.galo.android.ui.viewer
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ZoomImageRequestTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val preview = "https://galo.test/api/objects/preview.jpg?e=1&s=aaaa"
private val original = "https://galo.test/api/objects/original.jpg?e=1&s=bbbb"
@Test
fun `loading the original keeps the preview as a placeholder`() {
val request = zoomImageRequest(context, original, preview)
assertEquals("https://galo.test/api/objects/original.jpg", request.memoryCacheKey?.key)
assertNotNull(
"the preview must be a placeholder so zoom does not flash black",
request.placeholderMemoryCacheKey,
)
assertEquals("https://galo.test/api/objects/preview.jpg", request.placeholderMemoryCacheKey?.key)
}
@Test
fun `fitted preview has no placeholder`() {
val request = zoomImageRequest(context, preview, preview)
assertNull(request.placeholderMemoryCacheKey)
}
}
@@ -0,0 +1,37 @@
package com.galo.android.ui.viewer
import com.galo.android.data.image.ObjectCacheKey
import org.junit.Assert.assertEquals
import org.junit.Test
class ZoomSourceTest {
private val preview = "https://galo.test/api/objects/preview.jpg?e=1&s=aaaa"
private val original = "https://galo.test/api/objects/original.jpg?e=1&s=bbbb"
@Test
fun `fitted view shows the preview`() {
assertEquals(preview, zoomSource(preview, original, zoomed = false))
}
@Test
fun `zoomed view shows the original`() {
assertEquals(original, zoomSource(preview, original, zoomed = true))
}
@Test
fun `zoomed view falls back to the preview without an original`() {
assertEquals(preview, zoomSource(preview, null, zoomed = true))
assertEquals(preview, zoomSource(preview, "", zoomed = true))
}
@Test
fun `switching source keeps a signature-independent cache key`() {
// The Coil key drops the rotating signature, so re-serving the same object
// under a new signature reuses the cached bytes.
val signed = ObjectCacheKey.keyFor(original)
val rotated = ObjectCacheKey.keyFor(original.replace("s=bbbb", "s=cccc"))
assertEquals("https://galo.test/api/objects/original.jpg", signed)
assertEquals(signed, rotated)
}
}
@@ -0,0 +1,116 @@
package com.galo.android.ui.viewer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pinch
import androidx.compose.ui.unit.dp
import com.galo.android.ui.theme.GaloTheme
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ZoomableImageTest {
@get:Rule
val compose = createComposeRule()
private fun setZoomable(onZoomChanged: (Boolean) -> Unit) {
compose.setContent {
GaloTheme(dynamicColor = false) {
Box(Modifier.size(200.dp)) {
ZoomableImage(
previewUrl = "http://localhost/photo.jpg?e=1&s=2",
hiResUrl = "http://localhost/photo.jpg?e=1&s=2&original=1",
contentDescription = null,
naturalWidth = 2000,
naturalHeight = 1000,
onZoomChanged = onZoomChanged,
)
}
}
}
}
@Test
fun `pinch zooms the image`() {
var zoomed = false
setZoomable { zoomed = it }
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
pinch(
start0 = center + Offset(-40f, 0f),
end0 = center + Offset(-160f, 0f),
start1 = center + Offset(40f, 0f),
end1 = center + Offset(160f, 0f),
)
}
compose.waitForIdle()
assertTrue("pinch should zoom the image", zoomed)
}
@Test
fun `double tap toggles between fitted and zoomed`() {
var zoomed = false
setZoomable { zoomed = it }
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() }
compose.waitForIdle()
assertTrue("double tap should zoom in from the fitted view", zoomed)
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() }
compose.waitForIdle()
assertFalse("double tap should return to the fitted view", zoomed)
}
@Test
fun `leaving the page resets the zoom`() {
var zoomed = false
var active by mutableStateOf(true)
compose.setContent {
GaloTheme(dynamicColor = false) {
Box(Modifier.size(200.dp)) {
ZoomableImage(
previewUrl = "http://localhost/photo.jpg?e=1&s=2",
hiResUrl = "http://localhost/photo.jpg?e=1&s=2&original=1",
contentDescription = null,
naturalWidth = 2000,
naturalHeight = 1000,
onZoomChanged = { zoomed = it },
active = active,
)
}
}
}
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
pinch(
start0 = center + Offset(-40f, 0f),
end0 = center + Offset(-160f, 0f),
start1 = center + Offset(40f, 0f),
end1 = center + Offset(160f, 0f),
)
}
compose.waitForIdle()
assertTrue(zoomed)
active = false
compose.waitForIdle()
assertFalse("zoom should reset when the page leaves focus", zoomed)
}
}