Skip to content

Commit 141e59c

Browse files
authored
fix(chat): keep a zoomed photo on screen when the finger lifts (#1751)
1 parent 673f9af commit 141e59c

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import com.getcode.theme.CodeTheme
44
import android.content.Context
55
import android.content.Intent
66
import androidx.compose.animation.core.Animatable
7+
import androidx.compose.animation.core.animateFloatAsState
8+
import androidx.compose.animation.core.snap
79
import androidx.compose.animation.core.tween
810
import androidx.compose.foundation.Image
911
import androidx.compose.foundation.background
@@ -28,6 +30,8 @@ import androidx.compose.runtime.Composable
2830
import androidx.compose.runtime.LaunchedEffect
2931
import androidx.compose.runtime.SideEffect
3032
import androidx.compose.runtime.getValue
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.setValue
3135
import androidx.compose.runtime.remember
3236
import androidx.compose.runtime.rememberCoroutineScope
3337
import androidx.compose.runtime.rememberUpdatedState
@@ -76,7 +80,8 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f
7680
* A full-screen photo.
7781
*
7882
* Pinch zooms from 1× to 4× with the pan held inside the image's edges; a double tap goes to 2.5×
79-
* about the tapped point, or back to fit when already zoomed. A one-finger drag that starts while
83+
* about the tapped point, or back to fit when already zoomed. A single tap hides or shows the
84+
* close and share buttons. A one-finger drag that starts while
8085
* the image is at fit pulls it away; when zoomed the same drag pans instead.
8186
*
8287
* On its own the pull moves the image down, fading the black behind it, and past a fifth of the
@@ -123,6 +128,12 @@ fun ChatMediaViewer(
123128
val currentOnPull by rememberUpdatedState(onPull)
124129
val currentOnPullEnd by rememberUpdatedState(onPullEnd)
125130
val touchSlop = LocalViewConfiguration.current.touchSlop
131+
var chromeVisible by remember { mutableStateOf(true) }
132+
val chromeShown by animateFloatAsState(
133+
targetValue = if (chromeVisible) 1f else 0f,
134+
animationSpec = if (reduceMotion) snap() else tween(200),
135+
label = "chrome",
136+
)
126137

127138
val painter = rememberAsyncImagePainter(
128139
model = model,
@@ -158,6 +169,7 @@ fun ChatMediaViewer(
158169
.testTag(VIEWER_IMAGE_TAG)
159170
.pointerInput(zoomState, reduceMotion) {
160171
detectTapGestures(
172+
onTap = { if (currentInteractive) chromeVisible = !chromeVisible },
161173
onDoubleTap = { tap ->
162174
if (currentInteractive) {
163175
scope.launch { zoomState.toggle(tap, animated = !reduceMotion) }
@@ -291,11 +303,12 @@ fun ChatMediaViewer(
291303
}
292304
}
293305

294-
val chrome = Modifier.graphicsLayer { alpha = frame?.invoke()?.chromeAlpha ?: 1f }
306+
val chrome = Modifier.graphicsLayer { alpha = (frame?.invoke()?.chromeAlpha ?: 1f) * chromeShown }
295307
ViewerButton(
296308
icon = Icons.Filled.Close,
297309
description = stringResource(R.string.description_chatMediaClose),
298310
enabled = interactive,
311+
visible = chromeVisible,
299312
tag = VIEWER_CLOSE_TAG,
300313
onClick = onDismiss,
301314
modifier = chrome.align(Alignment.TopStart),
@@ -304,6 +317,7 @@ fun ChatMediaViewer(
304317
icon = Icons.Outlined.IosShare,
305318
description = stringResource(R.string.description_chatMediaShare),
306319
enabled = fullImageLoaded && interactive,
320+
visible = chromeVisible,
307321
tag = VIEWER_SHARE_TAG,
308322
onClick = onShare,
309323
modifier = chrome.align(Alignment.TopEnd),
@@ -323,13 +337,15 @@ private fun ViewerButton(
323337
icon: androidx.compose.ui.graphics.vector.ImageVector,
324338
description: String,
325339
enabled: Boolean,
340+
visible: Boolean,
326341
tag: String,
327342
onClick: () -> Unit,
328343
modifier: Modifier = Modifier,
329344
) {
330345
IconButton(
331346
onClick = onClick,
332-
enabled = enabled,
347+
// Hidden buttons fade out at full tint but stop taking taps straight away.
348+
enabled = enabled && visible,
333349
modifier = modifier
334350
.statusBarsPadding()
335351
.padding(CodeTheme.dimens.staticGrid.x2)

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
99
import androidx.compose.runtime.setValue
1010
import androidx.compose.ui.geometry.Offset
1111
import androidx.compose.ui.geometry.Size
12+
import androidx.compose.ui.geometry.isSpecified
1213
import kotlin.math.max
1314

1415
/** The zoom arithmetic of the viewer, free of Compose state so it can be tested on the JVM. */
@@ -87,6 +88,8 @@ class ChatMediaZoomState {
8788

8889
/** One step of a pinch or pan: [zoom] is the factor since the last event, [pan] its movement. */
8990
internal fun transform(focus: Offset, pan: Offset, zoom: Float) {
91+
// The event that lifts the last finger has no centroid; its NaN would poison the offset.
92+
if (!focus.isSpecified) return
9093
val newScale = (scale * zoom).coerceIn(ChatMediaZoom.MIN_SCALE, ChatMediaZoom.MAX_SCALE)
9194
val moved = ChatMediaZoom.offsetAfterZoom(offset, focus, scale, newScale, pan, container)
9295
scale = newScale

‎apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,21 @@ class ChatMediaZoomTest {
4949
state.transform(Offset(200f, 400f), Offset.Zero, 0.01f)
5050
assertEquals(1f, state.scale)
5151
}
52+
53+
@Test
54+
fun transformIgnoresTheLiftEventsMissingCentroid() {
55+
// The event that lifts the last finger has no pointer still down, so calculateCentroid()
56+
// returns Offset.Unspecified (NaN). Applying it made the offset NaN and the photo vanished.
57+
val state = ChatMediaZoomState().apply {
58+
container = this@ChatMediaZoomTest.container
59+
content = Size(400f, 800f)
60+
}
61+
state.transform(Offset(300f, 500f), Offset.Zero, 2f)
62+
val zoomed = state.offset
63+
64+
state.transform(Offset.Unspecified, Offset.Zero, 1f)
65+
66+
assertEquals(2f, state.scale)
67+
assertEquals(zoomed, state.offset)
68+
}
5269
}

‎apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.assert
1111
import androidx.compose.ui.test.assertIsDisplayed
1212
import androidx.compose.ui.test.assertIsEnabled
1313
import androidx.compose.ui.test.assertIsNotEnabled
14+
import androidx.compose.ui.test.click
1415
import androidx.compose.ui.test.doubleClick
1516
import androidx.compose.ui.test.hasProgressBarRangeInfo
1617
import androidx.compose.ui.test.junit4.createComposeRule
@@ -327,5 +328,26 @@ class ChatPhotoMediaUiTest {
327328
assertFalse(zoom.isZoomed)
328329
}
329330

331+
@Test
332+
fun singleTapTogglesTheButtons() {
333+
rule.setContent {
334+
DesignSystem {
335+
ChatMediaViewer(
336+
model = null, fullImageLoaded = true, onFullImageLoaded = {},
337+
onShare = {}, onDismiss = {}, reduceMotion = true,
338+
)
339+
}
340+
}
341+
rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) }
342+
rule.mainClock.advanceTimeBy(1_000)
343+
rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsNotEnabled()
344+
rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled()
345+
346+
rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) }
347+
rule.mainClock.advanceTimeBy(1_000)
348+
rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled()
349+
rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled()
350+
}
351+
330352
// endregion
331353
}

0 commit comments

Comments
 (0)