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.
This commit is contained in:
austinried
2026-10-21 10:13:52 +00:00
parent 0602f835dc
commit 61774a19ca
2 changed files with 116 additions and 1 deletions
@@ -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,
@@ -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<ComponentActivity>()
@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")
}
}
}
}
}