4、焦点与导航:焦点机制详解、D-pad控制、焦点的请求与监听

做TV应用开发,焦点就是一切。你想想看,用户手里拿的是遥控器,不是触屏。没有焦点,用户就不知道自己在操作哪个控件。我刚开始从手机转做TV开发时,就踩过这个坑——界面跑起来了,但遥控器按下去,焦点乱跳,用户根本没法用。

说白了,TV端的交互核心就是焦点导航。今天咱们就把这块彻底讲透。

4.1 焦点机制的核心原理

Android TV的焦点机制,本质上是一个树形遍历的过程。系统会维护一个焦点查找链,当用户按下方向键时,系统从当前焦点控件出发,沿着指定方向寻找最近的、可聚焦的兄弟或子控件。

这里有个关键点:焦点是独占的。同一时刻,整个屏幕上只有一个控件拥有焦点。这个控件会收到焦点的进入和离开事件。

焦点状态的生命周期:

  • onFocusChange(View v, boolean hasFocus) — 焦点变化时回调
  • onWindowFocusChanged(boolean hasFocus) — 窗口焦点变化
  • isFocused() — 判断当前View是否拥有焦点

我习惯把焦点机制理解成一个"最近邻搜索"问题。系统会计算每个候选控件与当前焦点的距离,选最近的作为下一个焦点。这个距离计算考虑了控件的中心点、边缘重叠度等因素。

Android TV 焦点导航核心流程 用户按下方向键 系统查找当前焦点View 沿方向计算候选View距离 选中最近的可聚焦View 触发焦点变化回调

4.2 D-pad控制与焦点方向

遥控器的D-pad(方向键)是TV应用最核心的输入方式。每个方向键对应一个焦点查找方向:上、下、左、右。

系统默认的焦点查找算法是FocusFinder类实现的。它会做两件事:

  1. 排除法:剔除不在目标方向上的控件
  2. 距离计算:在剩余候选中选最近的

但默认算法不一定符合你的UI布局。举个例子,我做过一个视频详情页,左边是海报,右边是简介和按钮。默认焦点从海报往右跳,可能会跳到简介区域,但用户期望的是跳到"播放"按钮上。

这时候就需要手动干预了。

4.2.1 使用 nextFocus 属性

Android提供了四个XML属性,可以显式指定某个方向的下一个焦点:

属性 说明 示例值
android:nextFocusUp 按上键时跳转到指定View @id/btn_play
android:nextFocusDown 按下键时跳转到指定View @id/btn_detail
android:nextFocusLeft 按左键时跳转到指定View @id/img_poster
android:nextFocusRight 按右键时跳转到指定View @id/btn_play

我的习惯:在复杂布局中,我会给每个可聚焦控件都显式设置四个方向的nextFocus。虽然代码量大了点,但焦点导航的稳定性是最好的。用户不会遇到"按了没反应"的情况。

4.2.2 代码中动态控制焦点

有时候焦点逻辑需要根据运行时状态变化。比如,某个按钮在特定条件下才可聚焦。这时候用代码控制更灵活:

// 请求焦点
view.requestFocus();

// 判断是否可聚焦
view.setFocusable(true);
view.setFocusableInTouchMode(true); // TV上通常不需要,但保留兼容

// 清除焦点
view.clearFocus();

// 查找下一个焦点
View next = FocusFinder.getInstance().findNextFocus(
    rootView, currentView, View.FOCUS_DOWN);

我曾经在项目中遇到一个问题:弹出一个对话框后,焦点没有自动落到"确定"按钮上。用户按遥控器没反应,体验很差。解决方案就是在对话框显示后,手动调用btnConfirm.requestFocus()

4.3 焦点的监听与回调

监听焦点变化,最常用的就是OnFocusChangeListener。但这里有个细节很多人会忽略:焦点变化是异步的

什么意思?就是你调用requestFocus()后,不会立即触发onFocusChange回调。它会在下一个布局遍历中才执行。所以不要在requestFocus()后面立即做焦点相关的判断。

4.3.1 基础监听写法

button.setOnFocusChangeListener(new View.OnFocusChangeListener() {
    @Override
    public void onFocusChange(View v, boolean hasFocus) {
        if (hasFocus) {
            // 获得焦点:放大、高亮、播放音效
            v.animate().scaleX(1.1f).scaleY(1.1f).start();
            v.setBackgroundColor(getResources().getColor(R.color.focused));
        } else {
            // 失去焦点:恢复原样
            v.animate().scaleX(1.0f).scaleY(1.0f).start();
            v.setBackgroundColor(getResources().getColor(R.color.normal));
        }
    }
});

注意:不要在onFocusChange里做耗时操作。焦点变化可能很频繁,用户快速按方向键时,这个方法会被反复调用。动画、颜色变化这些轻量操作没问题,但网络请求、数据库读写就别放这里了。

