Skip to content

Commit 09ec7c0

Browse files
author
Arvin
committed
fix(home): keep loaded catalog cards visible
1 parent 9ea163d commit 09ec7c0

2 files changed

Lines changed: 58 additions & 51 deletions

File tree

app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -380,18 +380,18 @@ internal fun resolveHomeCategoryIndex(
380380
internal fun resolveHomeItemIndex(
381381
itemKeys: List<String>,
382382
preferredItemKey: String?,
383-
fallbackIndex: Int,
384-
hasMore: Boolean
383+
fallbackIndex: Int
385384
): Int {
386385
val preferredIndex = preferredItemKey?.let(itemKeys::indexOf) ?: -1
387386
if (preferredIndex >= 0) return preferredIndex
387+
if (itemKeys.isEmpty()) return 0
388+
return fallbackIndex.coerceIn(0, itemKeys.lastIndex)
389+
}
388390

389-
val safeFallback = fallbackIndex.coerceAtLeast(0)
390-
if (itemKeys.isEmpty() || safeFallback <= itemKeys.lastIndex) return safeFallback
391-
392-
// A paged row can temporarily contain fewer items while it is refreshing.
393-
// Preserve the intended index until the page arrives instead of snapping left.
394-
return if (hasMore) safeFallback else itemKeys.lastIndex
391+
internal fun clampHomeItemIndex(items: List<MediaItem>, index: Int): Int {
392+
val realItemCount = items.count { !it.isPlaceholder }
393+
val navigableItemCount = if (realItemCount > 0) realItemCount else items.size
394+
return if (navigableItemCount == 0) 0 else index.coerceIn(0, navigableItemCount - 1)
395395
}
396396

397397
@androidx.compose.runtime.Immutable
@@ -2455,17 +2455,15 @@ private fun HomeInputLayer(
24552455
stableHomeRowItemKeys(focusedCategory.id, focusedCategory.items)
24562456
}
24572457
}
2458-
val focusedRowHasMore = focusedCategoryId?.let { categoryHasMoreMap[it] == true } == true
2459-
2460-
// Restore the same title when a row is reordered or refreshed. Empty and
2461-
// partial paged results keep the pending index instead of resetting to zero.
2462-
LaunchedEffect(focusedCategoryId, focusedItemKeys, focusedRowHasMore) {
2458+
// Restore the same title when a row is reordered or refreshed. Always clamp
2459+
// to a real item: an out-of-range index can otherwise scroll a paged rail
2460+
// entirely into its loading placeholders.
2461+
LaunchedEffect(focusedCategoryId, focusedItemKeys) {
24632462
val categoryId = focusedCategoryId ?: return@LaunchedEffect
24642463
val resolvedIndex = resolveHomeItemIndex(
24652464
itemKeys = focusedItemKeys,
24662465
preferredItemKey = focusState.rowItemKeysByCategoryId[categoryId],
2467-
fallbackIndex = focusState.currentItemIndex,
2468-
hasMore = focusedRowHasMore
2466+
fallbackIndex = focusState.currentItemIndex
24692467
)
24702468
if (focusState.currentItemIndex != resolvedIndex) {
24712469
focusState.currentItemIndex = resolvedIndex
@@ -2600,10 +2598,13 @@ private fun HomeInputLayer(
26002598
}
26012599
focusState.currentRowIndex--
26022600
// Restore saved position for the target row (or 0 if never visited)
2603-
val targetCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id
2604-
focusState.currentItemIndex = targetCategoryId
2601+
val targetCategory = categories.getOrNull(focusState.currentRowIndex)
2602+
val restoredIndex = targetCategory?.id
26052603
?.let(focusState.rowItemIndicesByCategoryId::get)
26062604
?: 0
2605+
focusState.currentItemIndex = targetCategory
2606+
?.let { clampHomeItemIndex(it.items, restoredIndex) }
2607+
?: 0
26072608
focusState.lastNavEventTime = SystemClock.elapsedRealtime()
26082609
true
26092610
} else {
@@ -2620,10 +2621,13 @@ private fun HomeInputLayer(
26202621
focusState.userHasNavigated = true
26212622
if (focusState.isSidebarFocused) {
26222623
focusState.isSidebarFocused = false
2623-
val targetCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id
2624-
focusState.currentItemIndex = targetCategoryId
2624+
val targetCategory = categories.getOrNull(focusState.currentRowIndex)
2625+
val restoredIndex = targetCategory?.id
26252626
?.let(focusState.rowItemIndicesByCategoryId::get)
26262627
?: focusState.currentItemIndex
2628+
focusState.currentItemIndex = targetCategory
2629+
?.let { clampHomeItemIndex(it.items, restoredIndex) }
2630+
?: 0
26272631
focusState.lastNavEventTime = SystemClock.elapsedRealtime()
26282632
true
26292633
} else if (!focusState.isSidebarFocused && focusState.currentRowIndex < categories.size - 1) {
@@ -2633,10 +2637,13 @@ private fun HomeInputLayer(
26332637
}
26342638
focusState.currentRowIndex++
26352639
// Restore saved position for the target row (or 0 if never visited)
2636-
val targetCategoryId = categories.getOrNull(focusState.currentRowIndex)?.id
2637-
focusState.currentItemIndex = targetCategoryId
2640+
val targetCategory = categories.getOrNull(focusState.currentRowIndex)
2641+
val restoredIndex = targetCategory?.id
26382642
?.let(focusState.rowItemIndicesByCategoryId::get)
26392643
?: 0
2644+
focusState.currentItemIndex = targetCategory
2645+
?.let { clampHomeItemIndex(it.items, restoredIndex) }
2646+
?: 0
26402647
focusState.lastNavEventTime = SystemClock.elapsedRealtime()
26412648
true
26422649
} else {
@@ -3354,7 +3361,11 @@ private fun TvHomeRowsLayer(
33543361
categoryHasMore = categoryHasMoreMap[category.id] == true,
33553362
smoothScrolling = smoothScrolling,
33563363
onLoadMore = onRowLoadMore,
3357-
focusedItemIndex = if (rowIsFocused) focusState.currentItemIndex else -1,
3364+
focusedItemIndex = if (rowIsFocused) {
3365+
clampHomeItemIndex(category.items, focusState.currentItemIndex)
3366+
} else {
3367+
-1
3368+
},
33583369
isFastScrolling = rowIsFocused && isFastScrolling,
33593370
featuredTrailerKey = if (rowIsFocused) featuredTrailerKey else null,
33603371
featuredTrailerDelayMs = featuredTrailerDelayMs,
@@ -3594,7 +3605,7 @@ private fun ContentRow(
35943605
val cardAspectRatio = if (effectivePosterMode) 2f / 3f else 16f / 9f
35953606
val itemWidth = if (effectivePosterMode) 105.dp else 210.dp
35963607
val itemSpacing = 14.dp
3597-
val itemsToRender = remember(category.items, effectiveCategoryHasMore, effectivePosterMode) {
3608+
val itemsToRender = remember(category.items) {
35983609
if (category.items.isEmpty()) {
35993610
(1..8).map { index ->
36003611
MediaItem(
@@ -3604,17 +3615,9 @@ private fun ContentRow(
36043615
isPlaceholder = true
36053616
)
36063617
}
3607-
} else if (effectiveCategoryHasMore) {
3608-
val skeletonCount = if (effectivePosterMode) 12 else 7
3609-
category.items + List(skeletonCount) { idx ->
3610-
MediaItem(
3611-
id = -1000 - idx,
3612-
title = "",
3613-
isPlaceholder = true
3614-
)
3615-
}
36163618
} else {
3617-
category.items
3619+
val realItems = category.items.filterNot { it.isPlaceholder }
3620+
realItems.ifEmpty { category.items }
36183621
}
36193622
}
36203623
val itemKeys = remember(category.id, itemsToRender) {
@@ -3812,12 +3815,8 @@ private fun ContentRow(
38123815
}
38133816
}
38143817
) { index, item ->
3815-
if (item.isPlaceholder) {
3816-
LaunchedEffect(item.id) {
3817-
onLoadMore()
3818-
}
3819-
} else if (effectiveCategoryHasMore && index >= category.items.size - 5) {
3820-
LaunchedEffect(category.items.size) {
3818+
if (!item.isPlaceholder && effectiveCategoryHasMore && index >= itemsToRender.size - 5) {
3819+
LaunchedEffect(itemsToRender.size) {
38213820
onLoadMore()
38223821
}
38233822
}

app/src/test/kotlin/com/arflix/tv/ui/screens/home/HomeRowStateTest.kt

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,49 +86,57 @@ class HomeRowStateTest {
8686
val resolvedIndex = resolveHomeItemIndex(
8787
itemKeys = reorderedKeys,
8888
preferredItemKey = "MOVIE-2",
89-
fallbackIndex = 1,
90-
hasMore = false
89+
fallbackIndex = 1
9190
)
9291

9392
assertThat(resolvedIndex).isEqualTo(2)
9493
}
9594

9695
@Test
97-
fun `empty refreshing catalog preserves pending poster index`() {
96+
fun `empty refreshing catalog resets poster index`() {
9897
val resolvedIndex = resolveHomeItemIndex(
9998
itemKeys = emptyList(),
10099
preferredItemKey = "MOVIE-20",
101-
fallbackIndex = 19,
102-
hasMore = true
100+
fallbackIndex = 19
103101
)
104102

105-
assertThat(resolvedIndex).isEqualTo(19)
103+
assertThat(resolvedIndex).isEqualTo(0)
106104
}
107105

108106
@Test
109-
fun `partial paged catalog preserves pending poster index`() {
107+
fun `partial paged catalog clamps poster index to real items`() {
110108
val resolvedIndex = resolveHomeItemIndex(
111109
itemKeys = stableHomeRowItemKeys("trending", List(10) { mediaItem(it + 1) }),
112110
preferredItemKey = "MOVIE-20",
113-
fallbackIndex = 19,
114-
hasMore = true
111+
fallbackIndex = 19
115112
)
116113

117-
assertThat(resolvedIndex).isEqualTo(19)
114+
assertThat(resolvedIndex).isEqualTo(9)
118115
}
119116

120117
@Test
121118
fun `completed shorter catalog clamps poster index`() {
122119
val resolvedIndex = resolveHomeItemIndex(
123120
itemKeys = stableHomeRowItemKeys("trending", List(10) { mediaItem(it + 1) }),
124121
preferredItemKey = "MOVIE-20",
125-
fallbackIndex = 19,
126-
hasMore = false
122+
fallbackIndex = 19
127123
)
128124

129125
assertThat(resolvedIndex).isEqualTo(9)
130126
}
131127

128+
@Test
129+
fun `remembered index ignores trailing placeholders`() {
130+
val items = List(4) { mediaItem(it + 1) } + MediaItem(
131+
id = -1,
132+
title = "",
133+
mediaType = MediaType.MOVIE,
134+
isPlaceholder = true
135+
)
136+
137+
assertThat(clampHomeItemIndex(items, 8)).isEqualTo(3)
138+
}
139+
132140
private fun mediaItem(id: Int) = MediaItem(
133141
id = id,
134142
title = "Title $id",

0 commit comments

Comments
 (0)