上一篇文章, 一点见解: 焦点那点事(一), 了解了焦点相关的一些基本知识, 提到焦点切换的关键方法ViewParent#focusSearch
, 本文接着看, 焦点是从什么时候产生的, 又是如何在控件间切换的, 当控件被移除或者新增进布局时焦点又会发生什么变化.
焦点产生
页面创建出来后, 什么时候开始分发焦点?
关于页面创建流程的和绘制过程的文章有很多, 这里不再累述, 通过这些文章, 我们可以知道页面控件的绘制入口是ViewRootImpl#performTraversals
方法.
在这个方法中, 如果是第一次执行这个方法, 同时, ViewRoot
相关联的DecorView
没有焦点控件, 那么就会调用DecorView#requestFocus
, 实际上也就是调用了ViewGroup#requestFocus
, 上一篇文章一点见解: 焦点那点事(一)介绍过, 在这个方法里, 会遍历子控件, 执行View#requestFocus
直到某个控件持有焦点.
疑问: home键退出页面, 然后返回时, 如果当前页面没有焦点, 还会走一次
requestFocus
, 这种情况是哪里触发的?
焦点切换
虽然在触摸模式也能产生焦点, 但是一般不会用到, 因此这里着重分析通过键盘操作来切换焦点的情况.
起点
既然是通过键盘切换焦点, 因此从键盘事件开始入手.
关于输入事件的处理流程已经有很多文章了, 这个也不是本文关注的重点, 因此不再累述, 可以参考原来Android触控机制竟是这样的?.
概括起来就是
-
ViewRootImpl
通过一个Receiver
接收硬件发送过来的事件(包括触摸事件和键盘事件) - 然后
ViewRootImpl
会把这些事件放在队列中 - 然后再按顺序取出这些事件通过
InputStage
相关类分发出去, 最后会执行InputStage#onProcess()
方法 - 其中在
ViewPostImeInputStage
类中, 如果输入的事件是键盘事件, 那么就会调用ViewPostImeInputStage#processKeyEvent()
方法
processKeyEvent()
在这个方法里, 会先把事件传递给ViewGroup#dispatchKeyEvent()
方法, 如果这个方法没有消费掉这个事件, 并且这个事件是方向事件的按下事件, 例如KeyEvent.KEYCODE_DPAD_LEFT
等, 那么就会触发焦点切换, 也就是focusSearch
方法.
ViewGroup#dispatchKeyEvent()
首先看这个方法, 因为在ViewRootImpl
中持有的是DecorView
, 它本质上是一个FrameLayout
, 因此分发键盘事件时实际调用的会是ViewGroup#dispatchKeyEvent()
.
在这个方法里
- 如果这个
ViewGroup
持有焦点, 那么就会直接调用View#dispatchKeyEvent
- 如果是它的子控件持有焦点, 那么就会调用子控件的
View#dispatchKeyEvent
在View#dispatchKeyEvent
里面
- 询问
OnKeyListener
是否消费这个事件 - 消费确认相关的按键事件, 例如
KeyEvent.KEYCODE_DPAD_CENTER
等
由上可以知道, 一般情况下, ViewGroup#dispatchKeyEvent()
只会消费确认事件, 方向事件是会继续执行下一步的.
触发焦点切换
方向事件的按下事件表明, 在按下的时候就会触发焦点切换了, 这解释了为什么长按方向键会一直切换焦点.
焦点切换时
- 如果当前已经存在焦点, 那么就调用当前焦点控件的
View#focusSearch(int)
, 这个方法又会马上调用ViewParent#focusSearch(View, int)
方法, 注意区分这两个方法, 虽然同名, 但不是同一个方法. - 如果不存在焦点, 那么就会调用
ViewRootImpl#focusSearch
, 这个方法直接调用了FocusFinder#findNextFocus
来查找合适的控件 - 当找到具体的控件后, 就会调用该控件的
requestFocus
方法
这个过程说明
- 按下方向键时, 如果没有控件持有焦点, 那么我们不能控制候选控件的选择
- 按下方向键时, 如果有控件持有焦点, 那么可以通过重写这个控件的父控件的
ViewParent#focusSearch
来控制候选控件的选择- 无论是如何得到候选控件, 这个控件是通过
requestFocus
来获取焦点的, 后续流程参考一点见解: 焦点那点事(一)
焦点控件失去焦点资格
上一篇文章提到控件要获取焦点必须符合
View#isFocusable
返回true
, 如果在触摸模式, 则View#isFocusableInTouchMode
也要返回true
- 控件必须可见
- 控件相关的父控件, 包括祖父控件等,
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
方法, 在这个方法中, 两种情况下这个新控件可以获得焦点
- 如果当前没有焦点控件, 那么就会调用这个新获得焦点资格的控件的
requestFocus
方法 - 如果当前有焦点控件, 同时新的这个控件是当前焦点控件的子控件, 而这个焦点控件的焦点分发策略为
FOCUS_AFTER_DESCENDANTS
, 那么还是会调用requestFocus
来把焦点给这个新的控件
新增控件(有焦点资格)
通过addView
方式添加控件, 都会调用ViewGroup#addViewInner
方法, 在这个方法中, 如果新增的控件的hasFocus
方法为true
, 那么就会调用父控件的ViewParent#requestChildFocus
, 参考上一篇文章可以知道, 在这个方法里会把现有的焦点控件的焦点清除掉. 也就是说, 新增的控件如果持有焦点, 那么就会替换现有的控件成为焦点控件.
如果新增的控件没有持有焦点, 即使它有焦点资格, 也不会有任何焦点相关的回调
注意: 新增(addView)控件时, 无论这个控件会不会获得焦点,
ViewParent#focusableViewAvailable
都不会被调用.
总结
- 页面第一次刷新布局时会通过根控件的
requestFocus
来寻找第一个焦点控件 - 当键盘输入方向事件时, 页面会通过
ViewParent#focusSearch
来寻找下一个焦点控件, 并调用它的requestFocus
方法 - 当焦点控件的可见性或者focusable属性发生变化, 导致该控件不能继续持有焦点, 那么就会清除焦点, 并重新通过根控件的
requestFocus
来分发焦点 - 当控件从不能持有焦点变为可以持有焦点, 会触发
ViewParent#focusableViewAvailable
, 并在两种情况下会替换旧焦点控件. - 当焦点控件从布局中移除, 会重新通过根控件的
requestFocus
来分发焦点 - 当可以获取焦点的控件新增进布局时, 不会调用
ViewParent#focusableViewAvailable
, 如果该控件被加入布局前已经持有焦点, 那么就会替换旧焦点控件, 否则就不会触发焦点相关方法.
RecyclerView
是一个非常常用的控件, 其中列表中的子控件会复用/移除/新增等, 因此焦点的处理也比较特殊, 下一篇会详细分析RecyclerView
的焦点处理逻辑, 以此得到移除焦点控件后重新分发焦点的解决方案.