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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-13
|
||||
@@ -0,0 +1,74 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Текущее устройство просмотра:
|
||||
|
||||
- `ViewerScreen.kt`: `ViewerContent` держит `HorizontalPager`; `pagerScrollEnabled =
|
||||
current?.isPanorama != true`. `MediaPage` разветвляет видео / панораму / изображение.
|
||||
- Изображение рисуется `GaloAsyncImage(... ContentScale.Fit)` — статично, без жестов.
|
||||
- Панорама уже имеет pinch-зум через `PanoramaView` + `PanoramaCamera`.
|
||||
- Веб-просмотрщик (`frontend/.../Viewer.svelte`) задаёт семантику зума: scale —
|
||||
множитель к натуральному размеру, предел 200% (`MAX_ZOOM = 2`), минимум — fit
|
||||
(`fitScale`), при зуме догружается `original_url`, зум сбрасывается при смене фото.
|
||||
Тач-жестов в вебе нет — жесты проектируем заново.
|
||||
- Прецедент ручных жестов: `AvatarCropDialog.kt` (`pointerInput { detectDragGestures }`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Дать зум обычных изображений в Android-просмотрщике с веб-паритетом по семантике
|
||||
(предел от натурального размера, догрузка оригинала).
|
||||
- Сохранить корректную навигацию по альбому (свайп) вне зума.
|
||||
|
||||
**Non-Goals:**
|
||||
- Зум видео (исключён требованием просмотра медиа).
|
||||
- Изменения панорамы — её зум остаётся как есть.
|
||||
- Изменения веб-клиента и сервера.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Ручные жесты вместо библиотеки
|
||||
Зум реализуется composable-обёрткой над изображением с `Modifier.pointerInput` +
|
||||
`graphicsLayer` (scale/translation), без сторонних зависимостей. Альтернатива —
|
||||
библиотека зума; отклонено: в проекте уже принят ручной подход (`AvatarCropDialog`),
|
||||
а новых зависимостей избегаем.
|
||||
|
||||
### Масштаб как множитель к натуральному размеру (вариант A)
|
||||
Храним `scale` относительно натурального размера, как в вебе:
|
||||
`fitScale = min(viewW / natW, viewH / natH, 1)`, допустимый диапазон
|
||||
`[fitScale, 2.0]` (200% натурального). Натуральные размеры берём из `Photo.width/height`
|
||||
(уже есть в модели), при отсутствии — из измеренного `intrinsicSize` изображения.
|
||||
Альтернатива (множитель от fit) отклонена в пользу веб-паритета.
|
||||
|
||||
### Двойной тап -> 100% натурального с якорем в точку тапа
|
||||
Двойной тап переключает fit <-> 100% натурального размера, центрируя на точке нажатия.
|
||||
Это тач-замена веб-`Space`, но с якорем в палец вместо центра.
|
||||
|
||||
### Состояние зума живёт в `ViewerContent`
|
||||
`zoomed` поднимается в `ViewerContent`, а zoomable-обёртка сообщает его вверх через
|
||||
колбэк. `pagerScrollEnabled = current?.isPanorama != true && !zoomed` — по той же логике,
|
||||
что уже применяется к панораме. Альтернатива (держать состояние внутри `MediaPage`) не
|
||||
даёт управлять pager'ом.
|
||||
|
||||
### Догрузка оригинала через Coil по стабильному ключу
|
||||
При входе в зум источник изображения переключается с `previewUrl` на `originalUrl`;
|
||||
Coil кэширует по устойчивому ключу объекта (`ObjectCacheKey`), поэтому подмена не ломает
|
||||
кэш. Пока оригинал грузится, на экране остаётся превью: запрос оригинала получает
|
||||
`placeholderMemoryCacheKey` превью, чтобы вход в зум не мигал пустым (чёрным) фоном.
|
||||
Сбрасывается при смене фото.
|
||||
|
||||
### Сброс при смене фото
|
||||
Состояние зума живёт на страницу pager (ключ — id фото); при переходе на другой элемент
|
||||
оно пересоздаётся во вписанном виде, что соответствует требованию сброса.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Свайп pager перехватывает горизонтальный жест увеличенного фото] -> `pagerScrollEnabled`
|
||||
выключается, пока `zoomed`.
|
||||
- [`Photo.width/height` может быть 0 до извлечения EXIF] -> fallback на `intrinsicSize`
|
||||
загруженного изображения; при 0/0 зум временно недоступен.
|
||||
- [Догрузка оригинала расходует трафик при каждом входе в зум] -> загрузка только по факту
|
||||
зума и с дисковым кэшем Coil.
|
||||
- [Точность якоря при двойном тапе и pinch] -> покрываем юнит-тестом на пересчёт
|
||||
scale/offset.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
В веб-просмотрщике фото можно приблизить (колесо, `Space`), а в Android-клиенте
|
||||
изображения в полноэкранном просмотре открываются только во вписанном виде — в 360-панораме
|
||||
зум есть, в обычных фото нет. Пользователь не может рассмотреть деталь снимка на телефоне.
|
||||
|
||||
## What Changes
|
||||
|
||||
- В полноэкранный просмотр обычных изображений добавляется зум пальцами:
|
||||
- pinch увеличивает/уменьшает, панорамирование при увеличении, двойной тап
|
||||
переключает вписанный вид и 100% натурального размера с якорем в точку тапа.
|
||||
- Предел зума — 200% натурального размера изображения, минимум — вписанный вид
|
||||
(семантика веб-просмотрщика, адаптированная под тач).
|
||||
- При входе в зум догружается оригинал (`originalUrl`), другие фото сбрасываются во
|
||||
вписанный вид при переходе.
|
||||
- Пока изображение увеличено, горизонтальный свайп не листает альбом — жест отдан
|
||||
панорамированию (как для панорамы).
|
||||
- Исключения: видео и 360-панорама не затрагиваются (у панорамы зум уже есть).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- none -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `android-client`: требование «Просмотр медиа на весь экран» расширяется зумом
|
||||
изображений — pinch, панорамирование, двойной тап, предел, догрузка оригинала,
|
||||
поведение pager и сброс при смене фото.
|
||||
|
||||
## Impact
|
||||
|
||||
- `android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt` — обёртка
|
||||
изображения и проброс состояния зума в `ViewerContent` (блокировка pager).
|
||||
- Новый zoomable-компонент изображения (ручной `pointerInput` + `graphicsLayer`),
|
||||
без новых зависимостей.
|
||||
- Тесты: Robolectric «свайп не листает увеличенное фото»; юнит на предел и якорь.
|
||||
- Серверная часть и веб-клиент не меняются.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Просмотр медиа на весь экран
|
||||
|
||||
Полноэкранный просмотр SHALL поддерживать изображение с зумом пальцами, видео с постером
|
||||
и управлением воспроизведением, а также панораму 360; навигация между элементами альбома
|
||||
SHALL выполняться свайпом.
|
||||
|
||||
#### Scenario: Свайп между элементами
|
||||
|
||||
- **WHEN** пользователь свайпает в просмотре
|
||||
- **THEN** показывается следующий или предыдущий элемент альбома без выхода из просмотра
|
||||
|
||||
#### Scenario: Видео
|
||||
|
||||
- **WHEN** открыт видеоэлемент
|
||||
- **THEN** показывается постер и доступно воспроизведение с перемоткой
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Зум изображения жестами
|
||||
|
||||
Полноэкранный просмотр изображения SHALL поддерживать увеличение и уменьшение щипком
|
||||
(pinch), а при увеличенном изображении — панорамирование перетаскиванием.
|
||||
|
||||
#### Scenario: Щипок увеличивает изображение
|
||||
|
||||
- **WHEN** пользователь разводит пальцы на изображении
|
||||
- **THEN** изображение увеличивается относительно вписанного вида
|
||||
|
||||
#### Scenario: Панорамирование при увеличении
|
||||
|
||||
- **WHEN** изображение увеличено, и пользователь перетаскивает его пальцем
|
||||
- **THEN** видимая область изображения смещается, а границы изображения не отрываются от краёв области просмотра
|
||||
|
||||
### Requirement: Двойной тап переключает вписанный вид и 100%
|
||||
|
||||
Двойной тап по изображению SHALL переключать вписанный вид и масштаб 100% натурального
|
||||
размера с якорем в точку нажатия.
|
||||
|
||||
#### Scenario: Вход в 100% из вписанного вида
|
||||
|
||||
- **WHEN** изображение показано вписанным, и пользователь делает двойной тап
|
||||
- **THEN** изображение показывается в масштабе 100% натурального размера с центром в точке нажатия
|
||||
|
||||
#### Scenario: Возврат во вписанный вид
|
||||
|
||||
- **WHEN** изображение увеличено, и пользователь делает двойной тап
|
||||
- **THEN** изображение возвращается во вписанный вид
|
||||
|
||||
### Requirement: Предел зума по натуральному размеру
|
||||
|
||||
Зум изображения SHALL ограничиваться снизу вписанным видом, а сверху — 200% натурального
|
||||
размера изображения; при входе в зум SHALL догружаться оригинал изображения.
|
||||
|
||||
#### Scenario: Верхний предел
|
||||
|
||||
- **WHEN** пользователь продолжает увеличивать изображение за 200% натурального размера
|
||||
- **THEN** масштаб останавливается на 200%, изображение не увеличивается дальше
|
||||
|
||||
#### Scenario: Оригинал при увеличении
|
||||
|
||||
- **WHEN** пользователь входит в зум
|
||||
- **THEN** догружается оригинал изображения и подменяет превью по факту загрузки
|
||||
|
||||
### Requirement: Навигация и сброс при зуме
|
||||
|
||||
Пока изображение увеличено, горизонтальный свайп SHALL NOT листать альбом, а жест SHALL
|
||||
отдаваться панорамированию; при переходе на другой элемент просмотра зум SHALL сбрасываться
|
||||
во вписанный вид.
|
||||
|
||||
#### Scenario: Свайп не листает увеличенное изображение
|
||||
|
||||
- **WHEN** изображение увеличено, и пользователь свайпает по нему
|
||||
- **THEN** альбом не листается, а изображение панорамируется
|
||||
|
||||
#### Scenario: Сброс при смене фото
|
||||
|
||||
- **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению
|
||||
- **THEN** изображение показывается во вписанном виде
|
||||
@@ -0,0 +1,26 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Zoomable image component
|
||||
|
||||
- [x] 1.1 Implement зум-математику (диапазон `[fitScale, 2.0]`, якорь в точку жеста) отдельным
|
||||
тестируемым помощником; проверить юнит-тестом клампы предела и пересчёт якоря при double-tap
|
||||
- [x] 1.2 Реализовать слой жестов (pinch + панорамирование при зуме + double-tap) над изображением
|
||||
через `pointerInput` + `graphicsLayer`; проверить Compose-тестом (Robolectric) на pinch и double-tap
|
||||
- [x] 1.3 Переключать источник на `originalUrl` при входе в зум, сохраняя cache key Coil; проверить,
|
||||
что запрос данных меняется при `zoomed` и не сбрасывает дисковый кэш
|
||||
|
||||
## 2. Viewer integration
|
||||
|
||||
- [x] 2.1 Поднять `zoomed` в `ViewerContent` и учитывать его в `pagerScrollEnabled`; проверить
|
||||
Robolectric-тестом «свайп не листает увеличенное фото»
|
||||
- [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при
|
||||
переходе на другой элемент и обратно изображение показывается во вписанном виде
|
||||
|
||||
## 3. Integration verification
|
||||
|
||||
- [x] 3.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch,
|
||||
панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Архив change после прохождения ревью проекта.
|
||||
Reference in New Issue
Block a user