Restore Home viewport across See all navigation
This commit is contained in:
parent
14a74ac43b
commit
9c8fceb18c
9 changed files with 125 additions and 45 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue