feat(viewer): show a loading spinner while the zoomed original loads

Show a spinner in the top-right of the photo viewer while the original is
being loaded on zoom, so the wait is visible. Only the original triggers it;
the fitted preview and a re-shown preview never do.

Cover the visibility gate with a unit test.

OpenSpec change: add-mobile-photo-zoom
This commit is contained in:
iu
2026-10-13 12:40:53 +03:00
parent f6df9e23f2
commit beeb4c7c09
4 changed files with 67 additions and 2 deletions
@@ -9,6 +9,9 @@ import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -16,8 +19,10 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -25,12 +30,14 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.galo.android.data.image.ObjectCacheKey import com.galo.android.data.image.ObjectCacheKey
import com.galo.android.domain.viewer.PhotoZoom import com.galo.android.domain.viewer.PhotoZoom
const val TAG_ZOOMABLE_IMAGE = "zoomable_image" const val TAG_ZOOMABLE_IMAGE = "zoomable_image"
const val TAG_ZOOM_LOADING = "zoom_loading"
/** /**
* Image source shown for the current zoom state: the original when the image is * Image source shown for the current zoom state: the original when the image is
@@ -61,6 +68,18 @@ internal fun zoomImageRequest(context: Context, source: String?, previewUrl: Str
.build() .build()
} }
/**
* True while the zoomed-in original is loading, i.e. the corner spinner is shown.
* Requires a source distinct from the fitted preview, so re-loading the preview
* itself (e.g. when no separate original exists) never shows the spinner.
*/
internal fun showZoomLoading(
zoomed: Boolean,
source: String?,
previewUrl: String?,
loading: Boolean,
): Boolean = zoomed && loading && source != null && source != previewUrl
/** /**
* Photo that can be zoomed and panned, mirroring the web viewer semantics (see * Photo that can be zoomed and panned, mirroring the web viewer semantics (see
* `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to * `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to
@@ -88,6 +107,7 @@ fun ZoomableImage(
var panX by remember { mutableFloatStateOf(0f) } var panX by remember { mutableFloatStateOf(0f) }
var panY by remember { mutableFloatStateOf(0f) } var panY by remember { mutableFloatStateOf(0f) }
var zoomed by remember { mutableStateOf(false) } var zoomed by remember { mutableStateOf(false) }
var originalLoading by remember { mutableStateOf(false) }
fun sync() { fun sync() {
layerScale = zoom.layerScale layerScale = zoom.layerScale
@@ -172,7 +192,9 @@ fun ZoomableImage(
model = request, model = request,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onLoading = { originalLoading = true },
onSuccess = { state -> onSuccess = { state ->
originalLoading = false
if (natural.width <= 0 || natural.height <= 0) { if (natural.width <= 0 || natural.height <= 0) {
val drawable = state.result.drawable val drawable = state.result.drawable
if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) {
@@ -180,6 +202,7 @@ fun ZoomableImage(
} }
} }
}, },
onError = { originalLoading = false },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
@@ -189,5 +212,16 @@ fun ZoomableImage(
translationY = panY translationY = panY
}, },
) )
if (showZoomLoading(zoomed, source, previewUrl, originalLoading)) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.size(24.dp)
.testTag(TAG_ZOOM_LOADING),
)
}
} }
} }
@@ -2,6 +2,8 @@ package com.galo.android.ui.viewer
import com.galo.android.data.image.ObjectCacheKey import com.galo.android.data.image.ObjectCacheKey
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ZoomSourceTest { class ZoomSourceTest {
@@ -25,6 +27,15 @@ class ZoomSourceTest {
assertEquals(preview, zoomSource(preview, "", zoomed = true)) assertEquals(preview, zoomSource(preview, "", zoomed = true))
} }
@Test
fun `loading spinner shows only while the zoomed original is loading`() {
assertTrue(showZoomLoading(zoomed = true, source = original, previewUrl = preview, loading = true))
assertFalse("fitted view", showZoomLoading(false, original, preview, true))
assertFalse("not loading", showZoomLoading(true, original, preview, false))
assertFalse("preview is the source", showZoomLoading(true, preview, preview, true))
assertFalse("no source", showZoomLoading(true, null, preview, true))
}
@Test @Test
fun `switching source keeps a signature-independent cache key`() { fun `switching source keeps a signature-independent cache key`() {
// The Coil key drops the rotating signature, so re-serving the same object // The Coil key drops the rotating signature, so re-serving the same object
@@ -80,3 +80,18 @@ SHALL выполняться свайпом.
- **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению - **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению
- **THEN** изображение показывается во вписанном виде - **THEN** изображение показывается во вписанном виде
### Requirement: Индикатор загрузки оригинала
Пока оригинал изображения загружается после входа в зум, приложение SHALL показывать индикатор
загрузки в правом верхнем углу области просмотра.
#### Scenario: Оригинал загружается
- **WHEN** пользователь входит в зум, и оригинал ещё загружается
- **THEN** в правом верхнем углу показывается индикатор загрузки
#### Scenario: Загрузка завершена
- **WHEN** оригинал загружен или недоступен
- **THEN** индикатор загрузки скрыт
@@ -16,9 +16,14 @@
- [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при - [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при
переходе на другой элемент и обратно изображение показывается во вписанном виде переходе на другой элемент и обратно изображение показывается во вписанном виде
## 3. Integration verification ## 3. Loading indicator
- [x] 3.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch, - [x] 3.1 Показывать индикатор загрузки в правом верхнем углу, пока грузится оригинал при зуме;
проверить тестом условие показа и что вписанный вид индикатор не показывает
## 4. Integration verification
- [x] 4.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch,
панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты
## Workflow follow-up ## Workflow follow-up