Reputation: 117
I have TvLazyRows inside TvLazyColumn. When I navigate to the end of all lists(position [20,20]) navigate to next screen and return back, focus is restored to the first visible position [15,1], not the position where I was before [20,20]. How can I restore focus to some specific position?
class MainActivity : ComponentActivity() {
private val rowItems = (0..20).toList()
private val rows = (0..20).toList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
MyAppNavHost(navController = navController)
}
}
@Composable
fun List(navController: NavController) {
val fr = remember {
FocusRequester()
}
TvLazyColumn( modifier = Modifier
.focusRequester(fr)
.fillMaxSize()
,
verticalArrangement = Arrangement.spacedBy(16.dp),
pivotOffsets = PivotOffsets(parentFraction = 0.05f),
) {
items(rows.size) { rowPos ->
Column() {
Text(text = "Row $rowPos")
TvLazyRow(
modifier = Modifier
.height(70.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
pivotOffsets = PivotOffsets(parentFraction = 0.0f),
) {
items(rowItems.size) { itemPos ->
var color by remember {
mutableStateOf(Color.Green)
}
Box(
Modifier
.width(100.dp)
.height(50.dp)
.onFocusChanged {
color = if (it.hasFocus) {
Color.Red
} else {
Color.Green
}
}
.background(color)
.clickable {
navController.navigate("details")
}
) {
Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
}
}
}
}
}
}
LaunchedEffect(true) {
fr.requestFocus()
}
}
@Composable
fun MyAppNavHost(
navController: NavHostController = rememberNavController(),
startDestination: String = "list"
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable("details") {
Details()
}
composable("list") { List(navController) }
}
}
@Composable
fun Details() {
Box(
Modifier
.background(Color.Blue)
.fillMaxSize()) {
Text("Second Screen", Modifier.align(Alignment.Center), fontSize = 48.sp)
}
}
}
versions
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
// Compose for TV dependencies
def tvCompose = '1.0.0-alpha06'
implementation "androidx.tv:tv-foundation:$tvCompose"
implementation "androidx.tv:tv-material:$tvCompose"
def nav_version = "2.5.3"
implementation "androidx.navigation:navigation-compose:$nav_version"
}
I tried passing FocusRequestor to each focusable element inside list. In that case I was able to restore focus. But For big amount of elements inside list it starts crashing with OutOfMemmoryError. So I need some another solution.
Upvotes: 8
Views: 4509
Reputation: 2282
I have a different approach here , which does not prevent other elements outside of the list to get focus.
var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }
GridItem(channel,
onClick = {
//on click logic
//Store the focused element id when clicked
lastFocusedChannel = channel.hashCode()
}, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {
// onFocusChanged Listener to Reset stored value after accuiring focus
if (it.hasFocus) lastFocusedChannel = null
}
}
//...
// In each Item
// Apply the modifier
val focusRequester = remember { FocusRequester() }
Card(
onClick = { onClick.invoke(channel) },
modifier = Modifier
.size(channelItemSize)
.onFocusChanged(onFocusChanged)
.focusRequester(focusRequester)
.onGloballyPositioned {
if(hadFocusBeforeNavigation) focusRequester.requestFocus()
}
})
Complete Code Snippet:
package dev.khaled.leanstream.channels
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BrokenImage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.Border
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import coil.compose.AsyncImage
import dev.khaled.leanstream.conditional
val channelItemSize = 128.dp
val isTouchScreen = false //TODO
@Composable
fun ChannelsGrid(items: List<Channel>, onClick: (channel: Channel) -> Unit) {
var lastFocusedChannel by rememberSaveable { mutableStateOf<Int?>(null) }
TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(channelItemSize),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
) {
items(items) { channel ->
GridItem(channel, onClick = {
onClick(it)
lastFocusedChannel = channel.hashCode()
}, hadFocusBeforeNavigation = lastFocusedChannel == channel.hashCode()) {
if (it.hasFocus) lastFocusedChannel = null
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun GridItem(
channel: Channel,
onClick: (channel: Channel) -> Unit,
hadFocusBeforeNavigation: Boolean,
onFocusChanged: (FocusState) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
Card(
onClick = { onClick.invoke(channel) },
modifier = Modifier
.size(channelItemSize)
.onFocusChanged(onFocusChanged)
.focusRequester(focusRequester)
.conditional(hadFocusBeforeNavigation) {
onGloballyPositioned {
focusRequester.requestFocus()
}
}
.conditional(isTouchScreen) {
clickable { onClick.invoke(channel) }
clip(RoundedCornerShape(16))
},
border = CardDefaults.border(focusedBorder = Border(BorderStroke(2.dp, Color.Black))),
) {
AsyncImage(
model = channel.icon,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
error = rememberVectorPainter(Icons.Rounded.BrokenImage),
)
}
}
Upvotes: 1
Reputation: 5416
@corvinav has posted a great approach. However, that approach has a possibility of crashing the app if the focusRequester
is not attached to the element before requesting focus on it. Check out more details here: gBug - 276738340
Summarizing the problems in @corvinav's approach:
LaunchedEffect
doesn't guarantee that the item will be ready to request focus.A better way would be to make use of navController.popBackStack()
call on the details
page which will restore the previous page and have many of the things preserved for you like scroll state across TvLazyRow
s and TvLazyColumn
s. One thing that it currently doesn't do is restore the focus the last focused item. You can do that by making use of focus requester, but with a slight modification to @corvinav's approach. We can make use of onGloballyPositioned
modifier to guarantee that the item is in view and ready to accept focus.
Two key points to restore the previous state:
navController.pop*
methods to restore previous pages. Checkout jetpack/compose/navigation for more detailsBackHandler
as wellDemo:
Find the relevant code snippet for the above demo:
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun App() {
val navController = rememberNavController()
val lastFocusedItemId = remember { mutableStateOf<String?>(null) }
// To avoid requesting focus on the item more than once
val itemAlreadyFocused = remember { mutableStateOf(false) }
NavHost(navController = navController, startDestination = "home") {
composable("home") {
LaunchedEffect(Unit) {
// reset the value when home is launched
itemAlreadyFocused.value = false
}
HomePage(
navController = navController,
lastFocusedItemId = lastFocusedItemId,
itemAlreadyFocused = itemAlreadyFocused,
)
}
composable("movie") {
BackHandler {
navController.popBackStack()
}
Button(onClick = { navController.popBackStack() }) {
Text("Home")
}
}
}
}
@Composable
fun HomePage(
navController: NavController,
lastFocusedItemId: MutableState<String?>,
itemAlreadyFocused: MutableState<Boolean>,
) {
TvLazyColumn(Modifier.fillMaxSize()) {
// `rows` could be coming from your view model
itemsIndexed(rows) { index, rowData ->
MyRow(
rowData = rowData,
navController = navController,
lastFocusedItemId = lastFocusedItemId,
itemAlreadyFocused = itemAlreadyFocused,
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MyRow(
rowData: List<MyItem>,
navController: NavController,
lastFocusedItemId: MutableState<String?>,
itemAlreadyFocused: MutableState<Boolean>,
) {
TvLazyRow(
horizontalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.focusRestorer()
) {
items(myItems) { item ->
key(item.id) {
val focusRequester = remember { FocusRequester() }
Card(
modifier = Modifier
.focusRequester(focusRequester)
.onGloballyPositioned {
if (
lastFocusedItemId.value == item.id &&
!itemAlreadyFocused.value
) {
focusRequester.requestFocus()
}
}
.onFocusChanged {
if (it.isFocused) {
lastFocusedItemId.value = item.id
itemAlreadyFocused.value = true
}
},
onClick = { navController.navigate("movie") }
)
}
}
}
}
Upvotes: 4
Reputation: 86
In Jetpack Compose, navigation is stateless by design, which means the focus state is not preserved by default. To achieve this, we have to maintain the state (the item's position) ourselves.
Below is a proposed solution that you can integrate into your code. Note that this solution works with the assumption that your list's items aren't dynamically changed. If they do, you may have to tweak the logic a bit:
private var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
lastFocusedItem
..onFocusChanged { focusState ->
if (focusState.hasFocus) {
lastFocusedItem = Pair(rowPos, itemPos)
}
...
}
LaunchedEffect(true) {
// Request focus to the last focused item
focusRequesters[lastFocusedItem]?.requestFocus()
}
To achieve the last point, we need to have a map of FocusRequester
s. We should use a map with keys as item positions (rowPos, itemPos) and values as FocusRequester
s.
Here's the updated portion of your code that maintains and restores the focus of the last navigated item.
This is a two-step process:
FocusRequester
as values. Use rememberSaveable to keep value during screen navigation.FocusRequester
for each item and add it to the focusRequesters
map.Your updated List
composable might look something like this:
@Composable
fun List(navController: NavController) {
val focusRequesters = remember { mutableMapOf<Pair<Int, Int>, FocusRequester>() }
var lastFocusedItem by rememberSaveable{ mutableStateOf(Pair(0, 0)) }
TvLazyColumn(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
pivotOffsets = PivotOffsets(parentFraction = 0.05f),
) {
items(rows.size) { rowPos ->
Column() {
Text(text = "Row $rowPos")
TvLazyRow(
modifier = Modifier
.height(70.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
pivotOffsets = PivotOffsets(parentFraction = 0.0f),
) {
items(rowItems.size) { itemPos ->
var color by remember { mutableStateOf(Color.Green) }
val fr = remember { FocusRequester() }
focusRequesters[Pair(rowPos, itemPos)] = fr
Box(
Modifier
.width(100.dp)
.height(50.dp)
.focusRequester(fr)
.onFocusChanged {
color = if (it.hasFocus) {
lastFocusedItem = Pair(rowPos, itemPos)
Color.Red
} else {
Color.Green
}
}
.background(color)
.clickable {
navController.navigate("details")
}
) {
Text(text = "Item ${itemPos.toString()}", Modifier.align(Alignment.Center))
}
}
}
}
}
}
LaunchedEffect(true) {
focusRequesters[lastFocusedItem]?.requestFocus()
}
}
PS. to have composable methods is a bad idea. Composables should be pure functions without side effects.
Upvotes: 7