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.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -31,7 +32,6 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
|||||||
import com.galo.android.GaloApplication
|
import com.galo.android.GaloApplication
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
import com.galo.android.domain.model.Photo
|
import com.galo.android.domain.model.Photo
|
||||||
import com.galo.android.ui.common.GaloAsyncImage
|
|
||||||
|
|
||||||
const val TAG_VIEWER_PAGER = "viewer_pager"
|
const val TAG_VIEWER_PAGER = "viewer_pager"
|
||||||
|
|
||||||
@@ -84,9 +84,11 @@ fun ViewerContent(
|
|||||||
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
|
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
|
||||||
) { photos.size }
|
) { photos.size }
|
||||||
val current = photos.getOrNull(pagerState.currentPage)
|
val current = photos.getOrNull(pagerState.currentPage)
|
||||||
// A 360 panorama wraps horizontally forever, so on panorama pages the pager
|
// Reset the zoom when the current photo changes; while an image is zoomed the
|
||||||
// must not steal horizontal drags: they rotate the view instead.
|
// pager must not steal horizontal drags - they pan the image instead. A 360
|
||||||
val pagerScrollEnabled = current?.isPanorama != true
|
// 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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -116,14 +118,22 @@ fun ViewerContent(
|
|||||||
key = { index -> photos.getOrNull(index)?.id ?: index },
|
key = { index -> photos.getOrNull(index)?.id ?: index },
|
||||||
) { page ->
|
) { page ->
|
||||||
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
||||||
MediaPage(photo)
|
MediaPage(
|
||||||
|
photo = photo,
|
||||||
|
active = pagerState.currentPage == page,
|
||||||
|
onZoomChanged = { zoomed = it },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaPage(photo: Photo) {
|
private fun MediaPage(
|
||||||
|
photo: Photo,
|
||||||
|
active: Boolean,
|
||||||
|
onZoomChanged: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
when {
|
when {
|
||||||
photo.kind == MediaKind.VIDEO -> {
|
photo.kind == MediaKind.VIDEO -> {
|
||||||
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
|
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
|
||||||
@@ -144,10 +154,14 @@ private fun MediaPage(photo: Photo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
GaloAsyncImage(
|
ZoomableImage(
|
||||||
url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
|
previewUrl = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
|
||||||
|
hiResUrl = photo.originalUrl,
|
||||||
contentDescription = photo.originalFilename,
|
contentDescription = photo.originalFilename,
|
||||||
contentScale = ContentScale.Fit,
|
naturalWidth = photo.width,
|
||||||
|
naturalHeight = photo.height,
|
||||||
|
onZoomChanged = onZoomChanged,
|
||||||
|
active = active,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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
|
package com.galo.android.ui.viewer
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.compose.ui.test.pinch
|
||||||
import androidx.compose.ui.test.swipeLeft
|
import androidx.compose.ui.test.swipeLeft
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
import com.galo.android.domain.model.Photo
|
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.
|
// Paging is disabled on panorama pages so the drag can rotate the view.
|
||||||
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
|
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