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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay
import com.flipcash.shared.chat.ui.media.ChatPhotoSources
import com.flipcash.shared.chat.ui.media.chatPhotoTranscript
import com.flipcash.shared.chat.ui.media.LocalChatMediaProgress
import com.flipcash.shared.chat.ui.media.LocalChatPhotoSources
import androidx.compose.runtime.LaunchedEffect
Expand Down Expand Up @@ -412,7 +413,14 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
.fillMaxSize()
.testTag("chat_message_list")
.softTopEdge(ChatTopEdge.blurHold(barHeight))
.hazeSource(hazeState),
.hazeSource(hazeState)
// Inside the edge blur and the haze, so a photo landing under the bars
// passes behind them the way its bubble does.
.chatPhotoTranscript(
sources = photoSources,
top = barHeight,
bottom = overlapPadding.calculateBottomPadding(),
),
state = state,
contentPadding = overlapPadding,
messages = messages,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ fun ChatQuotePanel(
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
/** A solid fill under the tint, for a panel that sits on a photo rather than a filled bubble. */
ground: Color? = null,
/** Overrides the panel's own corners, for a panel nested in something other than a text bubble. */
shape: Shape? = null,
) {
val accent = quote.accent ?: CodeTheme.colors.tertiary
val name = quote.nameAccent ?: accent
Expand All @@ -61,7 +65,8 @@ fun ChatQuotePanel(
// Intrinsic minimum, so the rule can fill a height the text column decides. Without it a
// fillMaxHeight child in an unbounded Row measures to zero.
modifier = modifier
.clip(QuotePanelDefaults.shape)
.clip(shape ?: QuotePanelDefaults.shape)
.addIf(ground != null) { Modifier.background(ground!!) }
// The author's own colour at low alpha rather than a neutral scrim: the panel sits on a
// filled bubble, and tinting it to match the rule is what separates the two surfaces.
.background(accent.copy(alpha = QuotePanelDefaults.groundAlpha))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ internal fun Bubble(
drawRect(Color.White, alpha = ATTENTION_SCRIM_ALPHA * strength)
}
}
.addIf(onClick != null || onLongClick != null) {
.addIf(onClick != null || onLongClick != null || onDoubleClick != null) {
// combinedClickable rather than two modifiers: a bubble that takes the tap takes
// the long press with it, so both gestures are reported from the same target.
// A double tap here waits out the timeout before a single tap acts: that delay is
Expand All @@ -972,12 +972,25 @@ internal fun Bubble(

@Composable
fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerShape {
val cornerSpec = spring<Dp>(dampingRatio = 0.68f, stiffness = 500f)

val targets = bubbleCorners(position, isFromSelf)

val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS")
val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE")
val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE")
val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS")

return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
}

/** The corners [bubbleShape] settles on for a bubble at [position]. */
@Composable
internal fun bubbleCorners(position: BubblePosition, isFromSelf: Boolean): BubbleCorners {
val l = BubbleDefaults.cornerLarge
val s = BubbleDefaults.cornerSmall

val cornerSpec = spring<Dp>(dampingRatio = 0.68f, stiffness = 500f)

val targets = when (position) {
return when (position) {
BubblePosition.Solo -> BubbleCorners(l, l, l, l)
BubblePosition.First -> if (isFromSelf) BubbleCorners(l, l, s, l) else BubbleCorners(
l,
Expand All @@ -1000,16 +1013,9 @@ fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerSha
l
)
}

val topStart by animateDpAsState(targets.topStart, cornerSpec, label = "cTS")
val topEnd by animateDpAsState(targets.topEnd, cornerSpec, label = "cTE")
val bottomEnd by animateDpAsState(targets.bottomEnd, cornerSpec, label = "cBE")
val bottomStart by animateDpAsState(targets.bottomStart, cornerSpec, label = "cBS")

return RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
}

private data class BubbleCorners(
internal data class BubbleCorners(
val topStart: Dp,
val topEnd: Dp,
val bottomEnd: Dp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.layout
Expand Down Expand Up @@ -93,6 +96,7 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f
* @param frame the photo's rect, corners and the opacities, read at layout and draw time so a
* host can animate them without recomposing. Null fills the container.
* @param interactive false while the host is closing the viewer: touches and buttons do nothing.
* @param onPainter receives the painter the photo is drawn with, for a host that draws a copy of it.
*/
@Composable
fun ChatMediaViewer(
Expand All @@ -109,6 +113,7 @@ fun ChatMediaViewer(
onPull: ((Offset) -> Unit)? = null,
onPullEnd: ((Offset) -> Unit)? = null,
interactive: Boolean = true,
onPainter: ((Painter) -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
val dragY = remember { Animatable(0f) }
Expand All @@ -128,6 +133,7 @@ fun ChatMediaViewer(
}
},
)
if (onPainter != null) SideEffect { onPainter(painter) }

Box(
modifier = modifier
Expand Down Expand Up @@ -225,6 +231,16 @@ fun ChatMediaViewer(
) {
Box(
modifier = Modifier
.drawWithContent {
// In the container's coordinates, before the photo is placed in it: keeps a
// photo landing on a bubble under the chat's bars behind them, as the bubble is.
val clip = frame?.invoke()?.clip
if (clip == null) {
drawContent()
} else {
clipRect(clip.left, clip.top, clip.right, clip.bottom) { this@drawWithContent.drawContent() }
}
}
.layout { measurable, constraints ->
// The photo's own frame inside the container, which a host animates; the
// whole container when it does not.
Expand All @@ -245,9 +261,11 @@ fun ChatMediaViewer(
translationY = zoomState.offset.y + if (frame == null) dragY.value else 0f
if (frame != null) {
val f = frame()
alpha = f.contentAlpha
shape = RoundedCornerShape(f.radiusPx)
clip = f.radiusPx > 0f
// The transcript draws the rest, under the chat's bars.
alpha = f.contentAlpha * (1f - f.handoff)
val c = f.corners
shape = RoundedCornerShape(c.topStart, c.topEnd, c.bottomEnd, c.bottomStart)
clip = !c.isSquare
}
},
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
Expand Down Expand Up @@ -48,7 +49,8 @@ private const val STIFFNESS = 439f
internal class ChatPhotoPresentation(
private val scope: CoroutineScope,
private val lookup: () -> Rect?,
private val sourceRadiusPx: Float,
private val sourceCorners: ChatPhotoCorners,
private val transcript: () -> Rect?,
private val content: () -> Size,
private val density: Float,
private val reduceMotion: Boolean,
Expand Down Expand Up @@ -95,7 +97,15 @@ internal class ChatPhotoPresentation(
t = ChatPhotoTransition.dragProgress(offset, size.height)
anchor = ChatPhotoTransition.draggedRect(open, offset, t)
}
return ChatPhotoTransition.frame(source, sourceRadiusPx, anchor, t, progress.value)
return ChatPhotoTransition.frame(
source = source,
sourceCorners = sourceCorners,
anchor = anchor,
anchorPull = t,
progress = progress.value,
transcript = transcript()?.translate(-origin),
container = size,
)
}

suspend fun open() {
Expand Down Expand Up @@ -168,7 +178,8 @@ fun ChatPhotoOverlay(
ChatPhotoPresentation(
scope = scope,
lookup = { source.boundsInRoot() },
sourceRadiusPx = source.radiusPx,
sourceCorners = source.corners,
transcript = { sources.transcript },
content = {
source.imageSize
?: zoomState.content.takeIf { it.width > 0f && it.height > 0f }
Expand All @@ -186,6 +197,12 @@ fun ChatPhotoOverlay(
sources.hiddenId = messageId
onDispose { if (sources.hiddenId == messageId) sources.hiddenId = null }
}
var painter by remember(messageId) { mutableStateOf<Painter?>(null) }
DisposableEffect(sources, presentation, painter) {
val landing = painter?.let { ChatPhotoLanding(it, presentation::frame) { presentation.origin } }
sources.landing = landing
onDispose { if (sources.landing === landing) sources.landing = null }
}
LaunchedEffect(presentation) { presentation.open() }
BackHandler { presentation.dismiss() }

Expand Down Expand Up @@ -217,6 +234,7 @@ fun ChatPhotoOverlay(
onPull = presentation::onPull,
onPullEnd = presentation::onPullEnd,
interactive = !presentation.closing,
onPainter = { painter = it },
)
}
}
Loading
Loading