library: bound the detail scroll restore and cover grids

Gate the restore on the caller's content being ready instead of an item
count that can include only the header, clear it after clamping to the
available rows, and include offset-only positions. Apply the same saver
to the artist grid so a process-death restore does not open at the top.
This commit is contained in:
austinried
2026-10-21 10:13:52 +00:00
parent 2c6af4b126
commit 2da0d61d4c
7 changed files with 46 additions and 14 deletions
@@ -84,7 +84,6 @@ import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.baseArtworkColors
import com.subtracks.ui.theme.heroBarColor import com.subtracks.ui.theme.heroBarColor
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
@@ -105,12 +104,13 @@ fun HeroDetailScaffold(
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit, header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
content: LazyListScope.(rowModifier: Modifier) -> Unit, content: LazyListScope.(rowModifier: Modifier) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentReady: Boolean = true,
) { ) {
val effectiveArtwork = artwork ?: baseArtworkColors() val effectiveArtwork = artwork ?: baseArtworkColors()
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(effectiveArtwork) { ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberRestorableLazyListState() val listState = rememberRestorableLazyListState(contentReady)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
@@ -214,16 +214,15 @@ fun HeroDetailScaffold(
} }
@Composable @Composable
private fun rememberRestorableLazyListState(): LazyListState { private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
val state = rememberLazyListState() val state = rememberLazyListState()
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) } var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) } var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
var pending by remember { mutableStateOf(index > 0) } var pending by remember { mutableStateOf(index > 0 || offset > 0) }
LaunchedEffect(state) { LaunchedEffect(contentReady) {
if (pending) { if (contentReady && pending) {
state.requestScrollToItem(index, offset) val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
snapshotFlow { state.layoutInfo.totalItemsCount }.first { it > index } state.scrollToItem(target, offset)
state.scrollToItem(index, offset)
pending = false pending = false
} }
} }
@@ -215,6 +215,7 @@ fun AlbumDetailScreen(
artwork = artwork, artwork = artwork,
title = album?.name.orEmpty(), title = album?.name.orEmpty(),
onBack = onBack, onBack = onBack,
contentReady = songs.isNotEmpty(),
header = { controlsModifier, topInset -> header = { controlsModifier, topInset ->
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() } val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
HeroHeader( HeroHeader(
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@@ -55,10 +56,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.draw.alpha import androidx.compose.ui.draw.alpha
@@ -302,7 +306,7 @@ fun ArtistDetailScreen(
onCollapseBio: () -> Unit = {}, onCollapseBio: () -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
) { ) {
val listState = rememberLazyGridState() val listState = rememberRestorableLazyGridState(albumsLoaded)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val lineHeight = val lineHeight =
@@ -570,6 +574,31 @@ fun ArtistDetailScreen(
} }
} }
@Composable
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
val state = rememberLazyGridState()
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
LaunchedEffect(contentReady) {
if (contentReady && pending) {
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
state.scrollToItem(target, offset)
pending = false
}
}
LaunchedEffect(state) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
.collect { (currentIndex, currentOffset) ->
if (!pending) {
index = currentIndex
offset = currentOffset
}
}
}
return state
}
// Only the lines that overlap the action buttons are narrowed; later lines use the full width. // Only the lines that overlap the action buttons are narrowed; later lines use the full width.
@Composable @Composable
private fun ArtistBio( private fun ArtistBio(
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -58,7 +57,6 @@ class ArtistDetailViewModel(
val albums: StateFlow<ArtistAlbums?> = val albums: StateFlow<ArtistAlbums?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<ArtistAlbums, ArtistAlbums?> { it }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val downloadStatus: StateFlow<ListDownloadStatus> = val downloadStatus: StateFlow<ListDownloadStatus> =
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle()
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
artwork = artwork, artwork = artwork,
title = playlist?.name.orEmpty(), title = playlist?.name.orEmpty(),
onBack = onBack, onBack = onBack,
contentReady = songs.itemCount > 0,
header = { controlsModifier, topInset -> header = { controlsModifier, topInset ->
HeroHeader( HeroHeader(
art = coverArt(playlist?.coverArt, false), art = coverArt(playlist?.coverArt, false),
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) } val playlist: StateFlow<Playlist?> =
sourceId
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val songs: Flow<PagingData<PlaylistSongItem>> = val songs: Flow<PagingData<PlaylistSongItem>> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
@@ -46,6 +46,7 @@ class HeroDetailScaffoldRestoreTest {
onBack = {}, onBack = {},
header = { _, _ -> Box(Modifier.height(200.dp)) }, header = { _, _ -> Box(Modifier.height(200.dp)) },
content = { rowModifier -> rowItems(rows, rowModifier) }, content = { rowModifier -> rowItems(rows, rowModifier) },
contentReady = rows > 0,
) )
} }
} }