Restore Home viewport across See all navigation

This commit is contained in:
justinzeus 2026-11-05 19:28:09 +01:00
parent 14a74ac43b
commit 9c8fceb18c
9 changed files with 125 additions and 45 deletions

View file

@ -8,18 +8,19 @@ class HomeFocus(
private val rows: List<HomeRow>,
private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus {
private val view = shell.viewFor("home")
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(shell.view.row)
val column: Int get() = row?.let { shell.view.cards[it.id] ?: 0 } ?: 0
val row: HomeRow? get() = rows.getOrNull(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
fun focus(index: Int, column: Int) {
shell.navFocused = false
shell.view.row = index
shell.view.home.focusedKey = rows[index].id
view.row = index
view.home.focusedKey = rows[index].id
val target = rows[index]
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
shell.view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0)
}
override fun key(key: ShellKey): Boolean {
@ -28,19 +29,19 @@ class HomeFocus(
if (key == ShellKey.Ok) { activate(current); return true }
if (reenterVisible()) return true
when (key) {
ShellKey.Left -> focus(shell.view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(shell.view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0)))
ShellKey.Up, ShellKey.Down -> {
val direction = if (key == ShellKey.Up) -1 else 1
var next = shell.view.row + direction
var next = view.row + direction
while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) {
val target = rows[next]
val nearest = nearest(current, target)
focus(next, shell.view.cards[target.id] ?: nearest)
focus(next, view.cards[target.id] ?: nearest)
} else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
shell.view.home.resetScroll++
view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
view.home.resetScroll++
shell.navFocused = true
shell.navIndex = shell.currentTab
}
@ -50,7 +51,6 @@ class HomeFocus(
}
private fun reenterVisible(): Boolean {
val view = shell.view
val layout = view.listState.layoutInfo
if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false
val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value
@ -79,9 +79,9 @@ class HomeFocus(
private fun nearest(from: HomeRow, to: HomeRow): Int {
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val last = minOf(to.count - 1, first + to.focusSlots - 1)
var best = first
var distance = Float.POSITIVE_INFINITY

View file

@ -27,7 +27,7 @@ fun HomeScreen(
modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit,
) {
val view = shell.view
val view = shell.viewFor("home")
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
@ -59,6 +59,14 @@ fun HomeScreen(
LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
}
LaunchedEffect(view) {
if (view.home.positionedKey != null) {
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
}
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect

View file

@ -8,13 +8,17 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -38,11 +42,19 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = rememberLazyListState(first)
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
val targetFirst by rememberUpdatedState(first)
val density = LocalDensity.current
LaunchedEffect(first) {
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((first - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
LaunchedEffect(track) {
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
var positionedFirst = targetFirst
snapshotFlow { targetFirst }.collectLatest { target ->
if (target != positionedFirst) {
positionedFirst = target
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
}
}
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))

View file

@ -1,5 +1,6 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
@ -9,6 +10,7 @@ import androidx.compose.runtime.setValue
class HomeViewMemory {
val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
val tracks = mutableMapOf<String, LazyListState>()
var loadingEntryRevision: Long? = null
var focusedKey: String? = null
var positionedKey: String? = null

View file

@ -40,8 +40,8 @@ fun LibraryScreen(
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
view: RememberedView = shell.view,
) {
val view = shell.view
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible

View file

@ -68,7 +68,10 @@ class ShellState(
private set
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
val view: RememberedView get() = viewFor(route)
/** A composing destination must not borrow the previous route's scroller before its enter effect. */
fun viewFor(destination: String): RememberedView = views.getOrPut(person.key to destination) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {