一、焦点获取

首先,TV端的开发和我们手机端开发最大的区别就在于TV端存在焦点的概念。

如下图:

Android TV开发总结【焦点】-编程知识网

可想而知,手机端我们直接通过点击\长按某个区域处理响应事件处,但是TV端只能通过遥控器的上下左右来操控焦点,从而选中特定的区域处理相应事件。

在TV开发中没有以前我手机端的dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 事件来分发,而需要使用dispatchKeyEvent、onKeyDown、onKeyLisenter 等事件来分发处理焦点事件传递。

然而TV端焦点没有什么好办法可以全局控制焦点,需要我们自己来想办法规定焦点走向,一旦焦点没有处理好就会造成焦点丢失。

android提供了一些焦点相关的属性,在现有的框架层下通过设置View的属性来获得焦点:

  • android:focusable:设置一个控件能否获得焦点
  • android:nextFocusDown:(当按下键时)下一个获得焦点的控件
  • android:nextFocusDown:(当按下键时)下一个获得焦点的控件
  • android:nextFocusLeft:(当按下键时)下一个获得焦点的控件
  • android:nextFocusRight:(当按下键时)下一个获得焦点的控
    **注意:**如果按下某个方向键时,想让焦点停留在自身,可以使用android:nextFocusRight:"@null"或者android:nextFocusRight:"@id/自身id"

栗子:如下图:

我们想要实现firstView(按右键)–>secondView(按下键)–>threadView(按上键)–>firstView

Android TV开发总结【焦点】-编程知识网

步骤:

  • 第一步:让这firstView、secondView、threadView获取焦点
  • 第二步:控制这三个View的移动轨迹
  • 注意:fourthView没有涉及到焦点,我们不用做任何处理

示例:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"><Viewandroid:id="@+id/firstView"android:layout_width="40dp"android:layout_height="40dp"android:focusable="true"android:nextFocusDown="@null"android:nextFocusLeft="@null"android:nextFocusRight="@id/secondView"android:nextFocusUp="@null" /><Viewandroid:id="@+id/secondView"android:layout_width="60dp"android:layout_height="60dp"android:focusable="true"android:nextFocusDown="@id/threadView"android:nextFocusLeft="@null"android:nextFocusRight="@null"android:nextFocusUp="@null" /><Viewandroid:id="@+id/threadView"android:layout_width="30dp"android:layout_height="30dp"android:focusable="true"android:nextFocusDown="@null"android:nextFocusLeft="@null"android:nextFocusRight="@null"android:nextFocusUp="@id/firstView" /><Viewandroid:id="@+id/fourthView"android:layout_width="100dp"android:layout_height="40dp" />
</android.support.constraint.ConstraintLayout>

也可以在代码中设置:

threadView.setNextFocusLeftId(R.id.firstView);
secondView.setNextFocusDownId(R.id.threadView);

注意:

  • 开发过程中我们有时需要布局初始化就有一个View是聚焦状态,那么可以使用requestFocus()来请求焦点。

那么此时问题来了,我们肉眼如何知道焦点在哪一个View上?

此时就需要我们对焦点选中的View进行样式改变,有一下两种方法:

二、聚焦时View样式

方法一:

 android:background:设置背景的drawableandroid:textColor:设置字体颜色

对应的xml文件:
drawable的xml文件,焦点选中时显示为keyboard_add,否则显示为keyboard_add_sel

<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:drawable="@drawable/keyboard_add_sel" android:state_focused="true" /><item android:drawable="@drawable/keyboard_add" android:state_focused="false"/>
</selector>

color的xml文件,焦点选中时显示#4194ff(蓝色),否则显示#29ffffff(灰色)

<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:color="#4194ff" android:state_focused="true"/><item android:color="#29ffffff" android:state_focused="false"/>
</selector>

方法二:

对该View进行焦点监听(setOnFocusChangeListener),在该监听事件中进行处理

