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 的展开和通知列表的展开是联动的,但又不是完全同步的。 具体来说:

  1. 当用户从顶部下滑时,先露出 QsPanel(快捷设置),再继续下滑才展开通知列表。
  2. 当用户从中间下滑时,直接展开通知列表,QsPanel 保持折叠状态。

这个逻辑在 NotificationPanelViewonInterceptTouchEvent() 中判断:

// 判断触摸起始位置
if (event.getY() < mQsPanel.getTop()) {
    // 从顶部区域开始滑动 -> 展开 QsPanel
    mQsPanel.expand();
} else {
    // 从中间区域开始滑动 -> 展开通知列表
    mNotificationStackScrollLayout.expand();
}

18.4 QsPanel 的内部结构

QsPanel 本身也是一个可滑动的容器,它内部包含:

  • QsTile 列表:每个快捷开关(WiFi、蓝牙、手电筒等)都是一个 QsTile 对象。
  • QsPanelFooter:底部的「编辑」按钮和「设置」按钮。
  • QsDetailAdapter:点击某个 Tile 后展开的详细设置面板。

我画了一张结构图,帮你理清它们的关系:

NotificationPanelView 与 QsPanel 结构图 NotificationPanelView QsPanel (快捷设置面板) Tile 1 Tile 2 Tile 3 Tile 4 Tile 5 Tile 6 QsPanelFooter (编辑/设置按钮) NotificationStackScrollLayout (通知列表) 通知条目 1 通知条目 2 滑动联动

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 的绘制。如果优化不好,很容易出现掉帧。我个人习惯做两件事:

  1. 使用 Hardware Layer:在滑动开始时,给 NotificationPanelView 设置 LAYER_TYPE_HARDWARE,滑动结束后再恢复为 LAYER_TYPE_NONE
  2. 减少 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 中「滑动面板」这一大类组件的设计思路。

公众号:蓝海资料掘金营,微信deep3321