Implement focus restoration for ContentCards in HomeScreen

- Add FocusRequester support to ContentCard component
- Track focused row and item indices in HomeScreen
- Add focus restoration logic to CatalogRowSection
- Wire up focus callbacks to update state on focus changes
- Handle edge cases: item not composed, item no longer exists

Completes focus restoration feature started in f5ead7c7. When
returning from MetaDetailsScreen, focus now restores to the exact
ContentCard that was clicked, not just scroll position.
This commit is contained in:
CrissZollo 2026-02-01 23:56:11 +01:00
parent 95ee3d1350
commit 74989b3de3
3 changed files with 58 additions and 6 deletions

View file

@ -16,8 +16,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -33,7 +37,9 @@ fun CatalogRowSection(
onItemClick: (String, String, String) -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
initialScrollIndex: Int = 0
initialScrollIndex: Int = 0,
focusedItemIndex: Int = -1,
onItemFocused: (itemIndex: Int) -> Unit = {}
) {
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = initialScrollIndex
@ -46,6 +52,22 @@ fun CatalogRowSection(
}
}
// Track which item has focus
var currentFocusedIndex by remember { mutableStateOf(-1) }
val itemFocusRequester = remember { FocusRequester() }
// Restore focus to specific item if requested
LaunchedEffect(focusedItemIndex) {
if (focusedItemIndex >= 0 && focusedItemIndex < catalogRow.items.size) {
kotlinx.coroutines.delay(100) // Wait for composition
try {
itemFocusRequester.requestFocus()
} catch (e: IllegalStateException) {
// Item not yet composed, ignore
}
}
}
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
@ -95,7 +117,13 @@ fun CatalogRowSection(
ContentCard(
item = item,
onClick = { onItemClick(item.id, item.type.toApiString(), catalogRow.addonBaseUrl) },
modifier = Modifier
modifier = Modifier.onFocusChanged { focusState ->
if (focusState.isFocused) {
currentFocusedIndex = index
onItemFocused(index)
}
},
focusRequester = if (index == focusedItemIndex) itemFocusRequester else null
)
}

View file

@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
@ -40,6 +42,7 @@ import com.nuvio.tv.ui.theme.NuvioTheme
fun ContentCard(
item: MetaPreview,
modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null,
onClick: () -> Unit = {}
) {
var isFocused by remember { mutableStateOf(false) }
@ -62,6 +65,10 @@ fun ContentCard(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester)
else Modifier
)
.onFocusChanged { isFocused = it.isFocused },
shape = CardDefaults.shape(
shape = RoundedCornerShape(8.dp)

View file

@ -13,6 +13,9 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -48,15 +51,20 @@ fun HomeScreen(
}
}
// Track which row and item have focus
var currentFocusedRowIndex by remember { mutableStateOf(focusState.focusedRowIndex) }
var currentFocusedItemIndex by remember { mutableStateOf(focusState.focusedItemIndex) }
val catalogRowScrollStates = remember { mutableMapOf<String, Int>() }
// Save scroll position when leaving screen
DisposableEffect(Unit) {
onDispose {
viewModel.saveFocusState(
verticalScrollIndex = columnListState.firstVisibleItemIndex,
verticalScrollOffset = columnListState.firstVisibleItemScrollOffset,
focusedRowIndex = 0, // Basic implementation
focusedItemIndex = 0, // Basic implementation
catalogRowScrollStates = emptyMap() // Will be enhanced with horizontal scroll tracking
focusedRowIndex = currentFocusedRowIndex,
focusedItemIndex = currentFocusedItemIndex,
catalogRowScrollStates = catalogRowScrollStates.toMap()
)
}
}
@ -109,6 +117,9 @@ fun HomeScreen(
key = { _, item -> "${item.addonId}_${item.type}_${item.catalogId}" }
) { index, catalogRow ->
val catalogKey = "${catalogRow.addonId}_${catalogRow.type.toApiString()}_${catalogRow.catalogId}"
val shouldRestoreFocus = index == focusState.focusedRowIndex
val focusedItemIndex = if (shouldRestoreFocus) focusState.focusedItemIndex else -1
CatalogRowSection(
catalogRow = catalogRow,
onItemClick = { id, type, addonBaseUrl ->
@ -123,7 +134,13 @@ fun HomeScreen(
)
)
},
initialScrollIndex = focusState.catalogRowScrollStates[catalogKey] ?: 0
initialScrollIndex = focusState.catalogRowScrollStates[catalogKey] ?: 0,
focusedItemIndex = focusedItemIndex,
onItemFocused = { itemIndex ->
currentFocusedRowIndex = index
currentFocusedItemIndex = itemIndex
catalogRowScrollStates[catalogKey] = itemIndex
}
)
}
}