Add nicer call quality animation.

This commit is contained in:
Alex Hart
2025-12-10 12:24:56 -04:00
committed by Michelle Tang
parent 86aa866626
commit 224503f6bb
2 changed files with 351 additions and 101 deletions

View File

@@ -9,7 +9,6 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
@@ -23,7 +22,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -65,6 +63,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.AnimatedFlowRow
import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.IconButtons import org.signal.core.ui.compose.IconButtons
@@ -221,85 +221,103 @@ private fun WhatIssuesDidYouHave(
val isAudioExpanded = CallQualityIssue.AUDIO_ISSUE in selectedQualityIssues val isAudioExpanded = CallQualityIssue.AUDIO_ISSUE in selectedQualityIssues
val isVideoExpanded = CallQualityIssue.VIDEO_ISSUE in selectedQualityIssues val isVideoExpanded = CallQualityIssue.VIDEO_ISSUE in selectedQualityIssues
FlowRow( AnimatedFlowRow(
modifier = Modifier modifier = Modifier
.animateContentSize()
.fillMaxWidth() .fillMaxWidth()
.horizontalGutters(), .padding(top = 24.dp)
horizontalArrangement = Arrangement.Center .horizontalGutters()
) { ) {
IssueChip( item(CallQualityIssue.AUDIO_ISSUE) {
issue = CallQualityIssue.AUDIO_ISSUE, IssueChip(
isSelected = isAudioExpanded, issue = CallQualityIssue.AUDIO_ISSUE,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_ISSUE) } isSelected = isAudioExpanded,
) onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_ISSUE) }
)
}
AnimatedIssueChip( if (isAudioExpanded) {
visible = isAudioExpanded, item(CallQualityIssue.AUDIO_STUTTERING) {
issue = CallQualityIssue.AUDIO_STUTTERING, IssueChip(
isSelected = CallQualityIssue.AUDIO_STUTTERING in selectedQualityIssues, issue = CallQualityIssue.AUDIO_STUTTERING,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_STUTTERING) } isSelected = CallQualityIssue.AUDIO_STUTTERING in selectedQualityIssues,
) onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_STUTTERING) }
)
}
AnimatedIssueChip( item(CallQualityIssue.AUDIO_CUT_OUT) {
visible = isAudioExpanded, IssueChip(
issue = CallQualityIssue.AUDIO_CUT_OUT, issue = CallQualityIssue.AUDIO_CUT_OUT,
isSelected = CallQualityIssue.AUDIO_CUT_OUT in selectedQualityIssues, isSelected = CallQualityIssue.AUDIO_CUT_OUT in selectedQualityIssues,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_CUT_OUT) } onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_CUT_OUT) }
) )
}
AnimatedIssueChip( item(CallQualityIssue.AUDIO_I_HEARD_ECHO) {
visible = isAudioExpanded, IssueChip(
issue = CallQualityIssue.AUDIO_I_HEARD_ECHO, issue = CallQualityIssue.AUDIO_I_HEARD_ECHO,
isSelected = CallQualityIssue.AUDIO_I_HEARD_ECHO in selectedQualityIssues, isSelected = CallQualityIssue.AUDIO_I_HEARD_ECHO in selectedQualityIssues,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_I_HEARD_ECHO) } onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_I_HEARD_ECHO) }
) )
}
AnimatedIssueChip( item(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) {
visible = isAudioExpanded, IssueChip(
issue = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO, issue = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO,
isSelected = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO in selectedQualityIssues, isSelected = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO in selectedQualityIssues,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) } onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) }
) )
}
}
IssueChip( item(CallQualityIssue.VIDEO_ISSUE) {
issue = CallQualityIssue.VIDEO_ISSUE, IssueChip(
isSelected = isVideoExpanded, issue = CallQualityIssue.VIDEO_ISSUE,
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_ISSUE) } isSelected = isVideoExpanded,
) onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_ISSUE) }
)
}
AnimatedIssueChip( if (isVideoExpanded) {
visible = isVideoExpanded, item(CallQualityIssue.VIDEO_POOR_QUALITY) {
issue = CallQualityIssue.VIDEO_POOR_QUALITY, IssueChip(
isSelected = CallQualityIssue.VIDEO_POOR_QUALITY in selectedQualityIssues, issue = CallQualityIssue.VIDEO_POOR_QUALITY,
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_POOR_QUALITY) } isSelected = CallQualityIssue.VIDEO_POOR_QUALITY in selectedQualityIssues,
) onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_POOR_QUALITY) }
)
}
AnimatedIssueChip( item(CallQualityIssue.VIDEO_LOW_RESOLUTION) {
visible = isVideoExpanded, IssueChip(
issue = CallQualityIssue.VIDEO_LOW_RESOLUTION, issue = CallQualityIssue.VIDEO_LOW_RESOLUTION,
isSelected = CallQualityIssue.VIDEO_LOW_RESOLUTION in selectedQualityIssues, isSelected = CallQualityIssue.VIDEO_LOW_RESOLUTION in selectedQualityIssues,
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_LOW_RESOLUTION) } onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_LOW_RESOLUTION) }
) )
}
AnimatedIssueChip( item(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) {
visible = isVideoExpanded, IssueChip(
issue = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION, issue = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION,
isSelected = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION in selectedQualityIssues, isSelected = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION in selectedQualityIssues,
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) } onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) }
) )
}
}
IssueChip( item(CallQualityIssue.CALL_DROPPED) {
issue = CallQualityIssue.CALL_DROPPED, IssueChip(
isSelected = CallQualityIssue.CALL_DROPPED in selectedQualityIssues, issue = CallQualityIssue.CALL_DROPPED,
onClick = { onCallQualityIssueClick(CallQualityIssue.CALL_DROPPED) } isSelected = CallQualityIssue.CALL_DROPPED in selectedQualityIssues,
) onClick = { onCallQualityIssueClick(CallQualityIssue.CALL_DROPPED) }
)
}
IssueChip( item(CallQualityIssue.SOMETHING_ELSE) {
issue = CallQualityIssue.SOMETHING_ELSE, IssueChip(
isSelected = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues, issue = CallQualityIssue.SOMETHING_ELSE,
onClick = { onCallQualityIssueClick(CallQualityIssue.SOMETHING_ELSE) } isSelected = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues,
) onClick = { onCallQualityIssueClick(CallQualityIssue.SOMETHING_ELSE) }
)
}
} }
AnimatedVisibility( AnimatedVisibility(
@@ -404,41 +422,10 @@ private fun IssueChip(
label = { label = {
Text(text = stringResource(issue.label)) Text(text = stringResource(issue.label))
}, },
modifier = modifier.padding(horizontal = 4.dp) modifier = modifier.padding(horizontal = 8.dp)
) )
} }
@Composable
private fun AnimatedIssueChip(
visible: Boolean,
issue: CallQualityIssue,
isSelected: Boolean,
onClick: () -> Unit
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(
animationSpec = tween(
durationMillis = 300,
delayMillis = 300,
easing = CubicBezierEasing(0f, 0f, 0.58f, 1f)
)
),
exit = fadeOut(
animationSpec = tween(
durationMillis = 300,
easing = CubicBezierEasing(0f, 0f, 0.58f, 1f)
)
)
) {
IssueChip(
issue = issue,
isSelected = isSelected,
onClick = onClick
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HelpUsImprove( private fun HelpUsImprove(
@@ -639,7 +626,7 @@ fun CancelButton(
} }
} }
@PreviewLightDark @AllDevicePreviews
@Composable @Composable
private fun CallQualityScreenPreview() { private fun CallQualityScreenPreview() {
var state by remember { mutableStateOf(CallQualitySheetState()) } var state by remember { mutableStateOf(CallQualitySheetState()) }

View File

@@ -0,0 +1,263 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Default values for [AnimatedFlowRow].
*/
object AnimatedFlowRowDefaults {
internal const val ANIMATION_DURATION_MS = 300L
private val DefaultEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f)
/**
* Default animation spec for position animations.
*/
@Stable
val positionAnimationSpec: FiniteAnimationSpec<IntOffset> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = DefaultEasing
)
/**
* Default animation spec for size (height) animations.
*/
@Stable
val sizeAnimationSpec: FiniteAnimationSpec<IntSize> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = DefaultEasing
)
internal val alphaAnimationSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = ANIMATION_DURATION_MS.toInt(),
easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
)
}
/**
* Scope for [AnimatedFlowRow] content that provides [item] for adding keyed items.
*/
class AnimatedFlowRowScope {
internal val items = mutableListOf<Pair<Any, @Composable () -> Unit>>()
/**
* Adds an item to the flow row with a stable key for animation tracking.
*
* @param key A stable, unique key for this item. Items with the same key will
* animate smoothly when their position changes.
* @param content The composable content for this item.
*/
fun item(key: Any, content: @Composable () -> Unit) {
items.add(key to content)
}
}
/**
* A FlowRow that animates item position changes smoothly.
* Items animate from their previous position to their new position when the layout changes.
* New items fade in after existing items have finished animating to their new positions.
*
* Use the [AnimatedFlowRowScope.item] function to add items with stable keys:
* ```
* AnimatedFlowRow {
* item(key = "audio") { AudioChip() }
* item(key = "video") { VideoChip() }
* }
* ```
*
* @param modifier The modifier to apply to this layout.
* @param sizeAnimationSpec Animation spec for container size changes, or null to disable size animation.
* Defaults to [AnimatedFlowRowDefaults.sizeAnimationSpec].
* @param positionAnimationSpec The animation spec to use for item position animations.
* Defaults to [AnimatedFlowRowDefaults.positionAnimationSpec].
* @param content The content builder using [AnimatedFlowRowScope].
*/
@Composable
fun AnimatedFlowRow(
modifier: Modifier = Modifier,
sizeAnimationSpec: FiniteAnimationSpec<IntSize>? = AnimatedFlowRowDefaults.sizeAnimationSpec,
positionAnimationSpec: FiniteAnimationSpec<IntOffset> = AnimatedFlowRowDefaults.positionAnimationSpec,
content: AnimatedFlowRowScope.() -> Unit
) {
val scope = rememberCoroutineScope()
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
val knownKeys = remember { mutableSetOf<Any>() }
val flowRowScope = remember { AnimatedFlowRowScope() }
flowRowScope.items.clear()
flowRowScope.content()
// Key operations run each recomposition to track additions/removals synchronously
val itemKeys = flowRowScope.items.map { it.first }
val currentKeysSet = itemKeys.toSet()
// Determine which keys are new (not seen before) - check synchronously
val newKeys = currentKeysSet - knownKeys
val hasExistingItems = knownKeys.isNotEmpty()
// Pre-initialize alpha for new items to 0 if there are existing items
// This prevents flicker by ensuring they start invisible BEFORE first render
newKeys.forEach { key ->
if (hasExistingItems) {
alphaAnimatables[key] = Animatable(0f)
}
knownKeys.add(key)
}
// Clean up animatables for removed items
val removedKeys = knownKeys - currentKeysSet
removedKeys.forEach { key ->
positionAnimatables.remove(key)
alphaAnimatables.remove(key)
knownKeys.remove(key)
}
val layoutModifier = if (sizeAnimationSpec != null) {
modifier.animateContentSize(animationSpec = sizeAnimationSpec)
} else {
modifier
}
Layout(
content = {
flowRowScope.items.forEach { (itemKey, itemContent) ->
key(itemKey) {
val alpha = alphaAnimatables[itemKey]?.value ?: 1f
Box(
modifier = Modifier
.layoutId(itemKey)
.alpha(alpha)
) {
itemContent()
}
}
}
},
modifier = layoutModifier
) { measurables, constraints ->
val placeables = measurables.map { it.measure(Constraints()) }
val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) ->
measurable.layoutId to placeable
}
// Calculate flow row positions (centered, wrapping)
val (totalHeight, positions) = calculateFlowRowPositions(measurables, placeables, constraints.maxWidth)
// Initialize animatables for new items and trigger animations for existing items
positions.forEach { (key, targetPosition) ->
val existingPosition = positionAnimatables[key]
if (existingPosition == null) {
// New item - start at target position
positionAnimatables[key] = Animatable(targetPosition, IntOffset.VectorConverter)
if (hasExistingItems) {
// Fade in after position animations complete
scope.launch {
delay(AnimatedFlowRowDefaults.ANIMATION_DURATION_MS)
alphaAnimatables[key]?.animateTo(1f, AnimatedFlowRowDefaults.alphaAnimationSpec)
}
} else {
// First layout, appear immediately
if (alphaAnimatables[key] == null) {
alphaAnimatables[key] = Animatable(1f)
}
}
} else if (existingPosition.targetValue != targetPosition) {
// Item is moving - animate to new position
scope.launch {
existingPosition.animateTo(targetPosition, positionAnimationSpec)
}
}
}
layout(constraints.maxWidth, totalHeight) {
positions.forEach { (key, _) ->
val placeable = keyToPlaceable[key]
val animatable = positionAnimatables[key]
if (placeable != null && animatable != null) {
placeable.place(animatable.value.x, animatable.value.y)
}
}
}
}
}
/**
* Calculates centered flow row positions for placeables.
* Returns a pair of (totalHeight, list of (key, position) pairs).
*/
private fun calculateFlowRowPositions(
measurables: List<Measurable>,
placeables: List<Placeable>,
maxWidth: Int
): Pair<Int, List<Pair<Any, IntOffset>>> {
if (placeables.isEmpty()) return 0 to emptyList()
val result = mutableListOf<Pair<Any, IntOffset>>()
val rows = mutableListOf<MutableList<Triple<Any, Measurable, Placeable>>>()
var currentRow = mutableListOf<Triple<Any, Measurable, Placeable>>()
var currentRowWidth = 0
// Group items into rows
measurables.zip(placeables).forEach { (measurable, placeable) ->
val key = measurable.layoutId ?: return@forEach
if (currentRowWidth + placeable.width > maxWidth && currentRow.isNotEmpty()) {
rows.add(currentRow)
currentRow = mutableListOf()
currentRowWidth = 0
}
currentRow.add(Triple(key, measurable, placeable))
currentRowWidth += placeable.width
}
if (currentRow.isNotEmpty()) {
rows.add(currentRow)
}
// Calculate total height first
val totalHeight = rows.sumOf { row -> row.maxOf { it.third.height } }
// Calculate positions (centered per row, from top to bottom)
var y = 0
rows.forEach { row ->
val rowWidth = row.sumOf { it.third.width }
val rowHeight = row.maxOf { it.third.height }
var x = (maxWidth - rowWidth) / 2
row.forEach { (key, _, placeable) ->
result.add(key to IntOffset(x, y))
x += placeable.width
}
y += rowHeight
}
return totalHeight to result
}