From 64432b0032049fde109baa628514190cdf691b42 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 20:54:53 +0100 Subject: [PATCH] Compute lazy list keys from the list the lazy block renders --- .../justinvisser/wolfskin/design/ComingInSheet.kt | 5 ++--- .../org/justinvisser/wolfskin/design/HomeShelf.kt | 4 ++-- .../justinvisser/wolfskin/design/LibraryScreen.kt | 14 +++++++------- .../org/justinvisser/wolfskin/design/PersonPage.kt | 5 +++-- .../org/justinvisser/wolfskin/design/TitlePage.kt | 7 ++++--- .../org/justinvisser/wolfskin/design/TitleShelf.kt | 13 ++++++++----- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 57770dfd..66d94daa 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -97,10 +97,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { val visibleRows = remember(data, start, state.windowSize) { data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) } - val rowKeys = remember(visibleRows) { visibleRows.map { it.id }.uniqueKeys() } LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { - itemsIndexed(visibleRows, - key = { index, _ -> rowKeys[index] }, contentType = { _, _ -> "request" }) { _, item -> + val keys = visibleRows.map { it.id }.uniqueKeys() + itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item -> ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, state.ratingsVisible && data.signedIn && item.id == state.focusedId, onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index ec88684c..8703b853 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -48,9 +48,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In 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)) val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() - val cardKeys = remember(row.cards, row.skeleton, row.hasSeeAll, row.id) { cards.mapIndexed { index, title -> title?.id ?: "${row.id}:tail:$index" }.uniqueKeys() } LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { - itemsIndexed(cards, key = { index, _ -> cardKeys[index] }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> + val keys = cards.mapIndexed { index, title -> title?.id ?: "${row.id}:tail:$index" }.uniqueKeys() + itemsIndexed(cards, key = { index, _ -> keys[index] }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> val selected = !row.skeleton && focused && column == index val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index fa02a73a..742025de 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -98,12 +98,6 @@ fun LibraryScreen( animateLibraryScroll(view, headerCount, target, density) } } - val rowKeys = remember(items, grid.columns, grid.catalogue) { - (0 until (items.size + grid.columns - 1) / grid.columns).map { row -> - val title = items[row * grid.columns] - "grid-${if (grid.catalogue) title.catalogueKey else title.id}" - }.uniqueKeys() - } Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) { LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) { awaitPointerEventScope { @@ -134,7 +128,13 @@ fun LibraryScreen( LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } else { - items((items.size + grid.columns - 1) / grid.columns, key = { rowKeys[it] }, contentType = { "grid" }) { row -> + val rowCount = (items.size + grid.columns - 1) / grid.columns + // Keys come from the same items and column count as the row count, never from an earlier composition. + val rowKeys = (0 until rowCount).map { row -> + val title = items[row * grid.columns] + "grid-${if (grid.catalogue) title.catalogueKey else title.id}" + }.uniqueKeys() + items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index a9c8f84f..3249fce7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -128,7 +128,6 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier } key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) { - val rowKeys = androidx.compose.runtime.remember(state.credits, columns) { state.credits.chunked(columns).map { it.first().id }.uniqueKeys() } LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { item(key = "person-header", contentType = "header") { Box(Modifier.height(L.row_phead)) { @@ -145,7 +144,9 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier item(key = "filmography", contentType = "header") { Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) } - itemsIndexed(state.credits.chunked(columns), key = { index, _ -> rowKeys[index] }, contentType = { _, _ -> "grid" }) { rowIndex, row -> + val rowsOfCredits = state.credits.chunked(columns) + val rowKeys = rowsOfCredits.map { it.first().id }.uniqueKeys() + itemsIndexed(rowsOfCredits, key = { index, _ -> rowKeys[index] }, contentType = { _, _ -> "grid" }) { rowIndex, row -> Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index b7820c37..97e3f5f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -203,10 +203,11 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip) } } - val seasonKeys = remember(state.data.seasonNumbers) { state.data.seasonNumbers.map { "season-$it" }.uniqueKeys() } androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { - items(state.data.seasonNumbers.size, key = { seasonKeys[it] }, contentType = { "chip" }) { index -> - val number = state.data.seasonNumbers[index] + val seasonNumbers = state.data.seasonNumbers + val keys = seasonNumbers.map { "season-$it" }.uniqueKeys() + items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index -> + val number = seasonNumbers[index] val focused = active && state.column == index val selected = state.season == number val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index ea2163f6..63423a91 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -51,8 +51,6 @@ fun TitleCast(state: TitleState, active: Boolean) { LaunchedEffect(state.column, active, state.data.cast.size) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size) } - // The same person is credited once per role, so the person id alone repeats. - val castKeys = androidx.compose.runtime.remember(state.data.cast) { state.data.cast.map { it.id }.uniqueKeys() } Box(Modifier.height(D.castHeight).testTag("title-cast")) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading), @@ -61,7 +59,9 @@ fun TitleCast(state: TitleState, active: Boolean) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(L.castCircle).background(C.surface2, CircleShape)) } - } else itemsIndexed(state.data.cast, key = { index, _ -> castKeys[index] }, contentType = { _, _ -> "person" }) { index, person -> + } else { + val keys = state.data.cast.map { it.id }.uniqueKeys() + itemsIndexed(state.data.cast, key = { index, _ -> keys[index] }, contentType = { _, _ -> "person" }) { index, person -> val focused = active && state.column == index Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } @@ -73,6 +73,7 @@ fun TitleCast(state: TitleState, active: Boolean) { } } } + } } } } @@ -90,7 +91,6 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { LaunchedEffect(state.column, active, titles.size) { if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size) } - val titleKeys = androidx.compose.runtime.remember(titles) { titles.map { it.id }.uniqueKeys() } Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand).testTag("title-shelf:$id")) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) @@ -100,11 +100,14 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index -> Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } - } else itemsIndexed(titles, key = { index, _ -> titleKeys[index] }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> + } else { + val keys = titles.map { it.id }.uniqueKeys() + itemsIndexed(titles, key = { index, _ -> keys[index] }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier) .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } } + } } } }