From 61774a19ca71fafc2f83b8299a3ba71d8d46407e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:52:50 +0000 Subject: [PATCH] library: restore detail scroll positions after the rows load The lazy list restores its saved position before a paging-backed playlist has replayed its cached pages, so a zero-item first frame clamped it back to the top. Track the position separately and reapply it once the item exists, which covers paged playlists as well as the album and artist lists. The test disposes and restores the scaffold with an empty first frame to keep this from regressing. --- .../ui/components/HeroDetailScaffold.kt | 35 +++++++- .../HeroDetailScaffoldRestoreTest.kt | 82 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index e3722663..761db24f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -78,6 +84,7 @@ import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.heroBarColor +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private const val FADE_DISTANCE_DP = 64 @@ -103,7 +110,7 @@ fun HeroDetailScaffold( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - val listState = rememberLazyListState() + val listState = rememberRestorableLazyListState() val scope = rememberCoroutineScope() val fill = rememberViewportFill(listState) val density = LocalDensity.current @@ -206,6 +213,32 @@ fun HeroDetailScaffold( } } +@Composable +private fun rememberRestorableLazyListState(): LazyListState { + val state = rememberLazyListState() + var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) } + var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) } + var pending by remember { mutableStateOf(index > 0) } + LaunchedEffect(state) { + if (pending) { + state.requestScrollToItem(index, offset) + snapshotFlow { state.layoutInfo.totalItemsCount }.first { it > index } + state.scrollToItem(index, offset) + pending = false + } + } + LaunchedEffect(state) { + snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset } + .collect { (currentIndex, currentOffset) -> + if (!pending) { + index = currentIndex + offset = currentOffset + } + } + } + return state +} + @Composable private fun HeroSubtitle( text: String, diff --git a/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt new file mode 100644 index 00000000..b6e821f1 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt @@ -0,0 +1,82 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HeroDetailScaffoldRestoreTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun aRestoredScreenWaitsForItsRowsBeforeRestoringTheScrollPosition() { + var visible by mutableStateOf(true) + var rows by mutableIntStateOf(100) + composeRule.setContent { + SubtracksTheme { + val holder = rememberSaveableStateHolder() + if (visible) { + holder.SaveableStateProvider("detail") { + HeroDetailScaffold( + artwork = null, + title = "Album", + onBack = {}, + header = { _, _ -> Box(Modifier.height(200.dp)) }, + content = { rowModifier -> rowItems(rows, rowModifier) }, + ) + } + } + } + } + + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Item 20")) + composeRule.onNodeWithText("Item 20").assertIsDisplayed() + + composeRule.runOnIdle { visible = false } + composeRule.runOnIdle { + rows = 0 + visible = true + } + composeRule.waitForIdle() + composeRule.runOnIdle { rows = 100 } + composeRule.waitForIdle() + + composeRule.onNodeWithText("Item 20").assertIsDisplayed() + } + + private fun LazyListScope.rowItems( + count: Int, + modifier: Modifier, + ) { + repeat(count) { index -> + item(key = index) { + Box(modifier.height(48.dp)) { + Text("Item $index") + } + } + } + } +}