4、焦点与导航:焦点机制详解、D-pad控制、焦点的请求与监听
做TV应用开发,焦点就是一切。你想想看,用户手里拿的是遥控器,不是触屏。没有焦点,用户就不知道自己在操作哪个控件。我刚开始从手机转做TV开发时,就踩过这个坑——界面跑起来了,但遥控器按下去,焦点乱跳,用户根本没法用。
说白了,TV端的交互核心就是焦点导航。今天咱们就把这块彻底讲透。
4.1 焦点机制的核心原理
Android TV的焦点机制,本质上是一个树形遍历的过程。系统会维护一个焦点查找链,当用户按下方向键时,系统从当前焦点控件出发,沿着指定方向寻找最近的、可聚焦的兄弟或子控件。
这里有个关键点:焦点是独占的。同一时刻,整个屏幕上只有一个控件拥有焦点。这个控件会收到焦点的进入和离开事件。
焦点状态的生命周期:
onFocusChange(View v, boolean hasFocus)— 焦点变化时回调onWindowFocusChanged(boolean hasFocus)— 窗口焦点变化isFocused()— 判断当前View是否拥有焦点
我习惯把焦点机制理解成一个"最近邻搜索"问题。系统会计算每个候选控件与当前焦点的距离,选最近的作为下一个焦点。这个距离计算考虑了控件的中心点、边缘重叠度等因素。
4.2 D-pad控制与焦点方向
遥控器的D-pad(方向键)是TV应用最核心的输入方式。每个方向键对应一个焦点查找方向:上、下、左、右。
系统默认的焦点查找算法是FocusFinder类实现的。它会做两件事:
- 排除法:剔除不在目标方向上的控件
- 距离计算:在剩余候选中选最近的
但默认算法不一定符合你的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 请求焦点的时机
最常见的场景是:页面加载完成后,让某个默认控件获得焦点。很多人会直接在onCreate或onResume里调用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开发,一定要在真机上反复测试焦点。模拟器上跑得再好,真机遥控器的手感也不一样。我每次提交代码前,都会拿着遥控器把每个页面从头到尾按一遍,确保没有焦点死胡同。