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
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun <T> ItemRow(
@ -36,9 +42,16 @@ fun <T> ItemRow(
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var position by rememberInt()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
modifier =
modifier.focusProperties {
onEnter = {
focusRequester.tryRequestFocus()
}
},
) {
Text(
text = title,
@ -52,11 +65,13 @@ fun <T> ItemRow(
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(firstFocus),
.focusGroup()
.focusRestorer(firstFocus)
.focusRequester(focusRequester),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
if (index == 0) {
if (index == position) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
@ -65,8 +80,14 @@ fun <T> ItemRow(
index,
item,
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.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.Person
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.rememberInt
@Composable
fun PersonRow(
@ -34,6 +37,7 @@ fun PersonRow(
onLongClick: ((Int, Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
var position by rememberInt()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -56,12 +60,18 @@ fun PersonRow(
itemsIndexed(people) { index, person ->
PersonCard(
person = person,
onClick = { onClick.invoke(person) },
onLongClick = { onLongClick?.invoke(index, person) },
onClick = {
position = index
onClick.invoke(person)
},
onLongClick = {
position = index
onLongClick?.invoke(index, person)
},
modifier =
Modifier
.width(personRowCardWidth)
.ifElse(index == 0, Modifier.focusRequester(firstFocus))
.ifElse(index == position, Modifier.focusRequester(firstFocus))
.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.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
@ -72,7 +71,6 @@ fun CollectionFolderMovie(
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {

View file

@ -75,7 +75,6 @@ fun CollectionFolderTv(
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
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.logTab
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.util.rememberDelayedNestedScroll
import kotlinx.coroutines.launch
@ -196,7 +197,7 @@ fun SeriesOverviewContent(
}
}
val state = rememberLazyListState(position.episodeRowIndex)
var epPosition by rememberInt()
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -204,7 +205,7 @@ fun SeriesOverviewContent(
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester)
// .focusRequester(episodeRowFocusRequester)
.onFocusChanged {
cardRowHasFocus = it.hasFocus
},
@ -230,19 +231,23 @@ fun SeriesOverviewContent(
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onClick = {
epPosition = episodeIndex
if (episode != null) onClick.invoke(episode)
},
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
epPosition = episodeIndex
if (episode != null) onLongClick.invoke(episode)
},
modifier =
Modifier
.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
Modifier
.focusRequester(firstItemFocusRequester),
).ifElse(
episodeIndex == epPosition,
Modifier.focusRequester(episodeRowFocusRequester),
).ifElse(
episodeIndex != position.episodeRowIndex,
Modifier

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.main
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.data.AddPlaylistViewModel
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.PlaylistDialog
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.playback.isPlayKeyUp
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.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
@ -209,52 +209,32 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
) {
val context = LocalContext.current
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))
}
var position by rememberPosition()
val focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
var firstFocused by rememberSaveable { mutableStateOf(false) }
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!firstFocused) {
// Waiting for the first home row to load, then focus on it
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
rowFocusRequesters[it].tryRequestFocus()
delay(50)
listState.scrollToItem(it)
firstFocused = true
}
}
}
LaunchedEffect(Unit) {
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)
if (position.row >= 0) {
rowFocusRequesters[position.row].tryRequestFocus()
firstFocused = true
} else {
// Waiting for the first home row to load, then focus on it
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
rowFocusRequesters[it].tryRequestFocus()
firstFocused = true
delay(50)
listState.scrollToItem(it)
}
}
}
}
LaunchedEffect(position) {
@ -353,6 +333,7 @@ fun HomePageContent(
modifier =
Modifier
.fillMaxWidth()
.focusGroup()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->