view.setOnFocusChangeListener(new View.OnFocusChangeListener()
{@Overridepublic void onFocusChange(View view, boolean hasFocus){if(hasFocus){//获得焦点view.xxxxx();}else{//失去焦点view.xxxxx();}}
});

三、按键事件如何分发?

首先看一下经常会遇到的坑,带着问题去探究整个过程

尽管官方提供了基本用法,但是我们开发中任然会遇到焦点相关的问题:

  • 我明明指定了焦点,为什么焦点还是丢失了?
  • onKeyDown为什么有时获取不到按键事件?
  • 没有做任何焦点处理的View会获取焦点?
  • 对RecycleView设置nextFocusDown没有效果?

接下来我们带着问题从源码角度来探究一下:

在手机端,我们通过滑动,触摸,长按等,会产生一个触摸事件(MotionEvent)。

同理:在遥控器上我们按“上”,“下”,“左”,“右”,“ok”,“返回”等按键时,会产生一个按键事件(KeyEvent),焦点的处理就在KeyEvent中分发处理。

所以此时我们需要从ViewRootImpl入手,来具体分析焦点是如何分发的?。那么此时有同学会问,为什么是从ViewRootImpl入手?

3.1 什么是ViewRootImpl?

官方定义:The top of a view hierarchy, implementing the needed protocol between View and the WindowManager.

翻译: 视图层次结构的顶部,在视图和窗口管理器之间实现所需的协议。

这里简单总结一下几点:

  • 1、ViewRootImpl是链接WindowManagerDecorView的纽带
  • 2、完成View的绘制,包括measure``、layoutdraw过程。
  • 3、向DecorView分发收到的用户发起的event事件,如按键,触屏等事件。

ViewRootImpl本身并不是一个View,可以看作是View树的管理者。而这里的成员变量mView就是DecorView,它指向的对象跟Window和Activity的mDecor指向的对象是同一个对象。所有的View组成了一个View树,每一个View都是树中的一个节点,如下图所示:

Android TV开发总结【焦点】-编程知识网
最上层的根是DecorView,中间是各ViewGroup,最下层是View。

所以我们知道知道keyevent的分发源头是ViewRootImpl,它是整个View树的管理者,首先走了mView的dispatchKeyEvent,也就是从DecorView开始进行KeyEvent的分发。

3.2 keyevent分发流程?

Android焦点事件的分发是在ViewRootImpl的内部类ViewPostImeInputStage中的processKeyEvent方法进行的,具体流程看代码:

本文以(API27)为例

(1)processKeyEvent方法的具体实现

private int processKeyEvent(QueuedInputEvent q) {final KeyEvent event = (KeyEvent)q.mEvent;//由dispatchKeyEvent进行焦点的分发,如果dispatchKeyEvent方法返回true,那么下面的焦点查找步骤就不会继续了。//这里mView是Activity的顶层容器DecorView,是一FrameLayout。//所以这里的dispatchKeyEvent方法执行的是ViewGroup的dispatchKeyEvent()方法if (mView.dispatchKeyEvent(event)) {return FINISH_HANDLED;}// 是否终止事件// 当根视图不存在就会停止下面的步骤// 属于保护措施if (shouldDropInputEvent(q)) {return FINISH_NOT_HANDLED;}int groupNavigationDirection = 0;//对TAB键做特殊处理//判断仅按下TAB还是TAB和其他键的组合//metaStateHasModifiers()方法根据指定的META状态按下指定的按键键,则返回true.如果按下不同的修改键组合,则返回false.//通过下面的方法判断groupNavigationDirection的方向if (event.getAction() == KeyEvent.ACTION_DOWN&& event.getKeyCode() == KeyEvent.KEYCODE_TAB) {if (KeyEvent.metaStateHasModifiers(event.getMetaState(), KeyEvent.META_META_ON)) {groupNavigationDirection = View.FOCUS_FORWARD;} else if (KeyEvent.metaStateHasModifiers(event.getMetaState(),KeyEvent.META_META_ON | KeyEvent.META_SHIFT_ON)) {groupNavigationDirection = View.FOCUS_BACKWARD;}}... ... ...// 应用 fallback 策略// 具体实现见PhoneFallbackEventHandler中dispatchKeyEvent()方法// 主要是对媒体键,音量键,通话键等做处理,如果是这些按键则会停止下面的步骤if (mFallbackEventHandler.dispatchKeyEvent(event)) {return FINISH_HANDLED;}// 自动追踪焦点// 该部分是重点if (event.getAction() == KeyEvent.ACTION_DOWN) {if (groupNavigationDirection != 0) {//如果是TAB键则groupNavigationDirection不为0,进行如下操作(这里不做重点解析)if (performKeyboardGroupNavigation(groupNavigationDirection)) {return FINISH_HANDLED;}} else {//此处是对我们按键焦点处理的重点//下面我们进入该方法详细去看一下,详见(2)if (performFocusNavigation(event)) {return FINISH_HANDLED;}}}return FORWARD;
}

(2)performFocusNavigation方法的具体实现(主要用于记录方向)

我们接下来看一下performFocusNavigation①方法:

private boolean performFocusNavigation(KeyEvent event) {//direction用来记录方向的值,用来进行后面的焦点查找int direction = 0;switch (event.getKeyCode()) {case KeyEvent.KEYCODE_DPAD_LEFT://根据指定的元状态没有按下修饰符键,则返回trueif (event.hasNoModifiers()) {direction = View.FOCUS_LEFT;}break;case KeyEvent.KEYCODE_DPAD_RIGHT:if (event.hasNoModifiers()) {direction = View.FOCUS_RIGHT;}break;case KeyEvent.KEYCODE_DPAD_UP:if (event.hasNoModifiers()) {direction = View.FOCUS_UP;}break;case KeyEvent.KEYCODE_DPAD_DOWN:if (event.hasNoModifiers()) {direction = View.FOCUS_DOWN;}break;case KeyEvent.KEYCODE_TAB:if (event.hasNoModifiers()) {direction = View.FOCUS_FORWARD;} else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {direction = View.FOCUS_BACKWARD;}break;}//给定了direction(遥控器按键按下的方向),接下来就是焦点寻找if (direction != 0) {//找到当前聚焦的View 下面会详细讲解,见(3)View focused = mView.findFocus();if (focused != null) {//如果focused不为空,说明找到了焦点,接着focusSearch会把direction(遥控器按键按下的方向)作为参数,找到特定方向下一个将要获取焦点的view,最后如果该view不为空,那么就让该view获取焦点。//后面详细介绍focusSearch()具体方法,见(4)View v = focused.focusSearch(direction);if (v != null && v != focused) {focused.getFocusedRect(mTempRect);if (mView instanceof ViewGroup) {((ViewGroup) mView).offsetDescendantRectToMyCoords(focused, mTempRect);((ViewGroup) mView).offsetRectIntoDescendantCoords(v, mTempRect);}if (v.requestFocus(direction, mTempRect)) {playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));return true;}}// Give the focused view a last chance to handle the dpad key.if (mView.dispatchUnhandledMove(focused, direction)) {return true;}} else {if (mView.restoreDefaultFocus()) {return true;}}}return false;
}

(3)findFocus方法的具体实现(查找到当前聚焦的view)

我们来看一下详细看一下findFocus()
Android TV开发总结【焦点】-编程知识网
我们看到findFocus有viewGroup和view的:
其实就是在一层一层往下查找已经获取焦点的子View(一定要先理解视图树)

//viewGroup焦点判断
@Override
public View findFocus() {if (DBG) {System.out.println("Find focus in " + this + ": flags="+ isFocused() + ", child=" + mFocused);}if (isFocused()) {return this;}if (mFocused != null) {return mFocused.findFocus();}return null;
}
//view焦点判断
public View findFocus() {return (mPrivateFlags & PFLAG_FOCUSED) != 0 ? this : null;
}

说明:判断view是否获取焦点的isFocused()方法, (mPrivateFlags & PFLAG_FOCUSED) != 0 和view 的findFocus()方法是一致的。

public boolean isFocused() {return (mPrivateFlags & PFLAG_FOCUSED) != 0;
}

isFocused()方法的作用是判断view是否已经获取焦点,如果viewGroup已经获取到了焦点,那么返回本身即可,否则通过mFocused的findFocus()方法来找焦点。mFocused其实就是ViewGroup中获取焦点的子view,如果mView不是ViewGourp的话,findFocus其实就是判断本身是否已经获取焦点,如果已经获取焦点了,返回本身。

此时我们已经找到了当前获得焦点的View,接下来就是说按照给定的方向去寻找下一个即将获得焦点的view

(4)focusSearch方法的具体实现

通过View的focusSearch方法找到下一个获取焦点的View,那么到底是如何查找的?往下看:

//view中
public View focusSearch(@FocusRealDirection int direction) {if (mParent != null) {return mParent.focusSearch(this, direction);} else {return null;}
}

View并不会直接去找,而是交给它的parent去找。

//viewGroup中
@Override
public View focusSearch(View focused, int direction) {if (isRootNamespace()) {//判断是否是顶层view,是则执行以下算法return FocusFinder.getInstance().findNextFocus(this, focused, direction);} else if (mParent != null) {return mParent.focusSearch(focused, direction);}return null;
}

判断是否为顶层布局(isRootNamespace()方法),若是则执行对应方法,若不是则继续向上寻找,说明会从内到外的一层层进行判断,直到最外层的布局为止。

最终会调用viewGroup的FocusFinder来找计算下一个获得焦点的view。

(5)findNextFocus方法的具体实现

// FocusFinder.java
public final View findNextFocus(ViewGroup root, View focused, int direction) {return findNextFocus(root, focused, null, direction);
}//root是上面isRootNamespace()为true的ViewGroup
//focused是当前焦点视图
private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) {View next = null;ViewGroup effectiveRoot = getEffectiveRoot(root, focused);if (focused != null) {// 优先从xml或者代码中指定focusid的View中找next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);}if (next != null) {return next;}ArrayList<View> focusables = mTempList;try {focusables.clear();effectiveRoot.addFocusables(focusables, direction);if (!focusables.isEmpty()) {//其次,根据算法去找,原理就是找在方向上最近的Viewnext = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);}} finally {focusables.clear();}return next;
}

从上面可以看出

  • (1)优先找开发者指定的下一个focus的视图 ,就是在xml或者代码中指定NextFocusDirection Id的视图
  • (2)其次,根据算法去找,原理就是找在方向上最近的视图

我们这里分开两个方法看findNextUserSpecifiedFocus()findNextFocus()

(6)findNextUserSpecifiedFocus() 从指定focusid的View中找

//FocusFinder.java
private View findNextUserSpecifiedFocus(ViewGroup root, View focused, int direction) {// 寻找用户定义的下一个焦点ViewView userSetNextFocus = focused.findUserSetNextFocus(root, direction);View cycleCheck = userSetNextFocus;boolean cycleStep = true; // we want the first toggle to yield falsewhile (userSetNextFocus != null) {if (userSetNextFocus.isFocusable()&& userSetNextFocus.getVisibility() == View.VISIBLE&& (!userSetNextFocus.isInTouchMode()|| userSetNextFocus.isFocusableInTouchMode())) {return userSetNextFocus;}userSetNextFocus = userSetNextFocus.findUserSetNextFocus(root, direction);if (cycleStep = !cycleStep) {cycleCheck = cycleCheck.findUserSetNextFocus(root, direction);if (cycleCheck == userSetNextFocus) {// found a cycle, user-specified focus forms a loop and none of the views// are currently focusable.break;}}}return null;
}

findNextUserSpecifiedFocus()方法会执行focused(即当前获取焦点的View)的findUserSetNextFocus方法,如果该方法返回的View不为空,且isFocusable = true && isInTouchMode()=true的话,FocusFinder找到的焦点就是findNextUserSpecifiedFocus()返回的View。

//View.java 
findUserSetNextFocus(View root, @FocusDirection int direction) {switch (direction) {case FOCUS_LEFT:if (mNextFocusLeftId == View.NO_ID) return null;return findViewInsideOutShouldExist(root, mNextFocusLeftId);case FOCUS_RIGHT:if (mNextFocusRightId == View.NO_ID) return null;return findViewInsideOutShouldExist(root, mNextFocusRightId);case FOCUS_UP:if (mNextFocusUpId == View.NO_ID) return null;return findViewInsideOutShouldExist(root, mNextFocusUpId);case FOCUS_DOWN:if (mNextFocusDownId == View.NO_ID) return null;return findViewInsideOutShouldExist(root, mNextFocusDownId);case FOCUS_FORWARD:if (mNextFocusForwardId == View.NO_ID) return null;return findViewInsideOutShouldExist(root, mNextFocusForwardId);case FOCUS_BACKWARD: {if (mID == View.NO_ID) return null;final int id = mID;return root.findViewByPredicateInsideOut(this, new Predicate<View>() {@Overridepublic boolean test(View t) {return t.mNextFocusForwardId == id;}});}}return null;
}

findUserSetNextFocus就是通过设置的id去找view,比如:按了“左”方向键,如果设置了mNextFocusLeftId,则会通过findViewInsideOutShouldExist去找这个View。

来看看findViewInsideOutShouldExist做了什么?

//View.java
private View findViewInsideOutShouldExist(View root, int id) {if (mMatchIdPredicate == null) {// 可以理解为一个判定器,如果id匹配则判定成功mMatchIdPredicate = new MatchIdPredicate();}mMatchIdPredicate.mId = id;View result = root.findViewByPredicateInsideOut(this, mMatchIdPredicate);...return result;
}public final View findViewByPredicateInsideOut(View start, Predicate<View> predicate) {View childToSkip = null;for (;;) {// 从当前起始节点开始寻找(ViewGroup是遍历自己的child),寻找id匹配的ViewView view = start.findViewByPredicateTraversal(predicate, childToSkip);if (view != null || start == this) {return view;}ViewParent parent = start.getParent();if (parent == null || !(parent instanceof View)) {return null;}// 如果如果当前节点没有,则往上一级,从自己的parent中查找,并跳过自己childToSkip = start;start = (View) parent;}
}protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) {if (predicate.apply(this)) {return this;}return null;
}

// ViewGroup

@Override
protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) {if (predicate.apply(this)) {return this;}final View[] where = mChildren;final int len = mChildrenCount;for (int i = 0; i < len; i++) {View v = where[i];if (v != childToSkip && (v.mPrivateFlags & PFLAG_IS_ROOT_NAMESPACE) == 0) {v = v.findViewByPredicate(predicate);if (v != null) {return v;}}}return null;
}

可以看到,findViewInsideOutShouldExist这个方法从当前指定视图去寻找指定id的视图。首先从自己开始向下遍历,如果没找到则从自己的parent开始向下遍历,直到找到id匹配的视图为止。

(7)findNextFocus()根据算法去找

如果开发者没有指定nextFocusId,则用findNextFocus找指定方向上最近的视图
看一下这里的用法:

//FocusFinder.java
private View findNextFocus(ViewGroup root, View focused, Rect focusedRect, int direction) {View next = null;ViewGroup effectiveRoot = getEffectiveRoot(root, focused);if (focused != null) {next = findNextUserSpecifiedFocus(effectiveRoot, focused, direction);}if (next != null) {return next;}ArrayList<View> focusables = mTempList;try {focusables.clear();//找到所有isFocusable的VieweffectiveRoot.addFocusables(focusables, direction);if (!focusables.isEmpty()) {next = findNextFocus(effectiveRoot, focused, focusedRect, direction, focusables);}} finally {focusables.clear();}return next;
}

这里就不对findNextFocus()具体展开了,大概讲一下步骤:

findNextFocus():

  • (1)遍历找出所有isFocusable的视图
  • (2)将focused视图的坐标系,转换到root的坐标系中,统一坐标,以便进行下一步的计算
  • (3)进行一次遍历比较,得到最“近”的视图作为下一个焦点视图

3.3 keyevent分发流程总结

  • 1、ViewRootImpl的processKeyEvent方法获取按键事件
  • 2、判断ViewGroup的dispatchKeyEvent()方法是否消费了事件是则不往下分发,终止。
  • 3、判断是否是一些特殊按键如:接听,挂断,音量等
  • 4、如果没有消费事件,那么焦点就会交给系统来处理
  • 5、Android底层先会记录按键的方向
  • 6、DecorView会从顶部一层一层往下调用findFocus方法找到当前获取焦点的View
  • 7、通过focusSearch从内到外层层寻找下一个焦点view,直到顶层为止,具体算法在FocusFinder
  • 8、FocusFinder会根据用户设置的id,优先查找,如果没有设置则通过系统算法找到最近的焦点view

3.4 处理焦点的时机

结合KeyEvent事件的流转,对处理焦点的时机做了如下排序:

  • 1、dispatchKeyEvent
  • 2、mOnKeyListener.onKey回调
  • 3、onKeyDown/onKeyUp
  • 4、focusSearch
  • 5、指定nextFocusId
  • 6、系统自动从所有isFocusable的视图中找下一个焦点视图
    以上任一处都可以指定焦点,一旦使用了就不再往下走。

扫码关注公众号“伟大程序猿的诞生“,更多干货新鲜文章等着你~
Android TV开发总结【焦点】-编程知识网
公众号回复“资料获取”,获取更多干货哦~

有问题添加本人微信号“fenghuokeji996” 或扫描博客导航栏本人二维码