我在 TvLazyColumn 中有 TvLazyRows。当我导航到所有列表的末尾(位置 [20,20])导航到下一个屏幕并返回时,焦点将恢复到第一个可见位置 [15,1],而不是我之前所在的位置 [20,20] ]。 如何将焦点恢复到某个特定位置?
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)
}
}
}
版本
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"
}
我尝试将 FocusRequestor 传递给列表中的每个可聚焦元素。在这种情况下,我能够恢复注意力。但是对于列表中的大量元素,它开始因 OutOfMemmoryError 而崩溃。所以我需要其他解决方案。
在 Jetpack Compose 中,导航在设计上是无状态的,这意味着默认情况下不保留焦点状态。为了实现这一点,我们必须自己维护状态(项目的位置)。
下面是一个建议的解决方案,您可以将其集成到您的代码中。请注意,此解决方案的工作假设是列表中的项目不会动态更改。如果他们这样做,你可能需要稍微调整一下逻辑:
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()
}
为了实现最后一点,我们需要一张
FocusRequester
的地图。我们应该使用一个映射,其中键作为项目位置(rowPos、itemPos),值作为FocusRequester
s。
这是代码的更新部分,用于维护和恢复上次导航项目的焦点。
这是一个两步过程:
FocusRequester
作为值。使用 RememberSaveable 在屏幕导航期间保留值。FocusRequester
并将其添加到 focusRequesters
地图中。您更新的
List
可组合项可能如下所示:
@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。拥有可组合的方法是一个坏主意。可组合项应该是没有副作用的纯函数。
@corvinav 发布了一个很好的方法。但是,如果在请求焦点之前未将
focusRequester
附加到元素,则该方法可能会导致应用程序崩溃。在这里查看更多详细信息:gBug - 276738340
总结@corvinav方法中的问题:
LaunchedEffect
也不能保证该项目已准备好请求焦点。更好的方法是在
navController.popBackStack()
页面上使用 details
调用,这将恢复上一页,并为您保留许多内容,例如跨 TvLazyRow
和 TvLazyColumn
的滚动状态。它当前不做的一件事是将焦点恢复到最后一个聚焦的项目。您可以通过使用焦点请求器来做到这一点,但对 @corvinav 的方法稍作修改。我们可以利用 onGloballyPositioned
修饰符来保证该项目位于视图中并准备好接受焦点。
恢复之前状态的两个关键点:
navController.pop*
方法之一恢复以前的页面。查看 jetpack/compose/navigation 了解更多详情BackHandler
演示:
找到上述演示的相关代码片段:
@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") }
)
}
}
}
}
我在这里有一个不同的方法,它不会阻止列表之外的其他元素获得焦点。
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()
}
})
完整代码片段:
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),
)
}
}