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

@ -18,6 +18,7 @@ import io.mockk.mockk
import java.util.UUID import java.util.UUID
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.assertSame
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -32,9 +33,25 @@ import org.robolectric.annotation.Config
class HomeSeeAllNavigationTest { class HomeSeeAllNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() { @Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
assertEquals(18, grid.items.size)
repeat(2) { press(Key.DirectionDown) }
repeat(5) { press(Key.DirectionRight) }
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
}
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
press(Key.DirectionDown)
assertEquals(6, grid.index)
rule.onNodeWithTag("library-grid").assertIsDisplayed()
}
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
rule.mainClock.autoAdvance = false
val snapshot = HomeFixture(rule.activity).snapshot("justin") val snapshot = HomeFixture(rule.activity).snapshot("justin")
val row = snapshot.rows.first { it.id == "pk-m" } val row = snapshot.rows.first { it.id == rowId }
val cardTag = "home-card:$rowId:$expectedColumn"
val navigation = NavigationManager() val navigation = NavigationManager()
val lists = WolfskinSeeAllLists() val lists = WolfskinSeeAllLists()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") { val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
@ -58,24 +75,56 @@ class HomeSeeAllNavigationTest {
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier) else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
} } } }
} }
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed() settle()
rule.onNodeWithTag(cardTag).assertIsDisplayed()
val home = shell.view
val track = home.home.tracks.getValue(rowId)
val verticalIndex = home.listState.firstVisibleItemIndex
val verticalOffset = home.listState.firstVisibleItemScrollOffset
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
val horizontalIndex = track.firstVisibleItemIndex
val horizontalOffset = track.firstVisibleItemScrollOffset
press(Key.DirectionCenter) press(Key.DirectionCenter)
assertEquals("seeall/justin/pk-m", (navigation.backStack.last() as Destination.Wolfskin).route) assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
val grid = requireNotNull(lists.get("justin/pk-m")) val grid = requireNotNull(lists.get("justin/$rowId"))
assertEquals(18, grid.items.size) try {
repeat(2) { press(Key.DirectionDown) } rule.onNodeWithTag("library-grid").assertIsDisplayed()
repeat(5) { press(Key.DirectionRight) } exerciseGrid(grid)
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed() press(Key.Back)
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id) rule.waitForIdle()
press(Key.Back) assertTrue(navigation.backStack.last() is Destination.Home)
rule.waitForIdle() assertEquals(row.id, snapshot.rows[shell.view.row].id)
assertTrue(navigation.backStack.last() is Destination.Home) assertEquals(expectedColumn, shell.view.cards[row.id])
assertEquals(row.id, snapshot.rows[shell.view.row].id) assertSame("Home retains its original route memory", home, shell.view)
assertEquals(5, shell.view.cards[row.id]) assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
rule.onNodeWithTag("home-card:pk-m:5").assertIsDisplayed() assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
rule.onNodeWithTag(cardTag).assertIsDisplayed()
} catch (failure: AssertionError) {
throw AssertionError(
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
failure,
)
}
} }
private fun press(key: Key) { private fun press(key: Key) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
settle()
}
private fun settle() {
repeat(3) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
rule.mainClock.advanceTimeBy(1_000)
rule.waitForIdle()
} }
} }

View file

@ -50,7 +50,7 @@ fun WolfskinLibraryContent(
seeAll: WolfskinSeeAllLists, seeAll: WolfskinSeeAllLists,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val view = shell.view val view = shell.viewFor(route)
val fixture by produceState<LibraryFixture?>(null, context) { val fixture by produceState<LibraryFixture?>(null, context) {
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) { if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
@ -89,7 +89,7 @@ fun WolfskinLibraryContent(
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
} }
} }
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null) LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
} }
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime") val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")

View file

@ -131,10 +131,16 @@ the complete underlying list rather than the visible shelf window. Title content
remains the later-slice target. remains the later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`. All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's card and first The shell's remembered view owns vertical position, each row's selected card and
visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
to Home does not re-run focus-follow over a manually scrolled saved view. The Composing destinations bind `viewFor(route)` before navigation effects run, so a
next arrow re-enters visible content when scrolling moved focus off-screen. new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
remembered track before the page with `scrollToItem`, preserving exact indices and
pixel offsets rather than animating back. Android focus stays with the shell;
cards use its logical focus, without requests against uncomposed lazy items.
Back preserves the viewport; returning to Home does not re-run focus-follow over
a manually scrolled saved view. The next arrow re-enters visible content when
scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. OK events cannot activate twice.
Between shelves, an unvisited row selects the nearest visible card center even Between shelves, an unvisited row selects the nearest visible card center even

View file

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

View file

@ -27,7 +27,7 @@ fun HomeScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onAction: (HomeAction) -> Unit, onAction: (HomeAction) -> Unit,
) { ) {
val view = shell.view val view = shell.viewFor("home")
LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt } LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt }
val action by rememberUpdatedState(onAction) val action by rememberUpdatedState(onAction)
val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } } val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } }
@ -59,6 +59,14 @@ fun HomeScreen(
LaunchedEffect(view.row, snapshot.rows) { LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } 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) { LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect 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.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush 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 caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } 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 val density = LocalDensity.current
LaunchedEffect(first) { LaunchedEffect(track) {
val pitch = with(density) { (width + K.posterGap).toPx() } track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
track.animateScrollBy((first - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base)) 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)) { 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)) 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 package org.justinvisser.wolfskin.design
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
@ -9,6 +10,7 @@ import androidx.compose.runtime.setValue
class HomeViewMemory { class HomeViewMemory {
val ratings = HomeRatingCache() val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>() val firstCards = mutableStateMapOf<String, Int>()
val tracks = mutableMapOf<String, LazyListState>()
var loadingEntryRevision: Long? = null var loadingEntryRevision: Long? = null
var focusedKey: String? = null var focusedKey: String? = null
var positionedKey: String? = null var positionedKey: String? = null

View file

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

View file

@ -68,7 +68,10 @@ class ShellState(
private set private set
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) 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. */ /** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) { fun enterDestination(destination: String) {