Fixes focusing issues on many rows (#718)

## Description
Fixes focus jumping back a few cards on rows when returning to a page

I think some behavior changes occurred in a recent compose release.
`requestFocus` doesn't throw an exception anymore for example. Focus in
compose has always been finicky.

This PR basically adds some delegating parent focus down its to
children. Also, most rows now maintain an internal position state to
track where the focus requester is applied.

### Related issues
Closes #632
This commit is contained in:
Ray 2026-01-17 18:09:17 -05:00 committed by GitHub
parent 154713d3f1
commit 08355bf24b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 74 additions and 60 deletions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -8,15 +9,20 @@ 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.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable @Composable
fun <T> ItemRow( fun <T> ItemRow(
@ -36,9 +42,16 @@ fun <T> ItemRow(
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var position by rememberInt()
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier =
modifier.focusProperties {
onEnter = {
focusRequester.tryRequestFocus()
}
},
) { ) {
Text( Text(
text = title, text = title,
@ -52,11 +65,13 @@ fun <T> ItemRow(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRestorer(firstFocus), .focusGroup()
.focusRestorer(firstFocus)
.focusRequester(focusRequester),
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val cardModifier = val cardModifier =
if (index == 0) { if (index == position) {
Modifier.focusRequester(firstFocus) Modifier.focusRequester(firstFocus)
} else { } else {
Modifier Modifier
@ -65,8 +80,14 @@ fun <T> ItemRow(
index, index,
item, item,
cardModifier, cardModifier,
{ if (item != null) onClickItem.invoke(index, item) }, {
{ if (item != null) onLongClickItem.invoke(index, item) }, position = index
if (item != null) onClickItem.invoke(index, item)
},
{
position = index
if (item != null) onLongClickItem.invoke(index, item)
},
) )
} }
} }

View file

@ -11,7 +11,9 @@ 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.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@ -24,6 +26,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.rememberInt
@Composable @Composable
fun PersonRow( fun PersonRow(
@ -34,6 +37,7 @@ fun PersonRow(
onLongClick: ((Int, Person) -> Unit)? = null, onLongClick: ((Int, Person) -> Unit)? = null,
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
var position by rememberInt()
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
@ -56,12 +60,18 @@ fun PersonRow(
itemsIndexed(people) { index, person -> itemsIndexed(people) { index, person ->
PersonCard( PersonCard(
person = person, person = person,
onClick = { onClick.invoke(person) }, onClick = {
onLongClick = { onLongClick?.invoke(index, person) }, position = index
onClick.invoke(person)
},
onLongClick = {
position = index
onLongClick?.invoke(index, person)
},
modifier = modifier =
Modifier Modifier
.width(personRowCardWidth) .width(personRowCardWidth)
.ifElse(index == 0, Modifier.focusRequester(firstFocus)) .ifElse(index == position, Modifier.focusRequester(firstFocus))
.animateItem(), .animateItem(),
) )
} }

View file

@ -37,7 +37,6 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@Composable @Composable
@ -72,7 +71,6 @@ fun CollectionFolderMovie(
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {

View file

@ -75,7 +75,6 @@ fun CollectionFolderTv(
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {

View file

@ -59,6 +59,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -196,7 +197,7 @@ fun SeriesOverviewContent(
} }
} }
val state = rememberLazyListState(position.episodeRowIndex) val state = rememberLazyListState(position.episodeRowIndex)
var epPosition by rememberInt()
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -204,7 +205,7 @@ fun SeriesOverviewContent(
modifier = modifier =
Modifier Modifier
.focusRestorer(firstItemFocusRequester) .focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester) // .focusRequester(episodeRowFocusRequester)
.onFocusChanged { .onFocusChanged {
cardRowHasFocus = it.hasFocus cardRowHasFocus = it.hasFocus
}, },
@ -230,19 +231,23 @@ fun SeriesOverviewContent(
playPercent = playPercent =
episode?.data?.userData?.playedPercentage episode?.data?.userData?.playedPercentage
?: 0.0, ?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) }, onClick = {
epPosition = episodeIndex
if (episode != null) onClick.invoke(episode)
},
onLongClick = { onLongClick = {
if (episode != null) { epPosition = episodeIndex
onLongClick.invoke( if (episode != null) onLongClick.invoke(episode)
episode,
)
}
}, },
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
episodeIndex == position.episodeRowIndex, episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester), Modifier
.focusRequester(firstItemFocusRequester),
).ifElse(
episodeIndex == epPosition,
Modifier.focusRequester(episodeRowFocusRequester),
).ifElse( ).ifElse(
episodeIndex != position.episodeRowIndex, episodeIndex != position.episodeRowIndex,
Modifier Modifier

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.main
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -24,7 +25,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -61,7 +61,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
@ -70,6 +69,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -209,52 +209,32 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
) { ) {
val context = LocalContext.current var position by rememberPosition()
val scope = rememberCoroutineScope()
val firstRow =
remember {
homeRows
.indexOfFirst {
when (it) {
is HomeRowLoadingState.Error -> false
is HomeRowLoadingState.Loading -> true
is HomeRowLoadingState.Pending -> true
is HomeRowLoadingState.Success -> it.items.isNotEmpty()
}
}.coerceAtLeast(0)
}
var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(firstRow, 0))
}
val focusedItem = val focusedItem =
position.let { position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by rememberSaveable { mutableStateOf(false) } var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) { LaunchedEffect(homeRows) {
if (!firstFocused) { if (!firstFocused) {
// Waiting for the first home row to load, then focus on it if (position.row >= 0) {
homeRows rowFocusRequesters[position.row].tryRequestFocus()
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } firstFocused = true
.takeIf { it >= 0 } } else {
?.let { // Waiting for the first home row to load, then focus on it
rowFocusRequesters[it].tryRequestFocus() homeRows
delay(50) .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
listState.scrollToItem(it) .takeIf { it >= 0 }
firstFocused = true ?.let {
} rowFocusRequesters[it].tryRequestFocus()
} firstFocused = true
} delay(50)
LaunchedEffect(Unit) { listState.scrollToItem(it)
if (firstFocused) { }
// After the first home row was loaded & focused, page recompositions should focus on the positioned row }
val index = position.row
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
delay(50)
listState.scrollToItem(index)
} }
} }
LaunchedEffect(position) { LaunchedEffect(position) {
@ -353,6 +333,7 @@ fun HomePageContent(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.focusGroup()
.focusRequester(rowFocusRequesters[rowIndex]) .focusRequester(rowFocusRequesters[rowIndex])
.animateItem(), .animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->