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())
}
}