一点见解: 焦点那点事(二)

上一篇文章, 一点见解: 焦点那点事(一), 了解了焦点相关的一些基本知识, 提到焦点切换的关键方法ViewParent#focusSearch, 本文接着看, 焦点是从什么时候产生的, 又是如何在控件间切换的, 当控件被移除或者新增进布局时焦点又会发生什么变化.

焦点产生

页面创建出来后, 什么时候开始分发焦点?
关于页面创建流程的和绘制过程的文章有很多, 这里不再累述, 通过这些文章, 我们可以知道页面控件的绘制入口是ViewRootImpl#performTraversals方法.

在这个方法中, 如果是第一次执行这个方法, 同时, ViewRoot相关联的DecorView没有焦点控件, 那么就会调用DecorView#requestFocus, 实际上也就是调用了ViewGroup#requestFocus, 上一篇文章一点见解: 焦点那点事(一)介绍过, 在这个方法里, 会遍历子控件, 执行View#requestFocus直到某个控件持有焦点.

疑问: home键退出页面, 然后返回时, 如果当前页面没有焦点, 还会走一次requestFocus, 这种情况是哪里触发的?

焦点切换

虽然在触摸模式也能产生焦点, 但是一般不会用到, 因此这里着重分析通过键盘操作来切换焦点的情况.

起点

既然是通过键盘切换焦点, 因此从键盘事件开始入手.
关于输入事件的处理流程已经有很多文章了, 这个也不是本文关注的重点, 因此不再累述, 可以参考原来Android触控机制竟是这样的?.

概括起来就是

  1. ViewRootImpl通过一个Receiver接收硬件发送过来的事件(包括触摸事件和键盘事件)
  2. 然后ViewRootImpl会把这些事件放在队列中
  3. 然后再按顺序取出这些事件通过InputStage相关类分发出去, 最后会执行InputStage#onProcess()方法
  4. 其中在ViewPostImeInputStage类中, 如果输入的事件是键盘事件, 那么就会调用ViewPostImeInputStage#processKeyEvent()方法

processKeyEvent()

在这个方法里, 会先把事件传递给ViewGroup#dispatchKeyEvent()方法, 如果这个方法没有消费掉这个事件, 并且这个事件是方向事件按下事件, 例如KeyEvent.KEYCODE_DPAD_LEFT等, 那么就会触发焦点切换, 也就是focusSearch方法.

ViewGroup#dispatchKeyEvent()

首先看这个方法, 因为在ViewRootImpl中持有的是DecorView, 它本质上是一个FrameLayout, 因此分发键盘事件时实际调用的会是ViewGroup#dispatchKeyEvent().

在这个方法里

  1. 如果这个ViewGroup持有焦点, 那么就会直接调用View#dispatchKeyEvent
  2. 如果是它的子控件持有焦点, 那么就会调用子控件View#dispatchKeyEvent

View#dispatchKeyEvent里面

  1. 询问OnKeyListener是否消费这个事件
  2. 消费确认相关的按键事件, 例如KeyEvent.KEYCODE_DPAD_CENTER

由上可以知道, 一般情况下, ViewGroup#dispatchKeyEvent()只会消费确认事件, 方向事件是会继续执行下一步的.

触发焦点切换

方向事件按下事件表明, 在按下的时候就会触发焦点切换了, 这解释了为什么长按方向键会一直切换焦点.

焦点切换时

  1. 如果当前已经存在焦点, 那么就调用当前焦点控件的View#focusSearch(int), 这个方法又会马上调用ViewParent#focusSearch(View, int)方法, 注意区分这两个方法, 虽然同名, 但不是同一个方法.
  2. 如果不存在焦点, 那么就会调用ViewRootImpl#focusSearch, 这个方法直接调用了FocusFinder#findNextFocus来查找合适的控件
  3. 当找到具体的控件后, 就会调用该控件的requestFocus方法

这个过程说明

  1. 按下方向键时, 如果没有控件持有焦点, 那么我们不能控制候选控件的选择
  2. 按下方向键时, 如果有控件持有焦点, 那么可以通过重写这个控件的父控件ViewParent#focusSearch来控制候选控件的选择
  3. 无论是如何得到候选控件, 这个控件是通过requestFocus来获取焦点的, 后续流程参考一点见解: 焦点那点事(一)

