mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 00:17:41 +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
|
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.VectorConverter
|
import androidx.compose.animation.core.VectorConverter
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.tween
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.layoutId
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.window.core.layout.WindowSizeClass
|
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.AllNightPreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
||||||
@@ -110,9 +110,6 @@ data class GridCell(
|
|||||||
val height: Float
|
val height: Float
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal helper for grid layout parameters
|
|
||||||
*/
|
|
||||||
private data class GridLayoutParams(
|
private data class GridLayoutParams(
|
||||||
val rows: Int,
|
val rows: Int,
|
||||||
val cols: 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
|
private const val WIDTH_DP_LARGE_LOWER_BOUND = 1200
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -365,7 +359,6 @@ private fun calculateGridCells(
|
|||||||
val actualItemsInRow = min(itemsInThisRow, remainingItems)
|
val actualItemsInRow = min(itemsInThisRow, remainingItems)
|
||||||
val isPartialRow = actualItemsInRow < config.columns
|
val isPartialRow = actualItemsInRow < config.columns
|
||||||
|
|
||||||
// Stretch items in partial rows to fill width (compact mode only)
|
|
||||||
val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) {
|
val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) {
|
||||||
(totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow
|
(totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow
|
||||||
} else {
|
} else {
|
||||||
@@ -422,7 +415,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
|||||||
val gridStartX = padding + (availableWidth - totalGridWidth) / 2
|
val gridStartX = padding + (availableWidth - totalGridWidth) / 2
|
||||||
val gridStartY = padding + (availableHeight - totalGridHeight) / 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
|
var index = 0
|
||||||
for (col in 0 until columnsForRegularItems) {
|
for (col in 0 until columnsForRegularItems) {
|
||||||
for (row in 0 until config.rows) {
|
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 spanningX = gridStartX + columnsForRegularItems * (cellWidth + spacing)
|
||||||
val spanningY = gridStartY
|
val spanningY = gridStartY
|
||||||
val spanningHeight = totalGridHeight
|
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>(
|
private data class ManagedItem<T>(val item: T, val animateEnter: Boolean)
|
||||||
val item: T,
|
|
||||||
val key: Any,
|
|
||||||
val lastPosition: IntOffset,
|
|
||||||
val lastSize: IntSize
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An animated grid layout for call participants.
|
* An animated grid layout for call participants.
|
||||||
@@ -479,7 +465,6 @@ private data class ExitingItem<T>(
|
|||||||
* - Smooth position animations when items move
|
* - Smooth position animations when items move
|
||||||
* - Fade-in/scale-in animation for new items (0% to 100% opacity, 90% to 100% scale)
|
* - 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)
|
* - 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
|
* - Device-aware grid configurations
|
||||||
*
|
*
|
||||||
* @param items List of items to display, each with a stable key
|
* @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
|
content: @Composable (item: T, modifier: Modifier) -> Unit
|
||||||
) {
|
) {
|
||||||
val strategy = rememberCallGridStrategy()
|
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 displayCount = min(items.size, strategy.maxTiles)
|
||||||
val displayItems = items.take(displayCount)
|
val displayItems = items.take(displayCount)
|
||||||
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
|
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
|
||||||
|
|
||||||
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
|
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
|
||||||
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
|
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
|
||||||
} else {
|
} else {
|
||||||
@@ -525,226 +499,95 @@ fun <T> CallGrid(
|
|||||||
label = "cornerRadius"
|
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 currentKeys = displayItems.map { itemKey(it) }.toSet()
|
||||||
val newKeys = currentKeys - knownKeys
|
val hasExistingItems = managedItems.isNotEmpty()
|
||||||
val hasExistingItems = knownKeys.isNotEmpty()
|
|
||||||
|
|
||||||
newKeys.forEach { key ->
|
SideEffect {
|
||||||
if (exitingItems.any { it.key == key }) {
|
displayItems.forEach { item ->
|
||||||
exitingItems = exitingItems.filterNot { it.key == key }
|
val key = itemKey(item)
|
||||||
}
|
if (key !in managedItems) {
|
||||||
if (hasExistingItems) {
|
managedItems[key] = ManagedItem(item, animateEnter = hasExistingItems)
|
||||||
alphaAnimatables[key] = Animatable(0f)
|
} else {
|
||||||
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
|
managedItems[key] = managedItems[key]!!.copy(item = item)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
removeAnimationState(key)
|
|
||||||
}
|
}
|
||||||
knownKeys.remove(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(
|
Box(modifier = modifier.onSizeChanged { containerSize = it }) {
|
||||||
modifier = modifier,
|
managedItems.entries.toList().forEach { (key, managed) ->
|
||||||
contentAlignment = Alignment.Center
|
val index = displayItems.indexOfFirst { itemKey(it) == key }
|
||||||
) {
|
val targetCell = cells.getOrNull(index)
|
||||||
val containerWidthPx = constraints.maxWidth.toFloat()
|
if (targetCell != null) lastKnownCells[key] = targetCell
|
||||||
val containerHeightPx = constraints.maxHeight.toFloat()
|
val effectiveCell = targetCell ?: lastKnownCells[key] ?: return@forEach
|
||||||
|
|
||||||
val cells = remember(config, containerWidthPx, containerHeightPx, displayCount) {
|
key(key) {
|
||||||
calculateGridCells(
|
var isVisible by remember { mutableStateOf(!managed.animateEnter) }
|
||||||
config = config,
|
LaunchedEffect(Unit) { isVisible = true }
|
||||||
containerWidth = containerWidthPx,
|
|
||||||
containerHeight = containerHeightPx,
|
|
||||||
itemCount = displayCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 targetPosition = IntOffset(effectiveCell.x.roundToInt(), effectiveCell.y.roundToInt())
|
||||||
val alpha = alphaAnimatables[key]?.value ?: 1f
|
val targetSize = IntSize(effectiveCell.width.roundToInt(), effectiveCell.height.roundToInt())
|
||||||
alpha < 1f
|
|
||||||
}.toSet()
|
|
||||||
|
|
||||||
// Internal to capture closure variables: alphaAnimatables, scaleAnimatables, density, animatedCornerRadius, content
|
val positionAnim = remember { Animatable(targetPosition, IntOffset.VectorConverter) }
|
||||||
@Composable
|
val sizeAnim = remember { Animatable(targetSize, IntSize.VectorConverter) }
|
||||||
fun RenderItem(item: T, itemKeyValue: Any, widthPx: Int, heightPx: Int) {
|
|
||||||
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
|
|
||||||
val itemScale = scaleAnimatables[itemKeyValue]?.value ?: 1f
|
|
||||||
|
|
||||||
Box(
|
// LaunchedEffect is tied to this composable's lifecycle and cancels automatically
|
||||||
modifier = Modifier
|
// when the item leaves composition, preventing any deactivated-node interaction.
|
||||||
.layoutId(itemKeyValue)
|
LaunchedEffect(targetPosition) {
|
||||||
.alpha(alpha)
|
positionAnim.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||||
.scale(itemScale)
|
}
|
||||||
) {
|
LaunchedEffect(targetSize) {
|
||||||
content(
|
sizeAnim.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||||
item,
|
}
|
||||||
Modifier
|
|
||||||
.size(
|
Box(modifier = Modifier.absoluteOffset { positionAnim.value }) {
|
||||||
width = with(density) { widthPx.toDp() },
|
content(
|
||||||
height = with(density) { heightPx.toDp() }
|
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 windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
|
||||||
val strategy = rememberCallGridStrategy()
|
val strategy = rememberCallGridStrategy()
|
||||||
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val widthDp = maxWidth
|
|
||||||
val heightDp = maxHeight
|
|
||||||
|
|
||||||
CallGrid(
|
CallGrid(
|
||||||
items = items,
|
items = items,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -796,8 +636,7 @@ private fun CallGridPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "${widthDp.value.toInt()} x ${heightDp.value.toInt()} dp\n" +
|
text = "WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||||
"WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
|
||||||
"Strategy: ${strategy::class.simpleName}",
|
"Strategy: ${strategy::class.simpleName}",
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import androidx.compose.animation.core.VectorConverter
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
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.Constraints
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,40 +108,59 @@ fun AnimatedFlowRow(
|
|||||||
positionAnimationSpec: FiniteAnimationSpec<IntOffset> = AnimatedFlowRowDefaults.positionAnimationSpec,
|
positionAnimationSpec: FiniteAnimationSpec<IntOffset> = AnimatedFlowRowDefaults.positionAnimationSpec,
|
||||||
content: AnimatedFlowRowScope.() -> Unit
|
content: AnimatedFlowRowScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
// Plain map (not snapshot state) so initialization inside the Layout measurement lambda is safe.
|
||||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
val positionAnimatables: MutableMap<Any, Animatable<IntOffset, *>> = remember { mutableMapOf() }
|
||||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||||
val knownKeys = remember { mutableSetOf<Any>() }
|
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() }
|
val flowRowScope = remember { AnimatedFlowRowScope() }
|
||||||
flowRowScope.items.clear()
|
flowRowScope.items.clear()
|
||||||
flowRowScope.content()
|
flowRowScope.content()
|
||||||
|
|
||||||
// Key operations run each recomposition to track additions/removals synchronously
|
|
||||||
val itemKeys = flowRowScope.items.map { it.first }
|
val itemKeys = flowRowScope.items.map { it.first }
|
||||||
val currentKeysSet = itemKeys.toSet()
|
val currentKeysSet = itemKeys.toSet()
|
||||||
|
|
||||||
// Determine which keys are new (not seen before) - check synchronously
|
|
||||||
val newKeys = currentKeysSet - knownKeys
|
val newKeys = currentKeysSet - knownKeys
|
||||||
val hasExistingItems = knownKeys.isNotEmpty()
|
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 ->
|
newKeys.forEach { key ->
|
||||||
if (hasExistingItems) {
|
alphaAnimatables[key] = if (hasExistingItems) Animatable(0f) else Animatable(1f)
|
||||||
alphaAnimatables[key] = Animatable(0f)
|
|
||||||
}
|
|
||||||
knownKeys.add(key)
|
knownKeys.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up animatables for removed items
|
|
||||||
val removedKeys = knownKeys - currentKeysSet
|
val removedKeys = knownKeys - currentKeysSet
|
||||||
removedKeys.forEach { key ->
|
removedKeys.forEach { key ->
|
||||||
positionAnimatables.remove(key)
|
positionAnimatables.remove(key)
|
||||||
alphaAnimatables.remove(key)
|
alphaAnimatables.remove(key)
|
||||||
|
firstSeenInLayout.remove(key)
|
||||||
knownKeys.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) {
|
val layoutModifier = if (sizeAnimationSpec != null) {
|
||||||
modifier.animateContentSize(animationSpec = sizeAnimationSpec)
|
modifier.animateContentSize(animationSpec = sizeAnimationSpec)
|
||||||
} else {
|
} else {
|
||||||
@@ -170,35 +189,17 @@ fun AnimatedFlowRow(
|
|||||||
measurable.layoutId to placeable
|
measurable.layoutId to placeable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate flow row positions (centered, wrapping)
|
|
||||||
val (totalHeight, positions) = calculateFlowRowPositions(measurables, placeables, constraints.maxWidth)
|
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) ->
|
positions.forEach { (key, targetPosition) ->
|
||||||
val existingPosition = positionAnimatables[key]
|
if (positionAnimatables[key] == null) {
|
||||||
if (existingPosition == null) {
|
|
||||||
// New item - start at target position
|
|
||||||
positionAnimatables[key] = Animatable(targetPosition, IntOffset.VectorConverter)
|
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) {
|
layout(constraints.maxWidth, totalHeight) {
|
||||||
positions.forEach { (key, _) ->
|
positions.forEach { (key, _) ->
|
||||||
val placeable = keyToPlaceable[key]
|
val placeable = keyToPlaceable[key]
|
||||||
@@ -227,7 +228,6 @@ private fun calculateFlowRowPositions(
|
|||||||
var currentRow = mutableListOf<Triple<Any, Measurable, Placeable>>()
|
var currentRow = mutableListOf<Triple<Any, Measurable, Placeable>>()
|
||||||
var currentRowWidth = 0
|
var currentRowWidth = 0
|
||||||
|
|
||||||
// Group items into rows
|
|
||||||
measurables.zip(placeables).forEach { (measurable, placeable) ->
|
measurables.zip(placeables).forEach { (measurable, placeable) ->
|
||||||
val key = measurable.layoutId ?: return@forEach
|
val key = measurable.layoutId ?: return@forEach
|
||||||
if (currentRowWidth + placeable.width > maxWidth && currentRow.isNotEmpty()) {
|
if (currentRowWidth + placeable.width > maxWidth && currentRow.isNotEmpty()) {
|
||||||
@@ -242,10 +242,8 @@ private fun calculateFlowRowPositions(
|
|||||||
rows.add(currentRow)
|
rows.add(currentRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total height first
|
|
||||||
val totalHeight = rows.sumOf { row -> row.maxOf { it.third.height } }
|
val totalHeight = rows.sumOf { row -> row.maxOf { it.third.height } }
|
||||||
|
|
||||||
// Calculate positions (centered per row, from top to bottom)
|
|
||||||
var y = 0
|
var y = 0
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
val rowWidth = row.sumOf { it.third.width }
|
val rowWidth = row.sumOf { it.third.width }
|
||||||
|
|||||||
Reference in New Issue
Block a user