From fe48b1ed92b8fe1eb183f12b364c07fa577ce3a6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 22:10:00 -0400 Subject: [PATCH] fix(chat): keep a zoomed photo on screen when the finger lifts The viewer goes black after a pinch, or after any drag while zoomed. The event that lifts the last finger has no pointer still down, so calculateCentroid() returns Offset.Unspecified (NaN, NaN). The zoomed branch passed it to ChatMediaZoomState.transform, which made the offset NaN; clampOffset keeps NaN, and the graphicsLayer translation moved the photo out of view. A double tap brought it back because zooming out resets the offset to zero. transform now ignores an unspecified focus. ChatMediaZoomTest covers the lift event: it failed with a NaN offset before the change. A single tap on the photo now hides or shows the close and share buttons. They fade over 200ms (instantly with reduced motion) and stop taking taps as soon as they are hidden. Because the tap detector also handles double taps, a single tap waits out the double-tap timeout before toggling. --- .../shared/chat/ui/media/ChatMediaViewer.kt | 22 ++++++++++++++++--- .../chat/ui/media/ChatMediaZoomState.kt | 3 +++ .../shared/chat/ui/media/ChatMediaZoomTest.kt | 17 ++++++++++++++ .../chat/ui/media/ChatPhotoMediaUiTest.kt | 22 +++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 508638ec7..b7382d7a8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -4,6 +4,8 @@ import com.getcode.theme.CodeTheme import android.content.Context import android.content.Intent import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -28,6 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -76,7 +80,8 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * A full-screen photo. * * Pinch zooms from 1× to 4× with the pan held inside the image's edges; a double tap goes to 2.5× - * about the tapped point, or back to fit when already zoomed. A one-finger drag that starts while + * about the tapped point, or back to fit when already zoomed. A single tap hides or shows the + * close and share buttons. A one-finger drag that starts while * the image is at fit pulls it away; when zoomed the same drag pans instead. * * 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( val currentOnPull by rememberUpdatedState(onPull) val currentOnPullEnd by rememberUpdatedState(onPullEnd) val touchSlop = LocalViewConfiguration.current.touchSlop + var chromeVisible by remember { mutableStateOf(true) } + val chromeShown by animateFloatAsState( + targetValue = if (chromeVisible) 1f else 0f, + animationSpec = if (reduceMotion) snap() else tween(200), + label = "chrome", + ) val painter = rememberAsyncImagePainter( model = model, @@ -158,6 +169,7 @@ fun ChatMediaViewer( .testTag(VIEWER_IMAGE_TAG) .pointerInput(zoomState, reduceMotion) { detectTapGestures( + onTap = { if (currentInteractive) chromeVisible = !chromeVisible }, onDoubleTap = { tap -> if (currentInteractive) { scope.launch { zoomState.toggle(tap, animated = !reduceMotion) } @@ -291,11 +303,12 @@ fun ChatMediaViewer( } } - val chrome = Modifier.graphicsLayer { alpha = frame?.invoke()?.chromeAlpha ?: 1f } + val chrome = Modifier.graphicsLayer { alpha = (frame?.invoke()?.chromeAlpha ?: 1f) * chromeShown } ViewerButton( icon = Icons.Filled.Close, description = stringResource(R.string.description_chatMediaClose), enabled = interactive, + visible = chromeVisible, tag = VIEWER_CLOSE_TAG, onClick = onDismiss, modifier = chrome.align(Alignment.TopStart), @@ -304,6 +317,7 @@ fun ChatMediaViewer( icon = Icons.Outlined.IosShare, description = stringResource(R.string.description_chatMediaShare), enabled = fullImageLoaded && interactive, + visible = chromeVisible, tag = VIEWER_SHARE_TAG, onClick = onShare, modifier = chrome.align(Alignment.TopEnd), @@ -323,13 +337,15 @@ private fun ViewerButton( icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, enabled: Boolean, + visible: Boolean, tag: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { IconButton( onClick = onClick, - enabled = enabled, + // Hidden buttons fade out at full tint but stop taking taps straight away. + enabled = enabled && visible, modifier = modifier .statusBarsPadding() .padding(CodeTheme.dimens.staticGrid.x2) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt index e28e61bcb..947f3de1e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified import kotlin.math.max /** The zoom arithmetic of the viewer, free of Compose state so it can be tested on the JVM. */ @@ -87,6 +88,8 @@ class ChatMediaZoomState { /** One step of a pinch or pan: [zoom] is the factor since the last event, [pan] its movement. */ internal fun transform(focus: Offset, pan: Offset, zoom: Float) { + // The event that lifts the last finger has no centroid; its NaN would poison the offset. + if (!focus.isSpecified) return val newScale = (scale * zoom).coerceIn(ChatMediaZoom.MIN_SCALE, ChatMediaZoom.MAX_SCALE) val moved = ChatMediaZoom.offsetAfterZoom(offset, focus, scale, newScale, pan, container) scale = newScale diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt index bb0f7af53..ffdd2c2fd 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt @@ -49,4 +49,21 @@ class ChatMediaZoomTest { state.transform(Offset(200f, 400f), Offset.Zero, 0.01f) assertEquals(1f, state.scale) } + + @Test + fun transformIgnoresTheLiftEventsMissingCentroid() { + // The event that lifts the last finger has no pointer still down, so calculateCentroid() + // returns Offset.Unspecified (NaN). Applying it made the offset NaN and the photo vanished. + val state = ChatMediaZoomState().apply { + container = this@ChatMediaZoomTest.container + content = Size(400f, 800f) + } + state.transform(Offset(300f, 500f), Offset.Zero, 2f) + val zoomed = state.offset + + state.transform(Offset.Unspecified, Offset.Zero, 1f) + + assertEquals(2f, state.scale) + assertEquals(zoomed, state.offset) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index 01ca1007c..ef340b7d8 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.click import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createComposeRule @@ -327,5 +328,26 @@ class ChatPhotoMediaUiTest { assertFalse(zoom.isZoomed) } + @Test + fun singleTapTogglesTheButtons() { + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = true, onFullImageLoaded = {}, + onShare = {}, onDismiss = {}, reduceMotion = true, + ) + } + } + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() + + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() + } + // endregion }