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") + } + } + } + } +}