From 4dd30f4ec3ca94d1ef5111564f8303e017c36dc9 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 31 Mar 2026 15:49:13 -0300 Subject: [PATCH] Fix deactivated node crash in call screen layout. Co-authored-by: Greyson Parrelli --- .../components/webrtc/v2/CallGrid.kt | 333 +++++------------- .../signal/core/ui/compose/AnimatedFlowRow.kt | 74 ++-- 2 files changed, 122 insertions(+), 285 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt index 7dd32e33aa..184fe1d36a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallGrid.kt @@ -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( - val item: T, - val key: Any, - val lastPosition: IntOffset, - val lastSize: IntSize -) +private data class ManagedItem(val item: T, val animateEnter: Boolean) /** * An animated grid layout for call participants. @@ -479,7 +465,6 @@ private data class ExitingItem( * - 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 CallGrid( content: @Composable (item: T, modifier: Modifier) -> Unit ) { val strategy = rememberCallGridStrategy() - val scope = rememberCoroutineScope() - - val positionAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } - val sizeAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } - val alphaAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } - val scaleAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } - val knownKeys = remember { mutableSetOf() } - var exitingItems: List> by remember { mutableStateOf(emptyList()) } - val previousItems = remember { mutableStateMapOf() } - 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 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> = 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() } + 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>) { - 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>) { - 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) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/AnimatedFlowRow.kt b/core/ui/src/main/java/org/signal/core/ui/compose/AnimatedFlowRow.kt index dedbc034ff..b39cd5fa33 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/AnimatedFlowRow.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/AnimatedFlowRow.kt @@ -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 = AnimatedFlowRowDefaults.positionAnimationSpec, content: AnimatedFlowRowScope.() -> Unit ) { - val scope = rememberCoroutineScope() - val positionAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } + // Plain map (not snapshot state) so initialization inside the Layout measurement lambda is safe. + val positionAnimatables: MutableMap> = remember { mutableMapOf() } val alphaAnimatables: SnapshotStateMap> = remember { mutableStateMapOf() } val knownKeys = remember { mutableSetOf() } + val firstSeenInLayout = remember { mutableSetOf() } + + // MutableStateFlow (not snapshot state) bridges Layout measurement to composition-phase + // animation launches, so position writes inside Layout measurement are safe. + val pendingPositions = remember { MutableStateFlow>(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>() 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 }