一、焦点获取
首先,TV端的开发和我们手机端开发最大的区别就在于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
步骤:
- 第一步:让这firstView、secondView、threadView获取焦点
- 第二步:控制这三个View的移动轨迹
- 注意:fourthView没有涉及到焦点,我们不用做任何处理
示例:
"1.0" encoding="utf-8"?>
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
"@+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" />
"@+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" />
"@+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" />
"@+id/fourthView"
android:layout_width="100dp"
android:layout_height="40dp" />
复制代码
也可以在代码中设置:
threadView.setNextFocusLeftId(R.id.firstView);
secondView.setNextFocusDownId(R.id.threadView);
复制代码
注意:
- 开发过程中我们有时需要布局初始化就有一个View是聚焦状态,那么可以使用
requestFocus()
来请求焦点。
那么此时问题来了,我们肉眼如何知道焦点在哪一个View上?
此时就需要我们对焦点选中的View进行样式改变,有一下两种方法:
二、聚焦时View样式
方法一:
android:background:设置背景的drawable
android:textColor:设置字体颜色
复制代码
对应的xml文件: drawable的xml文件,焦点选中时显示为keyboard_add,否则显示为keyboard_add_sel
"http://schemas.android.com/apk/res/android">
- "@drawable/keyboard_add_sel" android:state_focused="true" />
- "@drawable/keyboard_add" android:state_focused="false"/>
复制代码
color的xml文件,焦点选中时显示#4194ff(蓝色),否则显示#29ffffff(灰色)
"http://schemas.android.com/apk/res/android">
- "#4194ff" android:state_focused="true"/>
- "#29ffffff" android:state_focused="false"/>
复制代码
方法二:
对该View进行焦点监听(setOnFocusChangeListener),在该监听事件中进行处理
view.setOnFocusChangeListener(new View.OnFocusChangeListener()
{
@Override
public 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
是链接WindowManager
和DecorView
的纽带- 2、完成
View
的绘制,包括measure``、layout
、draw
过程。- 3、向
DecorView
分发收到的用户发起的event
事件,如按键,触屏等事件。
ViewRootImpl本身并不是一个View,可以看作是View树的管理者。而这里的成员变量mView就是DecorView,它指向的对象跟Window和Activity的mDecor指向的对象是同一个对象。所有的View组成了一个View树,每一个View都是树中的一个节点,如下图所示:
最上层的根是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:
//根据指定的元状态没有按下修饰符键,则返回true
if (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()
//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 focusables = mTempList;
try {
focusables.clear();
effectiveRoot.addFocusables(focusables, direction);
if (!focusables.isEmpty()) {
//其次,根据算法去找,原理就是找在方向上最近的View
next = 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) {
// 寻找用户定义的下一个焦点View
View userSetNextFocus = focused.findUserSetNextFocus(root, direction);
View cycleCheck = userSetNextFocus;
boolean cycleStep = true; // we want the first toggle to yield false
while (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() {
@Override
public 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 predicate) {
View childToSkip = null;
for (;;) {
// 从当前起始节点开始寻找(ViewGroup是遍历自己的child),寻找id匹配的View
View 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 predicate, View childToSkip) {
if (predicate.apply(this)) {
return this;
}
return null;
}
复制代码
// ViewGroup
@Override
protected View findViewByPredicateTraversal(Predicate 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 focusables = mTempList;
try {
focusables.clear();
//找到所有isFocusable的View
effectiveRoot.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的视图中找下一个焦点视图 以上任一处都可以指定焦点,一旦使用了就不再往下走。