Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading