You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android TV应用中TVLazyRow/LazyRow与可聚焦按钮的焦点管理及恢复

Jetpack Compose Android TV 焦点导航与状态恢复问题解答

1. Android TV 应选用 TVLazyRow 还是普通 LazyRow?

直接选用TVLazyRow,原因如下:

  • TVLazyRow 是 Compose TV 库专为电视设备优化的组件,内置了D-pad导航的核心适配逻辑:
    • 自动处理焦点横向滚动时的保持,当焦点移动到行边缘时会自动滚动列表,确保聚焦项始终处于可视区域
    • 针对TV的焦点移动速度、滚动灵敏度做了专属优化,避免普通LazyRow在D-pad操作时出现的焦点跳变或滚动卡顿问题
    • 支持焦点预加载,提前渲染即将聚焦的项,提升导航流畅度
  • 普通LazyRow为手机场景设计,无TV专属导航适配,手动处理焦点和滚动会增加大量冗余代码,体验远不如TVLazyRow。

2. 返回页面时如何保存并恢复焦点?

核心思路是持久化焦点标识而非直接保存FocusRequester(FocusRequester无法序列化),具体步骤:

  1. 定义专属标识类型,区分不同可聚焦组件(比如行内卡片的索引、下方按钮的唯一标识)
  2. 使用rememberSaveable保存最后聚焦的组件标识,确保页面跳转返回时状态不丢失
  3. 组件初始化时,根据保存的标识找到对应的FocusRequester,通过LaunchedEffect发起焦点请求
  4. 在每个可聚焦组件的onFocusChanged回调中更新保存的焦点标识

3. Jetpack Compose TV 焦点管理最佳实践

  • 优先使用TV专属组件:TVLazyRow/TVLazyColumn、TvMaterial3库中的Button/Card等,这些组件自带TV优化的焦点样式和导航逻辑
  • 用rememberSaveable持久化焦点状态:避免使用普通remember,页面跳转返回时组件会重建,remember的状态会丢失
  • 减少手动焦点链干预:除非特殊场景,尽量依赖Compose系统自动处理焦点移动逻辑,TV组件已内置合理的焦点导航规则
  • 聚焦项可视性保障:使用TVLazyRow/Column时,无需手动处理滚动,组件会自动将聚焦项滚动到可视区域
  • 避免内存泄漏:使用DisposableEffect清理焦点相关临时资源(如不需要的PinnedHandle)

优化后的示例代码

import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.tv.foundation.lazy.list.TVLazyColumn
import androidx.tv.foundation.lazy.list.TVLazyRow
import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import kotlinx.serialization.Serializable

// 定义焦点标识类型,区分行内卡片和下方按钮
sealed class FocusTarget {
    data class RowItem(val index: Int) : FocusTarget()
    object NextScreenButton : FocusTarget()
}

@Composable
fun NavGraph() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Destination.HomeDestination
    ) {
        composable<Destination.HomeDestination> {
            HomeScreen(navController = navController)
        }
        composable<Destination.DetailDestination> {
            DetailScreen(navController = navController)
        }
    }
}

sealed interface Destination {
    @Serializable
    data object HomeDestination : Destination

    @Serializable
    data object DetailDestination : Destination
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(navController: NavController) {
    // 使用rememberSaveable持久化最后聚焦的目标,跳转返回不丢失
    var lastFocusedTarget by rememberSaveable { mutableStateOf<FocusTarget?>(null) }
    // 存储行内每个item的FocusRequester
    val rowItemFocusRequesters = remember { List(10) { FocusRequester() } }
    // 下方按钮的FocusRequester
    val buttonFocusRequester = remember { FocusRequester() }

    TVLazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = androidx.tv.foundation.lazy.list.VerticalArrangement.spacedBy(24.dp)
    ) {
        item {
            TVLazyRow(
                horizontalArrangement = androidx.tv.foundation.lazy.list.HorizontalArrangement.spacedBy(12.dp)
            ) {
                items(10) { index ->
                    val focusRequester = rowItemFocusRequesters[index]
                    FocusableBox(
                        modifier = Modifier
                            .focusRequester(focusRequester)
                            .onFocusChanged {
                                if (it.isFocused) {
                                    lastFocusedTarget = FocusTarget.RowItem(index)
                                }
                            }
                    )
                }
            }
        }
        item {
            Button(
                onClick = { navController.navigate(Destination.DetailDestination) },
                modifier = Modifier
                    .focusRequester(buttonFocusRequester)
                    .onFocusChanged {
                        if (it.isFocused) {
                            lastFocusedTarget = FocusTarget.NextScreenButton
                        }
                    }
            ) {
                Text("跳转到详情页")
            }
        }
    }

    // 页面重组时,根据保存的焦点标识恢复焦点
    LaunchedEffect(lastFocusedTarget) {
        when (lastFocusedTarget) {
            is FocusTarget.RowItem -> {
                val index = (lastFocusedTarget as FocusTarget.RowItem).index
                rowItemFocusRequesters[index].requestFocus()
            }
            FocusTarget.NextScreenButton -> buttonFocusRequester.requestFocus()
            null -> {
                // 首次进入默认聚焦第一个行内item
                rowItemFocusRequesters.first().requestFocus()
            }
        }
    }
}

@Composable
fun DetailScreen(navController: NavController) {
    Button(onClick = { navController.navigateUp() }) {
        Text("返回首页")
    }
}

@Composable
private fun FocusableBox(
    modifier: Modifier = Modifier,
    content: @Composable BoxScope.() -> Unit = {},
) {
    var borderColor by remember { mutableStateOf(Color.Black) }
    Box(
        modifier = modifier
            .size(100.dp)
            .padding(2.dp)
            .onFocusChanged { borderColor = if (it.isFocused) Color.Red else Color.Black }
            .border(2.dp, borderColor)
            .focusable(),
        content = content
    )
}

内容的提问来源于stack exchange,提问作者Meet

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 01:27:22