mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-01 16:07:17 +01:00
Fix deactivated node crash in call screen layout.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
@@ -5,16 +5,21 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -25,32 +30,27 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
||||
@@ -110,9 +110,6 @@ data class GridCell(
|
||||
val height: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Internal helper for grid layout parameters
|
||||
*/
|
||||
private data class GridLayoutParams(
|
||||
val rows: Int,
|
||||
val cols: Int,
|
||||
@@ -226,9 +223,6 @@ sealed class CallGridStrategy(val maxTiles: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the appropriate CallGridStrategy based on current window size
|
||||
*/
|
||||
private const val WIDTH_DP_LARGE_LOWER_BOUND = 1200
|
||||
|
||||
@Composable
|
||||
@@ -365,7 +359,6 @@ private fun calculateGridCells(
|
||||
val actualItemsInRow = min(itemsInThisRow, remainingItems)
|
||||
val isPartialRow = actualItemsInRow < config.columns
|
||||
|
||||
// Stretch items in partial rows to fill width (compact mode only)
|
||||
val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) {
|
||||
(totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow
|
||||
} else {
|
||||
@@ -422,7 +415,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
val gridStartX = padding + (availableWidth - totalGridWidth) / 2
|
||||
val gridStartY = padding + (availableHeight - totalGridHeight) / 2
|
||||
|
||||
// Place regular items in column-major order (fills columns top-to-bottom, left-to-right)
|
||||
var index = 0
|
||||
for (col in 0 until columnsForRegularItems) {
|
||||
for (row in 0 until config.rows) {
|
||||
@@ -444,7 +436,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
}
|
||||
|
||||
// Spanning item takes full height
|
||||
val spanningX = gridStartX + columnsForRegularItems * (cellWidth + spacing)
|
||||
val spanningY = gridStartY
|
||||
val spanningHeight = totalGridHeight
|
||||
@@ -463,14 +454,9 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
|
||||
/**
|
||||
* State for an item that is exiting the grid with animation
|
||||
* Holds an item being tracked by [CallGrid], along with whether it should animate in on entry.
|
||||
*/
|
||||
private data class ExitingItem<T>(
|
||||
val item: T,
|
||||
val key: Any,
|
||||
val lastPosition: IntOffset,
|
||||
val lastSize: IntSize
|
||||
)
|
||||
private data class ManagedItem<T>(val item: T, val animateEnter: Boolean)
|
||||
|
||||
/**
|
||||
* An animated grid layout for call participants.
|
||||
@@ -479,7 +465,6 @@ private data class ExitingItem<T>(
|
||||
* - Smooth position animations when items move
|
||||
* - Fade-in/scale-in animation for new items (0% to 100% opacity, 90% to 100% scale)
|
||||
* - Fade-out/scale-out animation for removed items (100% to 0% opacity, 100% to 90% scale)
|
||||
* - Crossfade for swapped items (same position, different participant)
|
||||
* - Device-aware grid configurations
|
||||
*
|
||||
* @param items List of items to display, each with a stable key
|
||||
@@ -499,20 +484,9 @@ fun <T> CallGrid(
|
||||
content: @Composable (item: T, modifier: Modifier) -> Unit
|
||||
) {
|
||||
val strategy = rememberCallGridStrategy()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
||||
val sizeAnimatables: SnapshotStateMap<Any, Animatable<IntSize, *>> = remember { mutableStateMapOf() }
|
||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val scaleAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val knownKeys = remember { mutableSetOf<Any>() }
|
||||
var exitingItems: List<ExitingItem<T>> by remember { mutableStateOf(emptyList()) }
|
||||
val previousItems = remember { mutableStateMapOf<Any, T>() }
|
||||
|
||||
val displayCount = min(items.size, strategy.maxTiles)
|
||||
val displayItems = items.take(displayCount)
|
||||
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
|
||||
|
||||
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
|
||||
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
|
||||
} else {
|
||||
@@ -525,226 +499,95 @@ fun <T> CallGrid(
|
||||
label = "cornerRadius"
|
||||
)
|
||||
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
val cells = remember(config, containerSize, displayCount) {
|
||||
if (containerSize == IntSize.Zero) emptyList()
|
||||
else calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerSize.width.toFloat(),
|
||||
containerHeight = containerSize.height.toFloat(),
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
|
||||
// Holds all items currently in the grid, including those still animating out.
|
||||
val managedItems: SnapshotStateMap<Any, ManagedItem<T>> = remember { mutableStateMapOf() }
|
||||
|
||||
// lastKnownCells freezes the last grid position for items that are animating out so they
|
||||
// stay in place (rather than jumping to zero) while their exit animation plays.
|
||||
val lastKnownCells = remember { mutableMapOf<Any, GridCell>() }
|
||||
|
||||
val currentKeys = displayItems.map { itemKey(it) }.toSet()
|
||||
val newKeys = currentKeys - knownKeys
|
||||
val hasExistingItems = knownKeys.isNotEmpty()
|
||||
val hasExistingItems = managedItems.isNotEmpty()
|
||||
|
||||
newKeys.forEach { key ->
|
||||
if (exitingItems.any { it.key == key }) {
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
}
|
||||
if (hasExistingItems) {
|
||||
alphaAnimatables[key] = Animatable(0f)
|
||||
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
|
||||
}
|
||||
knownKeys.add(key)
|
||||
}
|
||||
|
||||
displayItems.forEach { item ->
|
||||
previousItems[itemKey(item)] = item
|
||||
}
|
||||
|
||||
fun removeAnimationState(key: Any) {
|
||||
positionAnimatables.remove(key)
|
||||
sizeAnimatables.remove(key)
|
||||
alphaAnimatables.remove(key)
|
||||
scaleAnimatables.remove(key)
|
||||
previousItems.remove(key)
|
||||
}
|
||||
|
||||
val removedKeys = knownKeys - currentKeys
|
||||
removedKeys.forEach { key ->
|
||||
val exitingItem = previousItems[key]
|
||||
val position = positionAnimatables[key]?.value
|
||||
val size = sizeAnimatables[key]?.value
|
||||
|
||||
if (exitingItem != null && position != null && size != null) {
|
||||
exitingItems = exitingItems + ExitingItem(
|
||||
item = exitingItem,
|
||||
key = key,
|
||||
lastPosition = position,
|
||||
lastSize = size
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[key]?.animateTo(0f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
if (key !in knownKeys) {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
SideEffect {
|
||||
displayItems.forEach { item ->
|
||||
val key = itemKey(item)
|
||||
if (key !in managedItems) {
|
||||
managedItems[key] = ManagedItem(item, animateEnter = hasExistingItems)
|
||||
} else {
|
||||
managedItems[key] = managedItems[key]!!.copy(item = item)
|
||||
}
|
||||
} else {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
knownKeys.remove(key)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val containerWidthPx = constraints.maxWidth.toFloat()
|
||||
val containerHeightPx = constraints.maxHeight.toFloat()
|
||||
Box(modifier = modifier.onSizeChanged { containerSize = it }) {
|
||||
managedItems.entries.toList().forEach { (key, managed) ->
|
||||
val index = displayItems.indexOfFirst { itemKey(it) == key }
|
||||
val targetCell = cells.getOrNull(index)
|
||||
if (targetCell != null) lastKnownCells[key] = targetCell
|
||||
val effectiveCell = targetCell ?: lastKnownCells[key] ?: return@forEach
|
||||
|
||||
val cells = remember(config, containerWidthPx, containerHeightPx, displayCount) {
|
||||
calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerWidthPx,
|
||||
containerHeight = containerHeightPx,
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
key(key) {
|
||||
var isVisible by remember { mutableStateOf(!managed.animateEnter) }
|
||||
LaunchedEffect(Unit) { isVisible = true }
|
||||
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(
|
||||
visible = isVisible && key in currentKeys,
|
||||
enter = scaleIn(
|
||||
initialScale = CallGridDefaults.ENTER_SCALE_START,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeIn(animationSpec = CallGridDefaults.alphaAnimationSpec),
|
||||
exit = scaleOut(
|
||||
targetScale = CallGridDefaults.EXIT_SCALE_END,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeOut(animationSpec = CallGridDefaults.alphaAnimationSpec)
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { managedItems.remove(key) }
|
||||
}
|
||||
|
||||
val enteringKeys = newKeys.filter { key ->
|
||||
val alpha = alphaAnimatables[key]?.value ?: 1f
|
||||
alpha < 1f
|
||||
}.toSet()
|
||||
val targetPosition = IntOffset(effectiveCell.x.roundToInt(), effectiveCell.y.roundToInt())
|
||||
val targetSize = IntSize(effectiveCell.width.roundToInt(), effectiveCell.height.roundToInt())
|
||||
|
||||
// Internal to capture closure variables: alphaAnimatables, scaleAnimatables, density, animatedCornerRadius, content
|
||||
@Composable
|
||||
fun RenderItem(item: T, itemKeyValue: Any, widthPx: Int, heightPx: Int) {
|
||||
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val itemScale = scaleAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val positionAnim = remember { Animatable(targetPosition, IntOffset.VectorConverter) }
|
||||
val sizeAnim = remember { Animatable(targetSize, IntSize.VectorConverter) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(itemKeyValue)
|
||||
.alpha(alpha)
|
||||
.scale(itemScale)
|
||||
) {
|
||||
content(
|
||||
item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { widthPx.toDp() },
|
||||
height = with(density) { heightPx.toDp() }
|
||||
// LaunchedEffect is tied to this composable's lifecycle and cancels automatically
|
||||
// when the item leaves composition, preventing any deactivated-node interaction.
|
||||
LaunchedEffect(targetPosition) {
|
||||
positionAnim.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
LaunchedEffect(targetSize) {
|
||||
sizeAnim.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.absoluteOffset { positionAnim.value }) {
|
||||
content(
|
||||
managed.item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { sizeAnim.value.width.toDp() },
|
||||
height = with(density) { sizeAnim.value.height.toDp() }
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-filter items by entering status, preserving indices for cell lookup
|
||||
val (enteringIndexedItems, nonEnteringIndexedItems) = displayItems
|
||||
.withIndex()
|
||||
.partition { (_, item) -> itemKey(item) in enteringKeys }
|
||||
|
||||
@Composable
|
||||
fun RenderDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (index, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
key(itemKeyValue) {
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val cell = cells.getOrNull(index)
|
||||
if (cell != null) {
|
||||
val widthPx = animatedSize?.width ?: cell.width.roundToInt()
|
||||
val heightPx = animatedSize?.height ?: cell.height.roundToInt()
|
||||
RenderItem(item, itemKeyValue, widthPx, heightPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
exitingItems.forEach { exitingItem ->
|
||||
key(exitingItem.key) {
|
||||
RenderItem(exitingItem.item, exitingItem.key, exitingItem.lastSize.width, exitingItem.lastSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
RenderDisplayItems(enteringIndexedItems)
|
||||
RenderDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
displayItems.forEachIndexed { index, item ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val cell = cells.getOrNull(index) ?: return@forEachIndexed
|
||||
val targetPosition = IntOffset(cell.x.roundToInt(), cell.y.roundToInt())
|
||||
val targetSize = IntSize(cell.width.roundToInt(), cell.height.roundToInt())
|
||||
|
||||
val existingPosition = positionAnimatables[itemKeyValue]
|
||||
if (existingPosition == null) {
|
||||
positionAnimatables[itemKeyValue] = Animatable(targetPosition, IntOffset.VectorConverter)
|
||||
if (hasExistingItems && itemKeyValue in newKeys) {
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[itemKeyValue]?.animateTo(CallGridDefaults.ENTER_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (alphaAnimatables[itemKeyValue] == null) {
|
||||
alphaAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
if (scaleAnimatables[itemKeyValue] == null) {
|
||||
scaleAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
}
|
||||
} else if (existingPosition.targetValue != targetPosition) {
|
||||
scope.launch {
|
||||
existingPosition.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val existingSize = sizeAnimatables[itemKeyValue]
|
||||
if (existingSize == null) {
|
||||
sizeAnimatables[itemKeyValue] = Animatable(targetSize, IntSize.VectorConverter)
|
||||
} else if (existingSize.targetValue != targetSize) {
|
||||
scope.launch {
|
||||
existingSize.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val placeables = measurables.map { measurable ->
|
||||
val itemKeyValue = measurable.layoutId
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val exitingItem = exitingItems.find { it.key == itemKeyValue }
|
||||
|
||||
when {
|
||||
animatedSize != null -> {
|
||||
measurable.measure(Constraints.fixed(animatedSize.width, animatedSize.height))
|
||||
}
|
||||
exitingItem != null -> {
|
||||
measurable.measure(Constraints.fixed(exitingItem.lastSize.width, exitingItem.lastSize.height))
|
||||
}
|
||||
else -> {
|
||||
measurable.measure(Constraints())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) ->
|
||||
measurable.layoutId to placeable
|
||||
}
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
fun placeDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (_, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val placeable = keyToPlaceable[itemKeyValue]
|
||||
val position = positionAnimatables[itemKeyValue]?.value
|
||||
if (placeable != null && position != null) {
|
||||
placeable.place(position.x, position.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitingItems.forEach { exitingItem ->
|
||||
val placeable = keyToPlaceable[exitingItem.key]
|
||||
placeable?.place(exitingItem.lastPosition.x, exitingItem.lastPosition.y)
|
||||
}
|
||||
|
||||
placeDisplayItems(enteringIndexedItems)
|
||||
placeDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,10 +618,7 @@ private fun CallGridPreview() {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
|
||||
val strategy = rememberCallGridStrategy()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val widthDp = maxWidth
|
||||
val heightDp = maxHeight
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CallGrid(
|
||||
items = items,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -796,8 +636,7 @@ private fun CallGridPreview() {
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${widthDp.value.toInt()} x ${heightDp.value.toInt()} dp\n" +
|
||||
"WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
text = "WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
"Strategy: ${strategy::class.simpleName}",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
@@ -13,11 +13,11 @@ 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.LaunchedEffect
|
||||
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
|
||||
@@ -28,7 +28,7 @@ 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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -108,40 +108,59 @@ fun AnimatedFlowRow(
|
||||
positionAnimationSpec: FiniteAnimationSpec<IntOffset> = AnimatedFlowRowDefaults.positionAnimationSpec,
|
||||
content: AnimatedFlowRowScope.() -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
||||
// Plain map (not snapshot state) so initialization inside the Layout measurement lambda is safe.
|
||||
val positionAnimatables: MutableMap<Any, Animatable<IntOffset, *>> = remember { mutableMapOf() }
|
||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val knownKeys = remember { mutableSetOf<Any>() }
|
||||
val firstSeenInLayout = remember { mutableSetOf<Any>() }
|
||||
|
||||
// MutableStateFlow (not snapshot state) bridges Layout measurement to composition-phase
|
||||
// animation launches, so position writes inside Layout measurement are safe.
|
||||
val pendingPositions = remember { MutableStateFlow<Map<Any, IntOffset>>(emptyMap()) }
|
||||
|
||||
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)
|
||||
}
|
||||
alphaAnimatables[key] = if (hasExistingItems) Animatable(0f) else Animatable(1f)
|
||||
knownKeys.add(key)
|
||||
}
|
||||
|
||||
// Clean up animatables for removed items
|
||||
val removedKeys = knownKeys - currentKeysSet
|
||||
removedKeys.forEach { key ->
|
||||
positionAnimatables.remove(key)
|
||||
alphaAnimatables.remove(key)
|
||||
firstSeenInLayout.remove(key)
|
||||
knownKeys.remove(key)
|
||||
}
|
||||
|
||||
// Animation launches live here, not inside the Layout measurement lambda, so the Layout
|
||||
// remains free of side effects that can interact with Compose's node deactivation.
|
||||
LaunchedEffect(Unit) {
|
||||
pendingPositions.collect { positions ->
|
||||
positions.forEach { (key, targetPos) ->
|
||||
val posAnim = positionAnimatables[key] ?: return@forEach
|
||||
val isFirstSeen = firstSeenInLayout.add(key)
|
||||
if (isFirstSeen) {
|
||||
if (alphaAnimatables[key]?.value == 0f) {
|
||||
launch {
|
||||
kotlinx.coroutines.delay(AnimatedFlowRowDefaults.ANIMATION_DURATION_MS)
|
||||
alphaAnimatables[key]?.animateTo(1f, AnimatedFlowRowDefaults.alphaAnimationSpec)
|
||||
}
|
||||
}
|
||||
} else if (posAnim.targetValue != targetPos) {
|
||||
launch { posAnim.animateTo(targetPos, positionAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val layoutModifier = if (sizeAnimationSpec != null) {
|
||||
modifier.animateContentSize(animationSpec = sizeAnimationSpec)
|
||||
} else {
|
||||
@@ -170,35 +189,17 @@ fun AnimatedFlowRow(
|
||||
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
|
||||
// Plain map mutation is safe inside Layout measurement; snapshot state mutation is not.
|
||||
positions.forEach { (key, targetPosition) ->
|
||||
val existingPosition = positionAnimatables[key]
|
||||
if (existingPosition == null) {
|
||||
// New item - start at target position
|
||||
if (positionAnimatables[key] == null) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingPositions.value = positions.toMap()
|
||||
|
||||
layout(constraints.maxWidth, totalHeight) {
|
||||
positions.forEach { (key, _) ->
|
||||
val placeable = keyToPlaceable[key]
|
||||
@@ -227,7 +228,6 @@ private fun calculateFlowRowPositions(
|
||||
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()) {
|
||||
@@ -242,10 +242,8 @@ private fun calculateFlowRowPositions(
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user