简介
在安卓电视的使用过程中,按键走焦是主要的交互方式,因此 TV 开发中会写很多焦点相关的代码。安卓系统内部的焦点管理是建立在 View 系统之上的,有默认的处理方式,不需要过多关注也能满足一些简单的需求,但更精确和细致地控制焦点需要对焦点管理有深入的了解。本文从易到难简述 TV 开发过程中的焦点显示、控制和管理,适合有一定安卓基础的初学 TV 开发的同学学习。配套源码请戳:Github传送门
焦点的基本使用
如何启用焦点
不是所有的 View 默认都可以获焦,一个 View 想要获得焦点,focusable
(可获焦的) 属性需要等于 true
。 默认情况下,Button
、EditText
、RadioButton
、CheckBox
等输入控件是可获焦的,而 TextView
、ImageView
、View
等展示用的控件是不可获焦的。
可显式地设置 focusable
属性等于 true
来让 View 可获焦,还有一些其他方式可以将 View 设置为可获焦,比如给 View 设置了 OnClickListener
,将其变成可交互的控件,这个 View 就变成可获焦的了。
设置
OnClickListener
会将clickable
属性设置为true
。
View 的clickable
属性设置为true
的同时会将focusable
设置为true
。
我们在 layout 文件中添加一个 ImageView
。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#222222"
tools:context=".MainActivity">
<ImageView
android:id="@+id/image1"
android:layout_width="150dp"
android:layout_height="60dp"
android:src="@drawable/image1"
tools:ignore="ContentDescription" />
</RelativeLayout>
只设置成这样的 ImageView
是无法获焦的。可直接设置 focusable
属性:
<ImageView
android:id="@+id/image1"
android:layout_width="150dp"
android:layout_height="60dp"
android:src="@drawable/image1"
tools:ignore="ContentDescription"
android:focusable="true" />
或在 Java 代码中设置:
// 直接设置
findViewById(R.id.image1).setFocusable(true);
// 设置了 OnClickListener 会设置 clickable 为 true,也就设置了 focusable 为 true
findViewById(R.id.image1).setOnClickListener(v -> Log.d(TAG, "onClick: "));
获焦显示不同的样式
焦点并不是一个实体,而是 View 的一种状态,与 View 的 selected
、pressed
、activated
状态一样,可以在资源文件中定义不同状态的样式,然后设置给 View 的 background
、textColor
等属性。
<RelativeLayout>
<Button
android:layout_width="280dp"
android:layout_height="56dp"
android:background="@drawable/button_bg1" />
<Button
android:layout_width="280dp"
android:layout_height="56dp"
android:background="@drawable/button_bg2" />
</RelativeLayout>
@drawable/button_bg1
只有 focused
状态组合:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:drawable="@drawable/button_bg_focused"/>
<item android:drawable="@drawable/button_bg_normal"/>
</selector>
效果如图所示:
@drawable/button_bg2
在 @drawable/button_bg1
的基础上添加了 pressed
状态:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_bg_pressed" android:state_pressed="true" />
<item android:drawable="@drawable/button_bg_focused" android:state_focused="true" />
<item android:drawable="@drawable/button_bg_normal" />
</selector>
效果如图所示:
判断是否获焦
在代码中判断一个 View 是否已经获得了焦点需要使用 View.isFocused()
方法;对于 ViewGroup 来说,有时需要判断 ViewGroup 自身有没有获得焦点,也用 View.isFocused()
方法即可,有时要判断内部的子孙 View 是否获得了焦点,就需要用 View.hasFocus()
方法了。
判断焦点的方法 | View | ViewGroup |
---|---|---|
isFocused() | 表示这个 View 有没有获得焦点 | 容器自身有没有获得焦点 |
hasFocus() | 表示这个 View 有没有获得焦点,与 isFocused() 含义相同 | 容器或者其内部子孙 View 有没有获得焦点 |
这两个方法都是可以重写的,一般常用的 ViewGroup,比如 RelativeLayout、LinearLayout 等都没有重写,保持这个语义。我们在实现自定义类的时候可以根据需要重写这两个方法,关于自定义类重写焦点相关的方法的话题,会在本系列的高级篇详细讲解。
事件监听
View.setOnFocusChangeListener(View.OnFocusChangeListener listener)
可添加焦点变化事件,监听焦点在 View 上的变化。
findViewById(R.id.button3).setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
// 修改自身的属性
v.setScaleX(hasFocus ? 1.15f : 1.0f);
v.setScaleY(hasFocus ? 1.15f : 1.0f);
TextView textView = MainActivity.this.findViewById(R.id.label_for_button3);
// 联动其他 View 的变化
textView.setText(hasFocus ? R.string.result_focused : R.string.result_unfocused);
}
});
移动焦点操作中,旧的获焦的 View 会触发 onFocusChange(v, false)
回调表示失去焦点,新获焦的 View 会触发 onFocusChange(v, true)
回调表示获得焦点。焦点的移动总是一个 View 失去焦点的同时另一个 View 获得焦点。通常可以用这个回调来:
- 改变自身的属性,有些属性无法用可自动切换状态的 drawable 来设置,只能通过回调。
- 监听 View 的焦点变化从而联动其他 View 的状态改变,获焦状态变化时修改另一个文本的内容。
当 View 获得焦点时,按 Enter(KeyEvent.KEYCODE_ENTER
) 或 Center(KeyEvent.KEYCODE_DPAD_CENTER
) 键,等同于 click 事件,需监听 View.OnClickListener
回调:
findViewById(R.id.button4).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mCustomMoveEnabled = !mCustomMoveEnabled;
updateCustomFocusMoveEnabled();
}
});
简单控制焦点移动
默认情况下,焦点的移动有一套内置的逻辑,虽然内部实现比较复杂,但从效果来看一句话就可以简单概括:按哪个方向键焦点就往哪个方向走。在全屏幕范围内,决定焦点移动方向的不是 View 的层次关系,而是 View 在屏幕上的实际位置。
如果不想要系统默认的焦点移动逻辑,就需要写代码来控制了,我们先看简单的控制:在某个 View 上按某个方向键移动到指定的另一个 View 上。这样决定焦点的就不是位置了,而是一个固定目标,不根据这两个 View 之间相对位置变化而变化。
void updateCustomFocusMoveEnabled() {
Button button = findViewById(R.id.button4);
if (mCustomMoveEnabled) {
// 自定义获焦时按右键移动到哪个 View
button.setNextFocusRightId(R.id.image1);
// 自定义获焦时按下键移动到哪个 View
button.setNextFocusDownId(R.id.button1);
} else {
// 将 id 设置为 View.NO_ID 可以取消自定义
button.setNextFocusRightId(View.NO_ID);
button.setNextFocusDownId(View.NO_ID);
}
}
焦点的深入定制
全局焦点事件
焦点作为 View 的一种状态,与其他状态不同的是,它是一个“全局”的状态,在一个 Window 上同时只有一个 View 能变成 focused
状态,也就是说在屏幕上只有一个 View 能获焦。焦点的这种特性是安卓 View 框架内部实现的,View 内部通过大量的逻辑来保证同一时间只有一个 View 能被设置为 focused
状态。想要对焦点处理逻辑有深入的了解,是一定要读懂源码的,有一个工具能很好地帮助我们来窥探源码内部的逻辑,就是全局焦点事件。
全局焦点事件可以监听整个 View Tree 的焦点变化,监听的回调提供旧的获焦 View 以及新的获焦 View,具体用法如下:
View view = ... // view 变量可以是任意已添加到 Window 的 View
view.getViewTreeObserver().addOnGlobalFocusChangeListener(new ViewTreeObserver.OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
Log.d(TAG, "onGlobalFocusChanged: oldFocus=" + oldFocus);
Log.d(TAG, "onGlobalFocusChanged: newFocus=" + newFocus, new Exception());
}
});
这里使用 log 打印来检查全局焦点结果,通过创建一个异常(Exception
)对象来捕获调用栈,如下:
2021-03-16 23:58:32.442 13891-13891/com.ajeyone.focusstudy D/SecondActivity: onGlobalFocusChanged: oldFocus=com.google.android.material.button.MaterialButton{837a65e VFED..C.. ......ID 340,0-640,112 #7f08005d app:id/button7}
2021-03-16 23:58:32.453 13891-13891/com.ajeyone.focusstudy D/SecondActivity: onGlobalFocusChanged: newFocus=com.google.android.material.button.MaterialButton{f16aa3f VFED..C.. .F...... 680,0-980,112 #7f08005e app:id/button8}
java.lang.Exception
at com.ajeyone.focusstudy.SecondActivity$1.onGlobalFocusChanged(SecondActivity.java:22)
at android.view.ViewTreeObserver.dispatchOnGlobalFocusChange(ViewTreeObserver.java:1035)
at android.view.View.handleFocusGainInternal(View.java:7475)
at android.view.View.requestFocusNoSearch(View.java:12441)
at android.view.View.requestFocus(View.java:12415)
at android.view.ViewRootImpl$ViewPostImeInputStage.performFocusNavigation(ViewRootImpl.java:5363)
at android.view.ViewRootImpl$ViewPostImeInputStage.processKeyEvent(ViewRootImpl.java:5480)
at android.view.ViewRootImpl$ViewPostImeInputStage.onProcess(ViewRootImpl.java:5292)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4799)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:4852)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:4818)
at android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:4958)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:4826)
at android.view.ViewRootImpl$AsyncInputStage.apply(ViewRootImpl.java:5015)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4799)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:4852)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:4818)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:4826)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:4799)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:4852)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:4818)
at android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:4991)
at android.view.ViewRootImpl$ImeInputStage.onFinishedInputEvent(ViewRootImpl.java:5152)
at android.view.inputmethod.InputMethodManager$PendingEvent.run(InputMethodManager.java:3064)
at android.view.inputmethod.InputMethodManager.invokeFinishedInputEventCallback(InputMethodManager.java:2607)
at android.view.inputmethod.InputMethodManager.finishedInputEvent(InputMethodManager.java:2598)
at android.view.inputmethod.InputMethodManager$ImeInputEventSender.onInputEventFinished(InputMethodManager.java:3041)
at android.view.InputEventSender.dispatchInputEventFinished(InputEventSender.java:143)
at android.os.MessageQueue.nativePollOnce(Native Method)
at android.os.MessageQueue.next(MessageQueue.java:336)
at android.os.Looper.loop(Looper.java:174)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1066)
这个 log 是在按了一下右键,移动了一下焦点时打印的。虽然调用栈很长,但也可以看出这次焦点移动的根源是用户的输入,我们来精简一下这个调用栈:
at com.ajeyone.focusstudy.SecondActivity$1.onGlobalFocusChanged(SecondActivity.java:22)
at android.view.ViewTreeObserver.dispatchOnGlobalFocusChange(ViewTreeObserver.java:1035)
at android.view.View.handleFocusGainInternal(View.java:7475)
at android.view.View.requestFocusNoSearch(View.java:12441)
at android.view.View.requestFocus(View.java:12415)
at android.view.ViewRootImpl$ViewPostImeInputStage.performFocusNavigation(ViewRootImpl.java:5363)
...
at android.view.inputmethod.InputMethodManager$ImeInputEventSender.onInputEventFinished(InputMethodManager.java:3041)
...
最后一行的 ImeInputEventSender
表示事件的根源来自键盘输入,其实就是电视遥控器。再往上就是 ViewRootImpl
代表的 View 体系内的了,可以看到中间有 View.requestFocus()
调用,这个方法是 public
的,不仅内部可以用来切换焦点,应用代码也可以调用,一般用来强制抢夺焦点,这个方法后面会详细讲解。
复杂的自定义样式
上文介绍了简单的自定义样式,可用资源文件根据状态自动更新样式,或在 onFocusChanged
回调中修改属性。复杂的样式也是基于这两个手段添加更多的逻辑来实现的,本节内容通过举例说明如何定义焦点的复杂样式。
例1:drawable 定义多种状态
需求:显示一行按钮,表示可选的水果:火龙果、橙子、猕猴桃、香蕉、苹果、大鸭梨。可以点击按钮将水果选中或取消选中,选中的水果按钮要始终能表示出来被选中的状态,也就是说只要是选中状态,不论其他状态是获焦还是按下,都能与未选中的按钮区分出来。
这里我们直接扩展 “还有 PRESSED 状态的按钮” 的样式,其实就是添加一个 selected
状态的维度,需要额外的 selected=true
状态下的3种样式,先看下效果图:
按效果图所示,我们给 selected=true
状态的 View 添加白色边框。
6种样式的状态组合见下表:
按钮 | selected | pressed | focused |
---|---|---|---|
火龙果 | true | ||
橙子 | |||
猕猴桃 | true | true | |
香蕉 | true | ||
苹果 | true | true | |
大鸭梨 | true |
对应的 drawable 代码也是 6 个 item:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/button_bg_selected_pressed" android:state_pressed="true" android:state_selected="true" />
<item android:drawable="@drawable/button_bg_pressed" android:state_pressed="true" android:state_selected="false" />
<item android:drawable="@drawable/button_bg_selected_focused" android:state_focused="true" android:state_selected="true" />
<item android:drawable="@drawable/button_bg_focused" android:state_focused="true" android:state_selected="false" />
<item android:drawable="@drawable/button_bg_selected" android:state_selected="true" />
<item android:drawable="@drawable/button_bg_normal" />
</selector>
@drawable/button_bg_selected_focused
的代码,添加了边框(其他 selected=true
的样式同理,略):
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke android:color="@color/white" android:width="2dp"/>
<gradient
android:angle="0"
android:endColor="@color/green_end"
android:startColor="@color/green_start" />
<corners android:radius="28dp" />
</shape>
例2:多个 View 联动切换样式
需求:显示一张名片,名片内信息包含头像、名字、称号、电话。这个名片作为一个整体来获得焦点,获焦状态时卡片本身以及所有信息的显示都有改变。
先看下效果图:
由于卡片获焦时,卡片中的多个 View 都会改变样式,但系统约束只能有一个 View 是获焦状态,因此不能用 focused
状态来定义 drawable 中的样式。不能通过资源文件根据状态自动选择样式,就必须用事件的方式来监听焦点的变化从而改变样式。具体的实现方法很灵活,大体思路是监听焦点变化事件,修改 View 的状态,再配合资源文件来定义样式。
所有 View 都可用的状态有 selected
和 activated
,其他状态例如 pressed
是系统使用的我们最好不要直接调用 setPressed()
,checked
状态是实现了 Checkable
接口的 View 才有的,例如 CheckBox
、RadioButton
等。只有 selected
和 activated
这两个状态是所有 View 都有的,是 View 提供给外部使用的,而且语义上也符合。
这两个状态的设置有个共同的特点,就是会递归设置子 View 的状态。下面我们以 activated
状态为例,activated
状态的设置方法 setActivated(boolean)
不仅仅设置这个 View 本身的 activated
状态,还会递归设置所有的子 View 的 activated
状态,使得这个 View 以及它的子孙 View 的 activated
状态保持一致。可以看下源码是怎么实现的:
// View.java
public void setActivated(boolean activated) {
if (((mPrivateFlags & PFLAG_ACTIVATED) != 0) != activated) {
// ...
dispatchSetActivated(activated);
}
}
protected void dispatchSetActivated(boolean activated) {
}
// ViewGroup.java
@Override
public void dispatchSetActivated(boolean activated) {
final View[] children = mChildren;
final int count = mChildrenCount;
for (int i = 0; i < count; i++) {
children[i].setActivated(activated);
}
}
这个功能给我们省了很多代码,不用自己将内部的 View 都找到并一个一个地设置。当然,如果需要改变样式的 View 并不都在一个 ViewGroup 中,还是要明确调用一下状态设置方法的。
这个功能的实现首先要用到某种 ViewGroup 作为卡片,内部填入一些 ImageView
和 TextView
,代码如下:
<RelativeLayout
android:id="@+id/card"
android:layout_width="360dp"
android:layout_height="108dp"
android:background="@drawable/button_bg4"
android:descendantFocusability="blocksDescendants"
android:focusable="true"
android:orientation="horizontal"
android:padding="10dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_centerVertical="true"
android:background="@drawable/round_bg_in_card"
android:src="@drawable/robin" />
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/avatar"
android:layout_marginStart="8dp"
android:layout_toEndOf="@id/avatar"
android:text="拾识物者"
android:textColor="@color/title_in_card"
android:textSize="16sp" />
<!-- 其他略 -->
</RelativeLayout>
以上代码关键点是 <RelativeLayout>
的 focusable
属性,表示这个 ViewGroup 自己可以获得焦点。默认情况下 ViewGroup.isFocusable()
都是返回 false 的,也就是自身不能获得焦点。注意 ViewGroup 自身能否获得焦点,并不影响它的子孙 View 能否获得焦点,能影响子孙 View 能否获得焦点的是 descendantFocusability
属性,当 descendantFocusability=blocksDescendants
时,子孙 View 就会被禁止获得焦点,无论是通过按键移动的方式,还是代码里直接调用 requestFocus()
的方式,焦点都不会移动到子孙 View 上。
ViewGroup 的 descendantFocusibility
属性定义了在 ViewGroup 可能获得焦点的时候,ViewGroup 自身和子孙 View 谁优先获得焦点的行为。有三种可选值:
descendantFocusability 属性值 | 对应的行为 |
---|---|
FOCUS_BEFORE_DESCENDANTS | 在 ViewGroup requestFocus() 的时候,先把自己当成 View 来处理焦点,先看看自己能不能获得焦点,如果不能然后再考虑子孙。 |
FOCUS_AFTER_DESCENDANTS | 在 ViewGroup requestFocus() 的时候,先考虑子孙能否获得焦点,如果一个能获得焦点的子孙都没有,再把自己当成 View 来处理焦点。 |
FOCUS_BLOCK_DESCENDANTS | 子孙没有获得焦点的权利,即使 ViewGroup 自己不能获得焦点,子孙也没有机会。这个属性一设置,焦点这块拿捏地死死的。 |
再来看 Java 代码部分,由于使用了 activated
属性,只需要一行代码就可以修改卡片以及内部的各个 View 的状态。
@Override
protected void onCreate(Bundle savedInstanceState) {
// ...
findViewById(R.id.card).setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
v.setActivated(hasFocus);
}
});
}
最后是 drawable 文件,注意使用 activated
状态而不是 focused
状态进行组合定义:
<!-- 头像 @drawable/round_bg_in_card -->
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true" android:drawable="@drawable/avatar_bg_in_card_activated"/>
<item android:drawable="@android:color/transparent"/>
</selector>
(其他相关资源文件略)
例3:ViewGroup 整体获焦事件
需求:将所有水果按钮放在一个容器内,当没有水果按钮获焦时,容器本身无变化,如果有任意水果按钮获焦时,容器轮廓要显示出来,表示正在选择水果。
对于 View 来说,有 setOnFocusChangeListener(View.OnFocusChangeListener)
来监听焦点的变化;ViewGroup 也可以设置这个 listener,监听的是 ViewGroup 本身焦点的变化,而不会监听内部子 View 的焦点变化。因此这个需求直接在 ViewGroup 上监听焦点是不管用的。
既然 ViewGroup 整体监听无效,那么我们只好监听内部所有按钮的焦点变化,只要有一个按钮是获焦状态,那么就可以看做是 ViewGroup 是“获焦”状态。得知了 ViewGroup 当前的“获焦”状态,再与上一次焦点变化时的“获焦”状态对比,如果有变化,就可以调用一个 ViewGroup “获焦”状态变化的回调。下文将这种 ViewGroup 内部子 View 焦点变化聚合的 ViewGroup “获焦”状态称为 togetherFocused
状态。
我们将这个逻辑抽象出来,写成一个通用的类,这个类使用起来要简单。如何使用的代码如下所示:
// 传入需要监听的 ViewGroup
private void setupTogetherFocus(ViewGroup viewGroup) {
final TogetherFocusChangeListener listener = new TogetherFocusChangeListener() {
@Override
protected void onTogetherFocusChange(ViewGroup container, View v, boolean focused) {
Log.d(TAG, "onTogetherFocusChange: hasFocus=" + container.hasFocus() + ", together focused=" + focused);
if (focused) {
container.setBackgroundResource(R.drawable.bordered_bg);
} else {
container.setBackgroundResource(0);
}
}
};
final int n = viewGroup.getChildCount();
for (int i = 0; i < n; i++) {
View child = viewGroup.getChildAt(i);
child.setOnFocusChangeListener(listener);
}
}
根据以上代码可以看出,我们自定义了一个实现了 View.OnFocusChangeListener
接口的类 TogetherFocusChangeListener
,它作为焦点变化的监听者来监听任何关于内部子 View 的焦点变化,然后它通过内部的处理,将子 View 的焦点变化转换为 ViewGroup 的 togetherFocus 变化,最后作为一个事件源调用回调 onTogetherFocusChange()
进行定制化处理。
注意在 onTogetherFocusChange()
回调中我们并没有使用修改状态+资源文件的方式来更新 UI,原因有两个:一是 UI 变化的只是 ViewGroup 本身,不涉及内部子 View 的变化;二是 selected
状态已经被内部的按钮使用,如果对 ViewGroup 调用 setSelected()
就会同时修改内部按钮的 selected
状态,这个效果不是我们想要的。本质上还是因为这两个状态会“殃及池鱼”,对子 View 设置不需要更改的状态。因此在只需要修改 ViewGroup 本身的样式时我们用 Java 代码直接设置的方法来实现。
下面给出 TogetherFocusChangeListener
的具体实现:
package com.ajeyone.focusstudy.together;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
public abstract class TogetherFocusChangeListener implements View.OnFocusChangeListener {
private static int sTagIdTogetherFocused;
public static void init(int tagIdTogetherFocused) {
sTagIdTogetherFocused = tagIdTogetherFocused;
}
@Override
public void onFocusChange(View v, boolean hasFocus) {
ViewParent parent = v.getParent();
if (!(parent instanceof ViewGroup)) {
return;
}
ViewGroup container = ((ViewGroup) parent);
boolean last = getLastTogetherFocusedState(container);
boolean current = hasAnyFocusedChild(container);
if (last != current) {
onTogetherFocusChange(container, v, current);
setLastTogetherFocusedState(container, current);
}
}
protected abstract void onTogetherFocusChange(ViewGroup container, View v, boolean focused);
private static boolean getLastTogetherFocusedState(ViewGroup container) {
Object tag = container.getTag(sTagIdTogetherFocused);
return (tag instanceof Boolean) && (Boolean) tag;
}
private void setLastTogetherFocusedState(ViewGroup container, boolean focused) {
container.setTag(sTagIdTogetherFocused, focused);
}
private static boolean hasAnyFocusedChild(ViewGroup container) {
final int n = container.getChildCount();
for (int i = 0; i < n; i++) {
View child = container.getChildAt(i);
if (child.isFocused()) {
return true;
}
}
return false;
}
}
以上代码中有一个静态的 init(int tagIdTogetherFocused)
方法,该方法用来设置一个静态的 Tag ID,也就是 View.setTag(int, Object)
方法的第一个参数。前面说过这个 Together Focus 状态需要保存上一次的值,才能与本次比较来判断是否发生了变化,那么这个状态应该保存在哪里呢?应该保存在 ViewGroup 里,它是 ViewGroup 的状态,而不是某个子 View 的。我们可以继承 ViewGroup 并将这个状态保存在一个成员变量中,但这种方式会多出一个自定义类来,而我们仅仅需要的只是一个状态的数据保存。
setTag(int, Object)
能给 View 添加任意的数据,只有一个比较麻烦的规则:第一个参数必须是静态的资源 ID,也就是说,必须得用定义在 xml 资源中的 id 才行。因此为了使这个类更加通用,使用了一个初始化方法让外面传递进来这个 id,这样这个类就不依赖 R
了。
例3 的补充说明
为什么 hasAnyFocusedChild()
方法不直接使用 container.hasFocus()
来判断焦点是否在 ViewGroup 中?
在子 View 的 onFocusChange(View v, boolean hasFocus)
回调执行期间,如果是焦点移出了这个 ViewGroup,ViewGroup.hasFocus()
仍然返回 true,而正确的值应该返回 false。为什么会这样需要查看源码中的失去焦点逻辑,这里大概分析一下:
从 OnFocusChangeListener.onFocusChange(View v, boolean hasFocus)
调用触发,看看调用之前和之后发生了什么导致 ViewGroup.hasFocus()
返回了错误的值。可以在 onFocusChange(...)
回调中创建一个 Exception
对象来查看具体的调用栈。
final TogetherFocusChangeListener listener = new TogetherFocusChangeListener() {
@Override
protected void onTogetherFocusChange(ViewGroup container, View v, boolean focused) {
Log.d(TAG, "onTogetherFocusChange: hasFocus="
+ container.hasFocus() + ", together focused=" + focused,
new Exception()); // 我们在这句 log 后面加一个 Exception 对象来捕获调用栈。
...
}
};
然后移动焦点,使焦点进入水果按钮所在的 ViewGroup,再移出 ViewGroup。如果不看 Exception()
打印的调用栈,可以得到两行log:
D/SecondActivity: onTogetherFocusChange: hasFocus=true, together focused=true
D/SecondActivity: onTogetherFocusChange: hasFocus=true, together focused=false
可以看出,container.hasFocus()
返回了 true
,不是我们想要的结果。
我们再看移出 ViewGroup 范围的操作对应的调用栈:
D/SecondActivity: onTogetherFocusChange: hasFocus=true, together focused=false
java.lang.Exception
at com.ajeyone.focusstudy.SecondActivity$3.onTogetherFocusChange(SecondActivity.java:53)
at com.ajeyone.focusstudy.together.TogetherFocusChangeListener.onFocusChange(TogetherFocusChangeListener.java:24)
at android.view.View.onFocusChanged(View.java:5206)
at android.widget.TextView.onFocusChanged(TextView.java:7913)
at android.view.View.clearFocusInternal(View.java:5089)
at android.view.View.unFocus(View.java:5122)
at android.view.ViewGroup.unFocus(ViewGroup.java:857)
at android.view.ViewGroup.requestChildFocus(ViewGroup.java:658)
at android.view.View.handleFocusGainInternal(View.java:4955)
at android.view.View.requestFocusNoSearch(View.java:7678)
at android.view.View.requestFocus(View.java:7657)
at android.view.ViewRootImpl$ViewPostImeInputStage.processKeyEvent(ViewRootImpl.java:4096)
然后结合源码进行分析(此处省略1000字...),可以找到关键方法 ViewGroup.unFocus()
@Override
void unFocus(View focused) {
if (mFocused == null) {
// ... 略
} else {
mFocused.unFocus(focused); // 先调用了 unFocus() 再执行 mFocused = null;
mFocused = null;
}
}
从 mFocused.unFocus(focused);
开始:
- 首先明确
mFocused
就是之前获得焦点的水果按钮,这时的this
就是水果按钮的容器 ViewGroup。 - 进入
View.unFocus(focused)
方法。 - 然后进入
clearFocusInternal(...)
- 然后进入
onFocusChanged(...)
- 然后进入
OnFocusChangeListener.onFocusChange(...)
- 然后执行我们代码中的
container.hasFocus()
,这个方法源码如下:
public boolean hasFocus() {
return (mPrivateFlags & PFLAG_FOCUSED) != 0 || mFocused != null;
}
前面标志位的判断是判断自身是否获得焦点的意思,在这个场景不需要考虑。关键是后面判断 mFocused != null
,那么此时 mFocused
是否是空呢?我们回到 ViewGroup.unFocus(...)
的源码中,注意此时我们仍在 mFocused.unFocus(focused);
的调用栈内,这句话仍没有返回,下面的那句 mFocused = null;
也还没有执行。因此此时 mFocused
仍然是有值的,所以 ViewGroup.hasFocus()
就返回了 true。
以上的源码分析其实省略了很多分析的步骤,安卓的焦点管理为了保证只有一个 View 保持获焦状态,有很多复杂的逻辑在里面,这里只简单分析了一下关键点,本系列文章还会有其他文章详细分析源码,这里先做个预告:
安卓TV开发之玩转焦点源码分析篇
从焦点的查找、抢占、丢失这几个基础步骤出发,模块化地进行源码分析。
安卓TV开发之玩转焦点场景实战篇
分析完源码我们来从场景出发,总结焦点相关的核心套路,制定一套模块化焦点开发范式。
入门篇完,配套源码请戳:Github传送门
(The End)