焦点控件失去焦点资格

上一篇文章提到控件要获取焦点必须符合

  1. View#isFocusable返回true, 如果在触摸模式, 则View#isFocusableInTouchMode也要返回true
  2. 控件必须可见
  3. 控件相关的父控件, 包括祖父控件等, ViewGroup#getDescendantFocusability()不能为ViewGroup#FOCUS_BLOCK_DESCENDANTS

unFocusable和unVisibility

改变控件的这两个状态, 最终会调用View#setFlags方法, 在该方法中, 如果焦点控件是变为了不可见或者不可获取焦点, 那么就会调用View#clearFocus来清除焦点, 跟手动清除焦点流程一样.

FOCUS_BLOCK_DESCENDANTS

如果父控件突然变为了FOCUS_BLOCK_DESCENDANTS, 不会影响当前焦点控件的状态, 只会影响下一次焦点分发/查找的流程.

焦点控件被移除

控件被移除, 最终都会调用ViewGroup#removeViewInternal方法, 在这个方法中, 首先会调用View#unFocus来清除焦点, 具体参考上一篇文章的介绍, 因为View#unFocus方法不会调用ViewParent#clearChildFocus, 因此ViewGroup会主动调用自己的clearChildFocus方法, 紧接着会调用View#rootViewRequestFocus方法, 在这个方法中会调用getRootView()#requestFocus, 然后就会遍历一次控件树来重新分发焦点.

控件获得焦点资格

和失去焦点资格类似, 最终会调用View#setFlags方法, 然后调用ViewParent#focusableViewAvailable方法, 默认实现中会一直向上级父控件传递, 最终就会调用ViewRootImpl#focusableViewAvailable方法, 在这个方法中, 两种情况下这个新控件可以获得焦点

  1. 如果当前没有焦点控件, 那么就会调用这个新获得焦点资格的控件的requestFocus方法
  2. 如果当前有焦点控件, 同时新的这个控件是当前焦点控件的子控件, 而这个焦点控件的焦点分发策略为FOCUS_AFTER_DESCENDANTS, 那么还是会调用requestFocus来把焦点给这个新的控件

新增控件(有焦点资格)

通过addView方式添加控件, 都会调用ViewGroup#addViewInner方法, 在这个方法中, 如果新增的控件的hasFocus方法为true, 那么就会调用父控件的ViewParent#requestChildFocus, 参考上一篇文章可以知道, 在这个方法里会把现有的焦点控件的焦点清除掉. 也就是说, 新增的控件如果持有焦点, 那么就会替换现有的控件成为焦点控件.

如果新增的控件没有持有焦点, 即使它有焦点资格, 也不会有任何焦点相关的回调

注意: 新增(addView)控件时, 无论这个控件会不会获得焦点, ViewParent#focusableViewAvailable都不会被调用.

总结

  1. 页面第一次刷新布局时会通过根控件的requestFocus来寻找第一个焦点控件
  2. 当键盘输入方向事件时, 页面会通过ViewParent#focusSearch来寻找下一个焦点控件, 并调用它的requestFocus方法
  3. 焦点控件的可见性或者focusable属性发生变化, 导致该控件不能继续持有焦点, 那么就会清除焦点, 并重新通过根控件的requestFocus来分发焦点
  4. 当控件从不能持有焦点变为可以持有焦点, 会触发ViewParent#focusableViewAvailable, 并在两种情况下会替换旧焦点控件.
  5. 当焦点控件从布局中移除, 会重新通过根控件的requestFocus来分发焦点
  6. 当可以获取焦点的控件新增进布局时, 不会调用ViewParent#focusableViewAvailable, 如果该控件被加入布局前已经持有焦点, 那么就会替换旧焦点控件, 否则就不会触发焦点相关方法.

RecyclerView是一个非常常用的控件, 其中列表中的子控件会复用/移除/新增等, 因此焦点的处理也比较特殊, 下一篇会详细分析RecyclerView的焦点处理逻辑, 以此得到移除焦点控件后重新分发焦点的解决方案.

推荐阅读更多精彩内容