21、SystemUI的动画与过渡:状态栏展开/折叠的动画,通知列表的入场/退场动画

动画这东西,说白了就是给用户一个「反馈」。你拉下状态栏,如果屏幕瞬间切换,用户会懵掉——我刚才碰了哪里?SystemUI 里的动画,不只是为了好看,更是为了告诉用户:你的操作我收到了,正在处理。

我个人习惯把 SystemUI 的动画分成两类:一类是手势驱动的过渡动画,比如状态栏的展开与折叠;另一类是事件驱动的入场/退场动画,比如通知列表的增删。这两类动画的底层实现逻辑完全不同,咱们一个一个拆开讲。

状态栏展开/折叠的动画

状态栏的展开与折叠,核心是一个叫 ScrimController 的类在背后干活。你可能没直接跟它打过交道,但每次拉下通知栏时看到的半透明遮罩、模糊效果,都是它管的。

展开动画的流程大致是这样:

  1. 手指触摸状态栏区域,触发 onTouchEvent
  2. 系统计算手指滑动的位移,映射到 0~1 的展开比例
  3. ScrimController 根据比例更新遮罩层的 alpha 值
  4. 同时 NotificationPanelView 的 Y 轴偏移跟着变化

嗯,这里要注意:展开比例不是线性的。我刚开始做这块时,直接用了线性插值,结果拉下来感觉特别「死板」。后来看了 Google 的参考实现,才发现他们用了 Interpolator 来做曲线映射。

核心代码片段:

// 状态栏展开动画的核心插值器
public class PanelExpansionInterpolator {
    private static final float SLOW_DOWN_FACTOR = 0.5f;
    
    public static float getExpansionFraction(float rawFraction) {
        // 前 60% 快速展开,后 40% 缓慢收尾
        if (rawFraction < 0.6f) {
            return rawFraction * 1.2f;
        } else {
            return 0.72f + (rawFraction - 0.6f) * 0.7f;
        }
    }
}

为什么这么设计?你想想看,用户拉下状态栏时,前半段是「确认操作」阶段,需要快速响应;后半段是「查看内容」阶段,需要平滑过渡。这个曲线模拟了物理世界的惯性感。

我曾经在一个项目里把插值器改成了匀速,结果测试同事反馈说「感觉像在拉一个没有阻尼的弹簧」。嗯,从那以后我再也不敢乱改插值器了。

通知列表的入场动画

通知列表的入场动画,比状态栏展开要复杂得多。因为通知是动态增删的,你永远不知道下一条通知会出现在哪个位置。

SystemUI 里用的是 LayoutTransition + AnimatorSet 的组合方案。每个通知条目被添加到 NotificationStackScrollLayout 时,会触发一个动画序列:

阶段 动画属性 持续时间 插值器
入场前 alpha: 0, translationY: +40dp 0ms
滑入 translationY: 40dp → 0dp 300ms FastOutSlowIn
淡入 alpha: 0 → 1 200ms Linear
弹性效果 scaleX/Y: 0.95 → 1.0 150ms OvershootInterpolator

我个人习惯把入场动画拆成「位移」和「透明度」两个独立的部分。为什么?因为如果用户同时收到多条通知,位移动画可以保证它们依次滑入,而透明度动画则保证视觉上的平滑过渡。

避坑指南:

我曾经遇到过一个问题:当通知列表已经滚动到中间位置时,新通知从顶部入场,会触发整个列表的重新布局。这时候如果直接用 translationY 动画,新通知会从屏幕外飞进来,而不是从列表顶部滑入。

解决方案是:在动画开始前,先计算新通知在列表中的「视觉位置」,然后根据这个位置决定动画的起始偏移量。

通知列表的退场动画

退场动画比入场更讲究。用户划掉一条通知时,系统需要同时做两件事:移除当前通知,并让后面的通知补上来。

退场动画的流程:

  1. 用户手指滑动通知条目,触发 onSwipe 回调
  2. NotificationSwipeController 计算滑动距离,实时更新条目的 translationX
  3. 当滑动距离超过阈值(通常是条目宽度的 40%),触发退场动画
  4. 退场动画包括:translationX 继续滑出屏幕 + alpha 渐变为 0
  5. 同时,后面的通知条目执行 translationY 动画,填补空缺

退场动画的时序控制:

// 退场动画的启动逻辑
private void startDismissAnimation(View notificationView) {
    // 1. 当前通知滑出
    ObjectAnimator slideOut = ObjectAnimator.ofFloat(
        notificationView, "translationX", 
        notificationView.getWidth() * 1.2f
    );
    slideOut.setDuration(250);
    slideOut.setInterpolator(new FastOutLinearInInterpolator());
    
    // 2. 后续通知补位(延迟 50ms 启动)
    ObjectAnimator slideUp = ObjectAnimator.ofFloat(
        nextNotification, "translationY",
        -notificationView.getHeight()
    );
    slideUp.setStartDelay(50);
    slideUp.setDuration(200);
    slideUp.setInterpolator(new FastOutSlowInInterpolator());
    
    // 3. 组合动画
    AnimatorSet set = new AnimatorSet();
    set.playTogether(slideOut, slideUp);
    set.start();
}

这里有个细节:补位动画的延迟时间。如果延迟太短,用户会感觉通知是「跳」上去的;如果延迟太长,又会觉得卡顿。我经过多次测试发现,50ms 是一个比较舒服的值——既不会让用户觉得拖沓,也不会显得生硬。

动画性能优化

做 SystemUI 动画,最怕的就是掉帧。状态栏展开时如果卡一下,用户会立刻觉得手机「不跟手」。我总结了几条优化经验:

  • 使用硬件层:在动画开始前,把 View 的 setLayerType(LAYER_TYPE_HARDWARE) 打开,动画结束后再切回 LAYER_TYPE_NONE。这样可以避免每一帧都重新绘制。
  • 避免触发布局:动画期间不要调用 requestLayout(),否则会触发 measure/layout 流程,导致掉帧。
  • 使用 ValueAnimator 替代 ObjectAnimator:如果只是更新一个数值(比如透明度),ValueAnimator 的开销更小。
  • 减少过度绘制:通知列表的条目如果背景重叠,会导致 GPU 重复绘制。建议在动画期间把背景设为透明。

警告:

不要在动画过程中频繁创建新的 Animator 对象。我见过一个案例,每次滑动通知都 new 一个 AnimatorSet,结果内存抖动严重,GC 频繁触发,动画直接卡成 PPT。

正确的做法是:复用 Animator 对象,或者使用 ViewPropertyAnimator 这种轻量级方案。

知识体系结构图

下面这张图梳理了本章的核心知识点,从动画类型到底层实现,再到优化手段,一目了然:

SystemUI动画与过渡知识体系 手势驱动的过渡动画 事件驱动的入场/退场动画 ScrimController 插值器曲线 遮罩层alpha Y轴偏移 LayoutTransition AnimatorSet 入场:滑入+淡入 退场:滑出+补位 性能优化 硬件层加速 避免requestLayout 复用Animator 减少过度绘制

做 SystemUI 动画,说白了就是在「用户体验」和「性能」之间找平衡。动画太复杂,手机扛不住;动画太简单,用户觉得廉价。我个人建议:先保证流畅度,再追求视觉效果。一个 60fps 的简单动画,远比一个掉帧的复杂动画更讨人喜欢。


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