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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user