Android TV 焦点处理详解:遥控器与空鼠

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)    // 遥控器焦点

如果 hoverablefocusable 用了不同的 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
        }
)

注意事项

  • focusRequester modifier 必须写在 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 会根据元素的空间位置计算下一个焦点目标。保持布局规整(网格对齐、等宽等高)可以让自动导航更符合预期。


常见问题排查

元素无法获得焦点

检查清单:

  1. 是否添加了 .focusable(true, interactionSource)
  2. 元素是否可见?alpha(0f)size(0.dp) 的元素无法获得焦点
  3. 是否被父容器的 clipToBounds 裁剪到不可见区域?

焦点跳转不符合预期

Compose 按空间距离计算下一个焦点目标。如果元素位置不规则,焦点可能跳到意料之外的地方。解决方案:

  • 保持网格布局对齐
  • 使用 Modifier.focusProperties 手动指定方向:
kotlin 复制代码
Modifier.focusProperties {
    down = specificFocusRequester  // 按下键时强制跳到指定元素
}

缩放后元素被裁剪

graphicsLayer 的缩放可能超出父容器边界被裁剪。解决方案:

  • 父容器不要设置 clip = true
  • 或者给父容器加 padding 预留缩放空间

requestFocus() 不生效

  • 确保在 LaunchedEffect 中调用,不要在组合阶段直接调用
  • 确保目标元素已经完成组合(可以加 delay(100) 等待)
  • 确保 focusRequester modifier 在 focusable 之前

总结

TV 端焦点处理的核心就三件事:

  1. 让元素可聚焦 --- hoverable + focusable 共享同一个 InteractionSource
  2. 给出视觉反馈 --- 根据 isFocused || isHovered 驱动缩放动画
  3. 处理点击 --- clickable 自动响应遥控器确认键和空鼠点击

掌握这三点,再配合 FocusRequester 处理焦点恢复的场景,就能覆盖 TV 端绝大多数交互需求。

相关推荐
愚者Pro2 小时前
Flutter基础学习
前端·javascript·vue.js
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_17:媒体与 Web Audio API 自动播放指南——策略、检测与最佳实践
前端·笔记·ui·html·音视频·媒体
悠哉清闲2 小时前
裁剪SurfaceView
android
canonical_entropy2 小时前
Nop Chaos Flux:百度AMIS之后的下一代低代码渲染引擎
前端·低代码·ai编程
常利兵2 小时前
Android字体字重设置全攻略:XML黑科技+Kotlin动态实现,告别.ttf臃肿
android·xml·科技
时光足迹2 小时前
Tiptap 简单编辑器模版
前端·javascript·react.js
JSLove3 小时前
nginx入门
前端·nginx
时光足迹3 小时前
ThreeJS之GUI控制器
前端·javascript·three.js