18、通知栏(NotificationPanel)的展开与折叠:NotificationPanelView的滑动逻辑,QsPanel的快捷设置
通知栏的展开与折叠,说白了就是用户手指在屏幕上的一次上下滑动。但背后涉及的逻辑,远比你想象的要复杂。我个人习惯把这一块称为「系统UI的咽喉」——因为几乎所有用户与系统的交互,都要经过这里。
18.1 NotificationPanelView 的滑动机制
先来看核心类 NotificationPanelView。它继承自 PanelView,而 PanelView 本身就是一个可滑动的容器。我刚开始接触这部分代码时,第一反应是「这不就是个 ScrollView 吗?」——嗯,还真不是。
核心区别:NotificationPanelView 的滑动不是简单的滚动,而是「展开/折叠」两个状态之间的切换。它内部维护了一个 mExpandedFraction 变量,范围 0.0 到 1.0,表示当前展开的程度。
滑动逻辑的核心代码在 onTouchEvent() 中。我截取一段关键逻辑给你看:
// NotificationPanelView.java - 简化版
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// 记录起始位置
mInitialTouchY = event.getY();
mInitialTouchX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
float currentY = event.getY();
float deltaY = currentY - mInitialTouchY;
// 根据滑动距离计算展开比例
float newFraction = mExpandedFraction + (deltaY / mPanelHeight);
// 限制范围 0~1
newFraction = Math.max(0f, Math.min(1f, newFraction));
setExpandedFraction(newFraction);
break;
case MotionEvent.ACTION_UP:
// 判断是展开还是折叠
if (mExpandedFraction > 0.5f) {
expand();
} else {
collapse();
}
break;
}
return true;
}
这里有个细节:为什么用 mPanelHeight 做分母? 因为通知栏的高度在不同设备上不一样,用固定像素值会出问题。我曾经在适配平板时踩过这个坑——平板的通知栏高度是手机的 1.5 倍,结果滑动比例完全不对。
18.2 滑动过程中的动画与插值
光有滑动比例还不够,用户期望的是「跟手」的体验。这里涉及两个关键点:
- 阻尼效果:当用户快速滑动时,通知栏应该有一个「惯性」效果。这个通过
VelocityTracker计算速度,然后交给OverScroller做 fling 动画。 - 弹性效果:当用户拉到顶部或底部时,应该有一个「弹一下」的感觉。这个通过
SpringAnimation实现,Android 10 之后引入了物理动画引擎。
我的经验:阻尼系数不要调太大。我见过一些 ROM 把阻尼设得特别重,用户滑起来感觉像在拖一块石头。建议阻尼系数在 0.6~0.8 之间,既跟手又不失控制感。
18.3 QsPanel 的快捷设置面板
QsPanel(Quick Settings Panel)是通知栏折叠时露出的那一块快捷开关区域。它和 NotificationPanelView 的关系是「父子关系」——QsPanel 是 NotificationPanelView 的一个子 View。
但这里有个设计上的巧妙之处:QsPanel 的展开和通知列表的展开是联动的,但又不是完全同步的。 具体来说:
- 当用户从顶部下滑时,先露出 QsPanel(快捷设置),再继续下滑才展开通知列表。
- 当用户从中间下滑时,直接展开通知列表,QsPanel 保持折叠状态。
这个逻辑在 NotificationPanelView 的 onInterceptTouchEvent() 中判断:
// 判断触摸起始位置
if (event.getY() < mQsPanel.getTop()) {
// 从顶部区域开始滑动 -> 展开 QsPanel
mQsPanel.expand();
} else {
// 从中间区域开始滑动 -> 展开通知列表
mNotificationStackScrollLayout.expand();
}
18.4 QsPanel 的内部结构
QsPanel 本身也是一个可滑动的容器,它内部包含:
- QsTile 列表:每个快捷开关(WiFi、蓝牙、手电筒等)都是一个
QsTile对象。 - QsPanelFooter:底部的「编辑」按钮和「设置」按钮。
- QsDetailAdapter:点击某个 Tile 后展开的详细设置面板。
我画了一张结构图,帮你理清它们的关系:
18.5 滑动冲突的处理
这里有个经典问题:QsPanel 和通知列表的滑动冲突怎么解决?
你想想看,当用户手指在 QsPanel 区域滑动时,到底是展开 QsPanel 还是滚动通知列表?Android 的解决方案是:
- 垂直方向:QsPanel 优先消费滑动事件。只有当 QsPanel 完全展开后,滑动事件才传递给通知列表。
- 水平方向:通知列表内部如果有横向滑动的通知(比如滑动删除),则通知列表优先消费。
这个逻辑在 onInterceptTouchEvent() 中通过 mQsPanel.isExpanded() 判断:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_MOVE) {
float deltaY = event.getY() - mInitialTouchY;
// 如果 QsPanel 还没完全展开,优先给它
if (!mQsPanel.isFullyExpanded() && deltaY < 0) {
return true; // 拦截事件,交给 QsPanel
}
}
return super.onInterceptTouchEvent(event);
}
注意:这里有个边界情况——当 QsPanel 已经展开到最大时,继续上滑应该折叠通知列表而不是 QsPanel。我曾经在某个定制 ROM 中忘记处理这个边界,结果用户上滑时 QsPanel 和通知列表一起动,画面非常诡异。
18.6 性能优化:避免过度绘制
通知栏的滑动动画涉及大量 View 的绘制。如果优化不好,很容易出现掉帧。我个人习惯做两件事:
- 使用 Hardware Layer:在滑动开始时,给 NotificationPanelView 设置
LAYER_TYPE_HARDWARE,滑动结束后再恢复为LAYER_TYPE_NONE。 - 减少 measure/layout 次数:滑动过程中只改变
translationY属性,不触发重新布局。
// 滑动开始时
panelView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
// 滑动过程中只改 translationY
panelView.setTranslationY(currentY);
// 滑动结束后恢复
panelView.setLayerType(View.LAYER_TYPE_NONE, null);
嗯,这里要注意:setLayerType 的调用是有开销的,不要频繁切换。我一般只在手指按下和抬起时各调用一次。
18.7 总结
通知栏的展开与折叠,看似简单,实则涉及事件分发、动画插值、滑动冲突、性能优化等多个方面。核心要点就三个:
- 滑动比例计算:用
mExpandedFraction统一管理展开程度 - QsPanel 与通知列表的联动:通过触摸起始位置判断展开哪个
- 滑动冲突处理:QsPanel 优先消费垂直滑动事件
把这些搞明白了,你基本就掌握了系统 UI 中「滑动面板」这一大类组件的设计思路。