Android TV 焦点处理详解:遥控器与空鼠
Jetpack Compose 实现电视端焦点交互的完整方案
为什么电视端焦点处理很重要
电视没有触摸屏,用户有两种输入方式:
- 遥控器(D-pad):通过上下左右方向键在元素间移动焦点,确认键触发点击
- 空鼠(Air Mouse):像鼠标一样移动光标,悬停在元素上产生 hover 状态,点击触发操作
两种输入方式需要统一的视觉反馈------用户无论用哪种方式操作,选中的元素都应该有一致的高亮效果。
核心概念
焦点(Focus)vs 悬停(Hover)
| 状态 | 触发方式 | 对应 API |
|---|---|---|
| Focus | 遥控器方向键导航到该元素 | collectIsFocusedAsState() |
| Hover | 空鼠光标移动到该元素上方 | collectIsHoveredAsState() |
两者在视觉上应该表现一致(都是"选中"状态),所以代码中通常合并处理:
kotlin
val isHighlighted = isFocused || isHovered
InteractionSource:状态的桥梁
MutableInteractionSource 是连接 UI 元素和状态观察的核心。它同时承载焦点事件和悬停事件,所有相关 modifier 必须共享同一个实例:
kotlin
val interactionSource = remember { MutableInteractionSource() }
// 从同一个 source 读取两种状态
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
// 同一个 source 传给两个 modifier
Modifier
.hoverable(interactionSource) // 空鼠悬停
.focusable(true, interactionSource) // 遥控器焦点
如果 hoverable 和 focusable 用了不同的 interactionSource,状态就会割裂,无法统一响应。
实现步骤
第一步:创建 InteractionSource 并观察状态
kotlin
@Composable
fun TvCard(onClick: () -> Unit) {
// 1. 创建交互源
val interactionSource = remember { MutableInteractionSource() }
// 2. 观察焦点状态(遥控器方向键选中时为 true)
val isFocused by interactionSource.collectIsFocusedAsState()
// 3. 观察悬停状态(空鼠光标悬停时为 true)
val isHovered by interactionSource.collectIsHoveredAsState()
// 4. 合并为统一的"高亮"状态
val isHighlighted = isFocused || isHovered
}
第二步:根据状态驱动视觉反馈
TV 端最常用的反馈是缩放动画,比手机端的水波纹更适合远距离观看:
kotlin
// 选中时放大到 1.1 倍,未选中时恢复原始大小
val scale by animateFloatAsState(
targetValue = if (isHighlighted) 1.1f else 1f,
animationSpec = tween(150, easing = FastOutSlowInEasing),
label = "cardScale"
)
为什么用缩放而不是边框或颜色变化?
- 电视观看距离远(2-3米),细微的边框变化不容易察觉
- 缩放效果直观醒目,用户一眼就能看到当前选中了哪个元素
- 推荐缩放比例:1.05x ~ 1.1x,太大会遮挡相邻元素
第三步:组装 Modifier 链
kotlin
Box(
modifier = Modifier
// 布局相关
.size(200.dp, 120.dp)
// 缩放动画(用 graphicsLayer 不影响布局)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
// 形状裁剪
.clip(RoundedCornerShape(16.dp))
// 空鼠悬停检测
.hoverable(interactionSource)
// 遥控器焦点检测
.focusable(true, interactionSource)
// 点击处理(同时响应遥控器确认键和空鼠点击)
.clickable(
interactionSource = interactionSource,
indication = null // 禁用默认水波纹
) { onClick() }
)
完整示例
kotlin
@Composable
fun TvCard(
title: String,
iconRes: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val isHighlighted = isFocused || isHovered
val scale by animateFloatAsState(
targetValue = if (isHighlighted) 1.1f else 1f,
animationSpec = tween(150, easing = FastOutSlowInEasing),
label = "cardScale"
)
Box(
modifier = modifier
.aspectRatio(214f / 125f)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(16.dp))
.hoverable(interactionSource)
.focusable(true, interactionSource)
.clickable(
interactionSource = interactionSource,
indication = null
) { onClick() }
) {
// 背景图
AsyncImage(
model = iconRes,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// 标题文字
Text(
text = title,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.align(Alignment.CenterStart).padding(14.dp)
)
}
}
封装为可复用的 Modifier
项目中多个页面都需要焦点处理,可以封装为扩展函数:
kotlin
@Composable
fun Modifier.tvFocusable(
scale: Float = 1.1f,
animDuration: Int = 150,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onClick: (() -> Unit)? = null
): Modifier {
val isFocused by interactionSource.collectIsFocusedAsState()
val isHovered by interactionSource.collectIsHoveredAsState()
val animatedScale by animateFloatAsState(
targetValue = if (isFocused || isHovered) scale else 1f,
animationSpec = tween(animDuration, easing = FastOutSlowInEasing),
label = "tvScale"
)
var result = this
.graphicsLayer {
scaleX = animatedScale
scaleY = animatedScale
}
.hoverable(interactionSource)
.focusable(true, interactionSource)
if (onClick != null) {
result = result.clickable(
interactionSource = interactionSource,
indication = null
) { onClick() }
}
return result
}
使用时一行搞定:
kotlin
Box(
modifier = Modifier
.size(200.dp, 120.dp)
.clip(RoundedCornerShape(16.dp))
.tvFocusable(scale = 1.08f) { /* 点击事件 */ }
)
程序化焦点控制:FocusRequester
有时需要代码主动把焦点移到某个元素上,比如页面打开时默认选中第一个卡片。
基本用法
kotlin
@Composable
fun MyScreen() {
val focusRequester = remember { FocusRequester() }
// 页面加载后自动聚焦
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(
modifier = Modifier
.focusRequester(focusRequester) // 绑定
.hoverable(interactionSource)
.focusable(true, interactionSource)
)
}
焦点恢复
场景:弹窗关闭后,焦点应该回到触发弹窗的那个元素。
kotlin
val cardFocusRequester = remember { FocusRequester() }
var showDialog by remember { mutableStateOf(false) }
// 弹窗关闭后恢复焦点
LaunchedEffect(showDialog) {
if (!showDialog) {
cardFocusRequester.requestFocus()
}
}
// 卡片
Box(
modifier = Modifier
.focusRequester(cardFocusRequester)
.hoverable(interactionSource)
.focusable(true, interactionSource)
.clickable(interactionSource = interactionSource, indication = null) {
showDialog = true
}
)
注意事项
focusRequestermodifier 必须写在focusable之前requestFocus()不能在组合阶段直接调用,必须放在LaunchedEffect或事件回调中- 目标元素必须已经完成组合(在屏幕上可见),否则
requestFocus()无效
Modifier 顺序详解
顺序错误是 TV 焦点开发中最常见的 bug 来源:
kotlin
Modifier
.focusRequester(requester) // ① 最先:绑定焦点请求器
.graphicsLayer { ... } // ② 视觉变换(不影响布局和触摸区域)
.clip(shape) // ③ 裁剪形状
.hoverable(interactionSource) // ④ 悬停检测
.focusable(true, interactionSource) // ⑤ 焦点检测
.clickable(...) // ⑥ 最后:点击处理
为什么 graphicsLayer 要在 focusable 前面?
graphicsLayer 的缩放是纯视觉效果,不改变元素的实际布局大小和触摸/焦点区域。如果放在后面,缩放会影响焦点区域的计算。
为什么 clickable 要在最后?
clickable 内部也会添加焦点处理。放在 focusable 后面,确保我们自己的 interactionSource 优先接管焦点状态。
监听焦点变化
当需要在焦点变化时执行逻辑(如加载数据、切换预览图):
kotlin
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
// 焦点变化时执行副作用
LaunchedEffect(isFocused) {
if (isFocused) {
// 元素获得焦点时的逻辑
// 例如:预加载大图、上报埋点、切换背景
}
}
遥控器按键与焦点的关系
| 按键 | 行为 |
|---|---|
| 上/下/左/右 | 系统自动将焦点移到对应方向最近的 focusable 元素 |
| 确认键(OK/Enter/DPAD_CENTER) | 触发当前焦点元素的 clickable 回调 |
| 返回键 | 触发 BackHandler,与焦点无关 |
方向键的焦点移动是系统自动处理的,Compose 会根据元素的空间位置计算下一个焦点目标。保持布局规整(网格对齐、等宽等高)可以让自动导航更符合预期。
常见问题排查
元素无法获得焦点
检查清单:
- 是否添加了
.focusable(true, interactionSource)? - 元素是否可见?
alpha(0f)或size(0.dp)的元素无法获得焦点 - 是否被父容器的
clipToBounds裁剪到不可见区域?
焦点跳转不符合预期
Compose 按空间距离计算下一个焦点目标。如果元素位置不规则,焦点可能跳到意料之外的地方。解决方案:
- 保持网格布局对齐
- 使用
Modifier.focusProperties手动指定方向:
kotlin
Modifier.focusProperties {
down = specificFocusRequester // 按下键时强制跳到指定元素
}
缩放后元素被裁剪
graphicsLayer 的缩放可能超出父容器边界被裁剪。解决方案:
- 父容器不要设置
clip = true - 或者给父容器加
padding预留缩放空间
requestFocus() 不生效
- 确保在
LaunchedEffect中调用,不要在组合阶段直接调用 - 确保目标元素已经完成组合(可以加
delay(100)等待) - 确保
focusRequestermodifier 在focusable之前
总结
TV 端焦点处理的核心就三件事:
- 让元素可聚焦 ---
hoverable+focusable共享同一个InteractionSource - 给出视觉反馈 --- 根据
isFocused || isHovered驱动缩放动画 - 处理点击 ---
clickable自动响应遥控器确认键和空鼠点击
掌握这三点,再配合 FocusRequester 处理焦点恢复的场景,就能覆盖 TV 端绝大多数交互需求。