返回 Android
Android
8 分钟阅读

处理 CoordinatorLayout 动画抖动问题

AppBarLayout 嵌套滚动场景下惯性滑动与手势冲突的根因分析与 Behavior 修复方案

CoordinatorLayout + AppBarLayout + RecyclerView / NestedScrollView 的折叠头部布局中,快速甩动(fling)列表后立刻按住屏幕反向拖拽,顶部 AppBar 常出现来回弹跳、位置抖动的现象。这不是渲染性能问题,而是 AppBarLayout.Behavior 内部 fling 动画与嵌套滚动手势在状态机上互相抢占 导致的。

本文从事件分发链路入手,说明抖动成因,并给出通过自定义 Behavior 拦截冲突滚动的修复方案。

典型场景与复现路径

布局结构通常如下:

<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
 
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="com.example.behavior.FixedBehavior">
 
        <androidx.appcompat.widget.Toolbar ... />
    </com.google.android.material.appbar.AppBarLayout>
 
    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

复现步骤(命中率较高):

  1. 快速向上 fling 列表,AppBar 随惯性继续折叠;
  2. 在惯性尚未结束时,手指按下并反向拖拽列表;
  3. AppBar 在「fling 目标位置」与「手指拖拽位置」之间反复修正,产生肉眼可见的抖动。

该问题在 Sticky 头部、Tab 吸顶、CollapsingToolbar 等需要 AppBar 与内容区联动的页面中尤为明显。相关讨论可参考 知乎:折叠效果 CoordinatorLayout纹身机主页抖动案例

根因分析

AppBarLayout.Behavior 继承自 HeaderBehavior,内部通过 OverScroller 驱动 fling 动画,同时实现 NestedScrollingParent 接口响应子 View 的嵌套滚动。

冲突发生在以下时序:

阶段Behavior 内部状态用户操作
T1OverScroller 正在执行 fling,flingRunnable 持续更新 AppBar offset
T2fling 尚未结束手指 ACTION_DOWN,开始反向拖拽
T3fling 与 onNestedPreScroll 同时消费位移AppBar offset 被两个来源交替写入

核心矛盾:旧的 fling 任务没有被及时取消,新手势触发的嵌套滚动事件仍被 Behavior 处理,两套位移计算在同一帧内竞争,表现为 AppBar 弹跳抖动。

Material 组件库未对外暴露「停止 fling」的公开 API,因此社区方案普遍通过反射访问 HeaderBehavior 内部的 flingRunnablescroller 字段,在关键回调节点强制中断惯性动画。

修复思路

自定义 FixedBehavior 继承 AppBarLayout.Behavior,在三个时机介入:

  1. onInterceptTouchEvent(ACTION_DOWN):用户手指落点时,立即停止正在进行的 fling;
  2. onStartNestedScroll:子 View 开始嵌套滚动前,再次确保 fling 已终止;
  3. onNestedPreScroll / onNestedScroll(type == TYPE_FLING):fling 惯性滚动期间,阻止 Behavior 继续消费嵌套滚动位移,避免双重计算。

状态流转如下:

实现代码

package com.example.behavior;
 
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.OverScroller;
 
import androidx.annotation.NonNull;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
 
import com.google.android.material.appbar.AppBarLayout;
 
import java.lang.reflect.Field;
 
/**
 * 修复 AppBarLayout 在 fling 与手势拖拽交替时产生的抖动。
 * 原理:在触摸落点与嵌套滚动开始时强制终止 OverScroller,
 * 并在 fling 类型的嵌套滚动中屏蔽 Behavior 的位移消费。
 */
public class FixedBehavior extends AppBarLayout.Behavior {
 
    private static final int TYPE_FLING = 1;
 
    private boolean isFlinging;
    private boolean shouldBlockNestedScroll;
 
