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