安卓TV开发之玩转焦点入门篇

简介

在安卓电视的使用过程中,按键走焦是主要的交互方式,因此 TV 开发中会写很多焦点相关的代码。安卓系统内部的焦点管理是建立在 View 系统之上的,有默认的处理方式,不需要过多关注也能满足一些简单的需求,但更精确和细致地控制焦点需要对焦点管理有深入的了解。本文从易到难简述 TV 开发过程中的焦点显示、控制和管理,适合有一定安卓基础的初学 TV 开发的同学学习。配套源码请戳:Github传送门

焦点的基本使用

如何启用焦点

不是所有的 View 默认都可以获焦,一个 View 想要获得焦点,focusable (可获焦的) 属性需要等于 true。 默认情况下,ButtonEditTextRadioButtonCheckBox 等输入控件是可获焦的,而 TextViewImageViewView 等展示用的控件是不可获焦的。

可显式地设置 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 的 selectedpressedactivated 状态一样,可以在资源文件中定义不同状态的样式,然后设置给 View 的 backgroundtextColor 等属性。

<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种样式,先看下效果图:

全部6种样式

按效果图所示,我们给 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 都可用的状态有 selectedactivated,其他状态例如 pressed 是系统使用的我们最好不要直接调用 setPressed()checked 状态是实现了 Checkable 接口的 View 才有的,例如 CheckBoxRadioButton 等。只有 selectedactivated 这两个状态是所有 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 作为卡片,内部填入一些 ImageViewTextView,代码如下:

<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)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容