4.3.2 焦点链的调试技巧

焦点出问题时,肉眼很难看出原因。我推荐用这两个方法调试:

  • 开启开发者选项中的"显示焦点边界":在系统设置里打开后,每个可聚焦控件会显示一个矩形框,焦点移动时一目了然。
  • 打印焦点树:写一个工具方法,递归遍历View树,打印每个View的焦点状态。
private void dumpFocusTree(ViewGroup root) {
    for (int i = 0; i < root.getChildCount(); i++) {
        View child = root.getChildAt(i);
        if (child instanceof ViewGroup) {
            dumpFocusTree((ViewGroup) child);
        } else {
            Log.d("FocusDebug", child.getId() + " focusable=" 
                + child.isFocusable() + " focused=" + child.isFocused());
        }
    }
}

这个函数我几乎每个TV项目都会写一个。遇到焦点乱跳的问题,跑一遍日志,马上就能看出哪个控件不该聚焦却聚焦了。

4.4 焦点请求的最佳实践

请求焦点看似简单,但有几个坑要注意。

4.4.1 请求焦点的时机

最常见的场景是:页面加载完成后,让某个默认控件获得焦点。很多人会直接在onCreateonResume里调用requestFocus()。但这时候布局可能还没完成测量,调用会失败。

我建议这样做:

// 方式一:使用post
view.post(() -> view.requestFocus());

// 方式二:使用OnPreDrawListener
view.getViewTreeObserver().addOnPreDrawListener(
    new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            view.getViewTreeObserver().removeOnPreDrawListener(this);
            view.requestFocus();
            return true;
        }
    });

方式一更简洁,我一般都用它。但如果你需要确保焦点请求发生在布局绘制之前,就用方式二。

4.4.2 焦点请求的优先级

当一个父容器和它的子View都可聚焦时,系统会优先让子View获得焦点。这个行为是递归的——系统会一直往下找,直到找到最内层的可聚焦子View。

如果你想让父容器获得焦点而不是子View,可以设置:

parentView.setDescendantFocusability(
    ViewGroup.FOCUS_BEFORE_DESCENDANTS);

这个属性的取值有三种:

常量 行为
FOCUS_BEFORE_DESCENDANTS 父容器优先获得焦点
FOCUS_AFTER_DESCENDANTS 子View优先获得焦点(默认)
FOCUS_BLOCK_DESCENDANTS 子View不可聚焦,只有父容器可聚焦

避坑指南:我曾经在RecyclerView的item里嵌套了多个可聚焦控件,结果焦点总是在item内部循环,跳不出item。后来发现是RecyclerView的descendantFocusability设置成了FOCUS_AFTER_DESCENDANTS,导致item本身无法获得焦点。改成FOCUS_BEFORE_DESCENDANTS后问题解决。

4.5 实战:构建一个焦点可控的TV界面

说了这么多理论,咱们来写一个实际的例子。假设我们要做一个电影列表页,横向滚动,每个卡片可以聚焦并显示详情。

public class MovieItemView extends FrameLayout {

    private ImageView poster;
    private TextView title;
    private boolean isFocused;

    public MovieItemView(Context context) {
        super(context);
        init();
    }

    private void init() {
        // 设置可聚焦
        setFocusable(true);
        setFocusableInTouchMode(true);

        // 设置焦点变化监听
        setOnFocusChangeListener((v, hasFocus) -> {
            if (hasFocus) {
                // 聚焦时放大并显示阴影
                animate().scaleX(1.08f).scaleY(1.08f)
                    .setDuration(150).start();
                setElevation(8f);
                // 通知父容器滚动到可见位置
                requestRectangleOnScreen(
                    new Rect(0, 0, getWidth(), getHeight()));
            } else {
                animate().scaleX(1.0f).scaleY(1.0f)
                    .setDuration(150).start();
                setElevation(0f);
            }
        });

        // 设置默认的焦点方向
        setNextFocusRightId(R.id.next_movie);
        setNextFocusLeftId(R.id.prev_movie);
    }
}

这个例子中,每个卡片聚焦时会有放大和阴影效果,同时会请求父容器滚动,确保卡片完整可见。这就是一个典型的TV卡片交互。

嗯,焦点这块内容其实挺多的,但核心就三点:理解系统怎么找焦点、学会手动控制焦点方向、掌握焦点变化的监听时机。把这三点吃透了,TV应用的导航体验就不会差。

最后说一句,做TV开发,一定要在真机上反复测试焦点。模拟器上跑得再好,真机遥控器的手感也不一样。我每次提交代码前,都会拿着遥控器把每个页面从头到尾按一遍,确保没有焦点死胡同。