16、导航栏(NavigationBar)深度解析:三键导航与手势导航的切换逻辑,KeyEvent的分发
导航栏这东西,说白了就是用户和系统交互的“最后一公里”。我做了这么多年Android框架,每次看到导航栏的代码都忍不住感慨——这玩意儿看着简单,里面的坑是真不少。今天咱们就把它彻底扒开看看。
16.1 三键导航 vs 手势导航:两种模式的本质
先问个问题:为什么Android要搞两套导航方案?
我个人习惯把这个问题拆成两个层面来看:
- 用户层面:三键导航是“点按”思维,手势导航是“滑动”思维
- 系统层面:三键导航靠KeyEvent,手势导航靠TouchEvent
嗯,这里要注意,这两种模式在SystemUI里是互斥的。你不可能同时启用两套方案,否则用户会疯掉。
核心结论:三键导航和手势导航的切换,本质上是SystemUI中NavigationBarFragment的替换过程。
16.2 切换逻辑:从Settings到SystemUI的完整链路
我记得第一次看这个流程时,差点被绕晕。咱们一步步拆解:
- 用户设置:在Settings里选择“手势导航”或“三键导航”
- Settings写入:将选择写入Settings.System的
navigation_mode字段 - SystemUI监听:NavigationModeController监听这个字段的变化
- Fragment替换:NavigationBarFragment根据模式加载不同的布局
来看关键代码:
// NavigationModeController.java
public int getNavigationMode(Context context) {
int mode = Settings.System.getInt(
context.getContentResolver(),
Settings.System.NAVIGATION_MODE,
NAVIGATION_MODE_GESTURAL // 默认手势导航
);
return mode;
}
避坑指南:我曾经在项目里遇到一个问题——切换导航模式后,导航栏没有立即刷新。后来发现是ContentObserver没有正确注册。记住,一定要在onAttach里注册监听,在onDetach里取消。
16.3 三键导航的KeyEvent分发机制
三键导航的核心就是三个虚拟按键:Back、Home、Recent。每个按键对应一个KeyEvent。
分发流程是这样的:
- 用户点击导航栏上的某个按键
- NavigationBarView捕获到点击事件
- 通过KeyEvent将事件发送到WindowManagerService
- WMS根据当前焦点窗口决定如何处理
举个例子,Back键的分发:
// NavigationBarView.java
public boolean onTouchEvent(MotionEvent event) {
// 检测到Back键被点击
if (isBackKeyTouched(event)) {
// 构造KeyEvent
KeyEvent keyEvent = new KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_BACK
);
// 发送到WMS
mWindowManager.injectInputEvent(keyEvent,
InputDevice.SOURCE_CLASS_BUTTON);
}
return super.onTouchEvent(event);
}
注意:这里有个细节——三键导航的KeyEvent是注入到系统输入管道里的,而不是直接调用某个方法。这样做的好处是,所有窗口都能公平地接收到事件,包括第三方应用。
16.4 手势导航的TouchEvent处理
手势导航就完全不一样了。它不产生KeyEvent,而是直接处理TouchEvent。
我画了一张图,帮你理解手势导航的核心逻辑:
手势导航的核心类在SystemUI里叫GestureNavigationView。它内部维护了一个GestureDetector,用来识别各种手势:
// GestureNavigationView.java
public boolean onTouchEvent(MotionEvent event) {
// 交给GestureDetector处理
mGestureDetector.onTouchEvent(event);
return true; // 消费事件,不让下层处理
}
private class GestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onSwipeUp(MotionEvent e1, MotionEvent e2) {
// 上滑 → 回到主页
performHomeAction();
return true;
}
@Override
public boolean onSwipeLeft(MotionEvent e1, MotionEvent e2) {
// 左滑 → 返回
performBackAction();
return true;
}
}
关键点:手势导航的TouchEvent是被消费的。一旦手势被识别,事件就不会再往下传递。这就是为什么你在手势导航模式下,无法在屏幕底部区域触发其他触摸事件。
16.5 两种模式的共存与切换
你可能会问:系统怎么知道当前该用哪种模式?
答案在NavigationBarFragment里。这个Fragment会根据NavigationModeController返回的模式,动态加载不同的布局:
// NavigationBarFragment.java
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container,
Bundle savedInstanceState) {
int mode = mNavigationModeController.getNavigationMode(getContext());
if (mode == NAVIGATION_MODE_GESTURAL) {
// 加载手势导航布局
return inflater.inflate(
R.layout.navigation_bar_gestural, container, false);
} else {
// 加载三键导航布局
return inflater.inflate(
R.layout.navigation_bar_3button, container, false);
}
}
个人经验:我在做定制ROM时,遇到过一个问题——切换模式后,导航栏的高度没有自适应。三键导航需要更高的导航栏(因为要放三个按钮),而手势导航只需要一个窄条。后来发现是NavigationBarFrame的onMeasure没有根据模式动态调整高度。解决方案是在onCreateView之后,重新请求布局。
16.6 KeyEvent分发的完整链路
最后,咱们把KeyEvent的分发链路串起来。不管你是三键导航还是手势导航,最终都会落到这个链路上:
| 层级 | 组件 | 职责 |
|---|---|---|
| 1 | InputReader | 从硬件读取输入事件 |
| 2 | InputDispatcher | 分发事件到目标窗口 |
| 3 | WindowManagerService | 确定焦点窗口,处理系统按键 |
| 4 | PhoneWindow | 将KeyEvent分发给Activity |
| 5 | Activity/View | 处理或消费事件 |
这里有个细节:系统按键(比如Back键)在WMS层就会被拦截,不会直接发给Activity。除非Activity通过onKeyDown或dispatchKeyEvent主动处理。
避坑指南:我曾经遇到一个bug——某个应用在后台播放视频时,用户按Back键无法退出。排查后发现,是应用在onKeyDown里消费了Back事件,但没有调用super.onKeyDown。记住,如果你要拦截Back键,一定要确保在合适的时机调用super方法,否则系统无法执行默认的返回操作。
好了,导航栏的深度解析就到这里。三键导航和手势导航的切换逻辑,说白了就是Fragment的替换 + 输入事件类型的切换。KeyEvent的分发链路虽然长,但只要抓住WMS这个核心节点,就不会迷路。
公众号:蓝海资料掘金营,微信deep3321