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.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -33,7 +37,9 @@ fun CatalogRowSection(
onItemClick: (String, String, String) -> Unit, onItemClick: (String, String, String) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialScrollIndex: Int = 0 initialScrollIndex: Int = 0,
focusedItemIndex: Int = -1,
onItemFocused: (itemIndex: Int) -> Unit = {}
) { ) {
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = initialScrollIndex 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 { val shouldLoadMore by remember {
derivedStateOf { derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
@ -95,7 +117,13 @@ fun CatalogRowSection(
ContentCard( ContentCard(
item = item, item = item,
onClick = { onItemClick(item.id, item.type.toApiString(), catalogRow.addonBaseUrl) }, 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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -40,6 +42,7 @@ import com.nuvio.tv.ui.theme.NuvioTheme
fun ContentCard( fun ContentCard(
item: MetaPreview, item: MetaPreview,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null,
onClick: () -> Unit = {} onClick: () -> Unit = {}
) { ) {
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
@ -62,6 +65,10 @@ fun ContentCard(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester)
else Modifier
)
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
shape = CardDefaults.shape( shape = CardDefaults.shape(
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)

View file

@ -13,6 +13,9 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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 // Save scroll position when leaving screen
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
viewModel.saveFocusState( viewModel.saveFocusState(
verticalScrollIndex = columnListState.firstVisibleItemIndex, verticalScrollIndex = columnListState.firstVisibleItemIndex,
verticalScrollOffset = columnListState.firstVisibleItemScrollOffset, verticalScrollOffset = columnListState.firstVisibleItemScrollOffset,
focusedRowIndex = 0, // Basic implementation focusedRowIndex = currentFocusedRowIndex,
focusedItemIndex = 0, // Basic implementation focusedItemIndex = currentFocusedItemIndex,
catalogRowScrollStates = emptyMap() // Will be enhanced with horizontal scroll tracking catalogRowScrollStates = catalogRowScrollStates.toMap()
) )
} }
} }
@ -109,6 +117,9 @@ fun HomeScreen(
key = { _, item -> "${item.addonId}_${item.type}_${item.catalogId}" } key = { _, item -> "${item.addonId}_${item.type}_${item.catalogId}" }
) { index, catalogRow -> ) { index, catalogRow ->
val catalogKey = "${catalogRow.addonId}_${catalogRow.type.toApiString()}_${catalogRow.catalogId}" val catalogKey = "${catalogRow.addonId}_${catalogRow.type.toApiString()}_${catalogRow.catalogId}"
val shouldRestoreFocus = index == focusState.focusedRowIndex
val focusedItemIndex = if (shouldRestoreFocus) focusState.focusedItemIndex else -1
CatalogRowSection( CatalogRowSection(
catalogRow = catalogRow, catalogRow = catalogRow,
onItemClick = { id, type, addonBaseUrl -> 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
}
) )
} }
} }