Android Jetpack Compose 电视焦点恢复

5

我有一个TvLazyColumn里面包含着TvLazyRows。当我浏览到所有列表的末尾(位置[20,20])并导航到下一个屏幕然后返回时,焦点被恢复到第一个可见位置[15,1],而不是之前的位置[20,20]。 如何将焦点恢复到特定的位置?

enter image description here

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传递给列表中的每个可聚焦元素。在这种情况下,我能够恢复焦点。但是对于列表中大量的元素,它会开始崩溃并出现OutOfMemoryError。因此,我需要另一种解决方案。

2个回答

4
在Jetpack Compose中,导航是有意设计为无状态的,这意味着默认情况下不会保留焦点状态。为了实现这一点,我们必须自己维护状态(项目位置)。
以下是一个建议的解决方案,您可以将其集成到您的代码中。请注意,此解决方案适用于假设您列表的项目不是动态更改的情况。如果它们发生了变化,您可能需要稍微调整一下逻辑:
  1. 您需要维护最后一个聚焦的项目状态。
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为值的映射表。
下面是更新后的代码部分,用于保持和恢复最后一个导航项的焦点。
这是一个两步过程:
  1. 创建一个可变状态映射表,将(rowPos,itemPos)对作为键,将它们对应的FocusRequester作为值。使用rememberSaveable在屏幕导航期间保持值。
  2. 记住每个项目的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()
    }
}

顺便说一句,拥有可组合的方法是个坏主意。组合应该是没有副作用的纯函数。


1
虽然很难对这个问题给出一个正确的答案,但在这里提出的方法有几个潜在的问题。首先,在将此可组合项添加到组合中时请求焦点可能会在许多情况下引起不可预测的焦点更改。还要注意的是,在返回时最后聚焦的项目很可能尚未组成,如果尝试调用匹配的FocusRequester上的requestFocus,则会导致崩溃。 - Remco
@Remco Shure。使用requestFocus需要小心。但在这种情况下,TvLazyRow正在恢复滚动位置。而且此代码(LaunchedEffect(true))仅在合成完成后运行。因此,所有可见项目都已创建。此外,空值检查将有助于避免崩溃。我们仅在添加项目后将FocusRequesters添加到focusRequesters映射中。 - corvinav
LaunchedEffect(true)不能保证在运行之前项目就会在视图中。更好的方法是使用onGloballyPositionChangedonPlaced修饰符来验证项目是否在视图中,然后请求对其进行焦点操作。参考票号:gBug - 276738340 - vighnesh153
LaunchedEffect(true)不能保证在运行之前项目会在视图中。更好的方法是使用onGloballyPositionChangedonPlaced修饰符来验证项目是否在视图中,然后请求对其进行焦点设置。参考工单:gBug - 276738340 - undefined
1
我们绝对需要一份良好的官方文件来管理电视焦点。 - undefined
有一个链接:https://developer.android.com/jetpack/compose/touch-input/focus/ 它可以提供相关信息。 - undefined

2
@corvinav发表了一个很好的方法。然而,如果在请求焦点之前未将focusRequester附加到元素上,该方法可能会导致应用程序崩溃。在这里查看更多详细信息:gBug - 276738340 总结一下@corvinav方法中的问题:
- 如果用户点击需要滚动的行或列中的项目,则此方法将无法工作,因为该项目不会显示在视图中以请求焦点。 - 即使项目的索引在视图中,LaunchedEffect也不能保证该项目准备好请求焦点。 - 所有的焦点请求者都存储在一个映射中。如果行/列被修改,则基于索引获取焦点请求者的方法可能无法正常工作。
更好的方法是在“details”页面上使用navController.popBackStack()调用,这将恢复先前的页面,并为您保留许多内容,例如跨TvLazyRowTvLazyColumn的滚动状态。目前它还不能恢复焦点到上次聚焦的项目。您可以通过使用焦点请求器来实现,但需要对@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") }
                )
            }
        }
    }
}

嗨!如果在“composable(“movie”)”屏幕内有另一个列表,并且还要导航到另一个屏幕,我应该实现类似于堆栈ADT的“lastFocusedItemId”吗? - Firate
现在一切都好了!我意识到将变量放在视图模型中要好得多。 - Firate

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接