    public FixedBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
    @Override
    public boolean onInterceptTouchEvent(
            @NonNull CoordinatorLayout parent,
            @NonNull AppBarLayout child,
            MotionEvent ev) {
        shouldBlockNestedScroll = isFlinging;
        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
            stopAppbarLayoutFling(child);
        }
        return super.onInterceptTouchEvent(parent, child, ev);
    }
 
    @Override
    public boolean onStartNestedScroll(
            @NonNull CoordinatorLayout parent,
            @NonNull AppBarLayout child,
            @NonNull View directTargetChild,
            View target,
            int nestedScrollAxes,
            int type) {
        stopAppbarLayoutFling(child);
        return super.onStartNestedScroll(
                parent, child, directTargetChild, target, nestedScrollAxes, type);
    }
 
    @Override
    public void onNestedPreScroll(
            CoordinatorLayout coordinatorLayout,
            @NonNull AppBarLayout child,
            View target,
            int dx, int dy,
            int[] consumed,
            int type) {
        if (type == TYPE_FLING) {
            isFlinging = true;
        }
        if (!shouldBlockNestedScroll) {
            super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
        }
    }
 
    @Override
    public void onNestedScroll(
            @NonNull CoordinatorLayout coordinatorLayout,
            @NonNull AppBarLayout child,
            @NonNull View target,
            int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed,
            int type) {
        if (!shouldBlockNestedScroll) {
            super.onNestedScroll(coordinatorLayout, child, target,
                    dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type);
        }
    }
 
    @Override
    public void onStopNestedScroll(
            CoordinatorLayout coordinatorLayout,
            @NonNull AppBarLayout abl,
            View target,
            int type) {
        super.onStopNestedScroll(coordinatorLayout, abl, target, type);
        isFlinging = false;
        shouldBlockNestedScroll = false;
    }
 
    // ---- 反射终止 fling(Material 库未提供公开 API)----
 
    private void stopAppbarLayoutFling(AppBarLayout appBarLayout) {
        try {
            Field flingRunnableField = getFlingRunnableField();
            Field scrollerField = getScrollerField();
            if (flingRunnableField != null) {
                flingRunnableField.setAccessible(true);
            }
            if (scrollerField != null) {
                scrollerField.setAccessible(true);
            }
 
            Runnable flingRunnable = flingRunnableField != null
                    ? (Runnable) flingRunnableField.get(this)
                    : null;
            OverScroller overScroller = scrollerField != null
                    ? (OverScroller) scrollerField.get(this)
                    : null;
 
            if (flingRunnable != null) {
                appBarLayout.removeCallbacks(flingRunnable);
                flingRunnableField.set(this, null);
            }
            if (overScroller != null && !overScroller.isFinished()) {
                overScroller.abortAnimation();
            }
        } catch (NoSuchFieldException | IllegalAccessException e) {
            // 字段名随 Material 版本变化,获取失败时降级为仅屏蔽嵌套滚动
        }
    }
 
    /**
     * support 27 及以前:mFlingRunnable;28+:flingRunnable
     */
    private Field getFlingRunnableField() throws NoSuchFieldException {
        Class<?> headerBehavior = getHeaderBehaviorClass();
        if (headerBehavior == null) return null;
        try {
            return headerBehavior.getDeclaredField("mFlingRunnable");
        } catch (NoSuchFieldException e) {
            return headerBehavior.getDeclaredField("flingRunnable");
        }
    }
 
    /**
     * support 27 及以前:mScroller;28+:scroller
     */
    private Field getScrollerField() throws NoSuchFieldException {
        Class<?> headerBehavior = getHeaderBehaviorClass();
        if (headerBehavior == null) return null;
        try {
            return headerBehavior.getDeclaredField("mScroller");
        } catch (NoSuchFieldException e) {
            return headerBehavior.getDeclaredField("scroller");
        }
    }
 
    private Class<?> getHeaderBehaviorClass() {
        Class<?> clazz = getClass().getSuperclass(); // AppBarLayout.BaseBehavior
        if (clazz != null) {
            clazz = clazz.getSuperclass(); // HeaderBehavior
        }
        return clazz;
    }
}

XML 绑定

layout_behavior 必须声明在 AppBarLayout 上,而非 RecyclerView

<com.google.android.material.appbar.AppBarLayout
    ...
    app:layout_behavior="com.example.behavior.FixedBehavior">

RecyclerView 仍使用默认的 appbar_scrolling_view_behavior,两者分工不变:内容区负责分发嵌套滚动事件,AppBar 侧的 FixedBehavior 负责消费并修正冲突。

上线注意事项

ProGuard / R8 混淆

FixedBehavior 通过反射访问 HeaderBehavior 的私有字段。若类名或字段名被混淆/裁剪,Release 包中反射会静默失败,表现为Debug 正常、Release 仍抖动

proguard-rules.pro 中保留相关类:

-keep class com.example.behavior.FixedBehavior { *; }
-keep class com.google.android.material.appbar.AppBarLayout$BaseBehavior { *; }
-keep class com.google.android.material.appbar.HeaderBehavior { *; }

建议在 Release 构建上单独回归「快速 fling → 按住反向拖拽」路径,该交互不属于常规功能测试用例,容易被遗漏。

Material 版本升级

反射字段名随 com.google.android.material:material 版本变化(mFlingRunnableflingRunnable)。升级 Material 依赖后应重新验证;若 Google 后续提供公开的 fling 取消 API,优先替换反射实现。

方案局限与替代路径

维度说明
维护成本依赖 Material 内部实现细节,属于 workaround 而非官方支持
兼容性需覆盖不同版本的字段命名差异
长期方向新页面可考虑 MotionLayoutComposeModifier.nestedScroll,从布局层规避 Behavior 状态机冲突

对于存量 XML + CoordinatorLayout 项目,该方案改动面小(仅替换 AppBar 的 layout_behavior),是解决抖动性价比最高的路径。

参考