fix(mutual): gate prefill scroll on real list layout (onGloballyPositioned)

On a device the Mutual entry is composed inside the NavDisplay transition;
LaunchedEffect could run before the LazyColumn is measurable and the
scrollToItem then never lands, leaving the page at the top. Only scroll
once onGloballyPositioned confirms the list has actually been laid out.
This commit is contained in:
atsunatsu committed 2026-09-22 14:49:00 +08:00
1 parent 94bffe80b0
commit d600836e19
1 file changed
+15 -3
@@ -54,11 +54,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -220,8 +222,18 @@ private fun MutualContent(
} }
} }
// Set once the list has actually been laid out. The prefill scroll only
// fires after this, because on a real device the Mutual entry appears
// through the NavDisplay transition and LaunchedEffect alone can run
// before the list is measurable (the scroll then never lands and the
// page stays at the top). onGloballyPositioned fires on the first real
// layout, so the subsequent scrollToItem always has a measured list.
var listReady by remember { mutableStateOf(false) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { listReady = true },
state = listState, state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
@@ -377,8 +389,8 @@ private fun MutualContent(
// LazyListState at index 1: the constructor parameter is ignored by this // LazyListState at index 1: the constructor parameter is ignored by this
// Compose version (verified in tests), while scrollToItem lands correctly. // Compose version (verified in tests), while scrollToItem lands correctly.
val matchSearchIndex = if (state.errorMessage != null) 2 else 1 val matchSearchIndex = if (state.errorMessage != null) 2 else 1
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex) { LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
if (state.scrollToTimeRange) { if (state.scrollToTimeRange && listReady) {
listState.scrollToItem(matchSearchIndex) listState.scrollToItem(matchSearchIndex)
viewModel.consumeScrollToTimeRange() viewModel.consumeScrollToTimeRange()
} }