聊聊SliverPersistentHeader优先消费滑动的设计

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。

带动画的吸顶滑动

那如果需求再复杂一点,吸顶组件滑动的同时还希望增加吸附的动画效果,其实SliverPersistentHeader也可以很轻松的实现。

但是实现这个效果有一个特殊的"Feature",他会优先消费滑动事件,导致底部滑动没有在我们预期的时机传递给上一级消费。

最近项目中处理这个问题时也没搜到相关的文章,所以今天想来聊聊这个组件的优先消费设计,以及很简单的一个定制效果。

在 Flutter 中,SliverPersistentHeader是实现"滚动时动态变化且可持久化"头部的核心组件,其浮动模式(floating: true)的动画交互(如滚动停止自动吸附、反向滚动立即展开)是通过多组件协同实现的。

瞅瞅源码

SliverPersistentHeader

那就从SliverPersistentHeader开始,让我们看看是如何实现动画的吸顶效果

kotlin 复制代码
class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    super.key,
    required this.delegate,
    this.pinned = false,
    this.floating = false,
  });

  final bool floating;

  @override
  Widget build(BuildContext context) {
    if (floating && pinned) {
      return _SliverFloatingPinnedPersistentHeader(delegate: delegate);
    }
    if (pinned) {
      return _SliverPinnedPersistentHeader(delegate: delegate);
    }
    if (floating) {
      return _SliverFloatingPersistentHeader(delegate: delegate);
    }
    return _SliverScrollingPersistentHeader(delegate: delegate);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    ....
  }
}

首先这个组件并不直接实现渲染逻辑,而是根据我们传入的flaoting和pinned的配置委派给不同的内部实现类,其中关于floating的有2个内部类分别是_SliverFloatingPinnedPersistentHeader和_SliverFloatingPersistentHeader,两个最后的实现逻辑类似,都会创建同一个Element实现效果。

_SliverFloatingPersistentHeader

以_SliverFloatingPersistentHeader的举例看逻辑

csharp 复制代码
class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjectWidget {
  const _SliverFloatingPersistentHeader({
    required super.delegate,
  }) : super(
    floating: true,
  );

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
    return _RenderSliverFloatingPersistentHeaderForWidgets(
      vsync: delegate.vsync,
      snapConfiguration: delegate.snapConfiguration,
      stretchConfiguration: delegate.stretchConfiguration,
      showOnScreenConfiguration: delegate.showOnScreenConfiguration,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) {
    renderObject.vsync = delegate.vsync;
    renderObject.snapConfiguration = delegate.snapConfiguration;
    renderObject.stretchConfiguration = delegate.stretchConfiguration;
    renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration;
  }
}

其中的核心是createRenderObject和updateRenderObject,后者的作用热重载和更新配置信息,前者的作用的是创建一个_RenderSliverFloatingPersistentHeaderForWidgets,在这个RenderObject它内部处理了复杂的逻辑,例如:

  • 响应滚动方向变化;
  • 控制 header 出现/消失动画;
  • 通过 ScrollPosition.hold() 暂停用户滚动;
  • 使用 _FloatingHeaderState 管理动画控制器(AnimationController)。

记住这个RenderObject,后面还会见到它

_SliverPersistentHeaderRenderObjectWidget

可以看到_SliverFloatingPersistentHeader继承于_SliverPersistentHeaderRenderObjectWidget,先看看它的代码

java 复制代码
abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWidget {
  const _SliverPersistentHeaderRenderObjectWidget({
    required this.delegate,
    this.floating = false,
  });

  final SliverPersistentHeaderDelegate delegate;
  final bool floating;

  @override
  _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this, floating: floating);

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context);

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
    super.debugFillProperties(description);
    description.add(
      DiagnosticsProperty<SliverPersistentHeaderDelegate>(
        'delegate',
        delegate,
      ),
    );
  }
}

核心逻辑是通过createElement创建了_SliverPersistentHeaderElement,到这里为止刚好对应上flutter渲染树中的三层架构:

层级 类名 作用
Widget _SliverPersistentHeaderRenderObjectWidget 定义静态配置(delegate、floating)
Element _SliverPersistentHeaderElement 管理生命周期与子节点(build、mount、update)
RenderObject _RenderSliverPersistentHeaderForWidgetsMixin 真正参与布局绘制

简单的说就是:

  • _SliverPersistentHeaderRenderObjectWidget 负责描述,
  • _SliverPersistentHeaderElement 负责执行,
  • _RenderSliverPersistentHeaderForWidgetsMixin 负责绘制。

_SliverPersistentHeaderElement

那这个Element长啥样呢

typescript 复制代码
class _SliverPersistentHeaderElement extends RenderObjectElement {
  _SliverPersistentHeaderElement(
    _SliverPersistentHeaderRenderObjectWidget super.widget, {
    this.floating = false,
  });

  final bool floating;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin get renderObject => super.renderObject as _RenderSliverPersistentHeaderForWidgetsMixin;

  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    renderObject._element = this;
  }

  @override
  void unmount() {
    renderObject._element = null;
    super.unmount();
  }

  @override
  void update(_SliverPersistentHeaderRenderObjectWidget newWidget) {
    ...
  }

  @override
  void performRebuild() {
    super.performRebuild();
    renderObject.triggerRebuild();
  }

  Element? child;

  void _build(double shrinkOffset, bool overlapsContent) {
    owner!.buildScope(this, () {
      final _SliverPersistentHeaderRenderObjectWidget sliverPersistentHeaderRenderObjectWidget = widget as _SliverPersistentHeaderRenderObjectWidget;
      child = updateChild(
        child,
        floating
          ? _FloatingHeader(child: sliverPersistentHeaderRenderObjectWidget.delegate.build(
            this,
            shrinkOffset,
            overlapsContent
          ))
          : sliverPersistentHeaderRenderObjectWidget.delegate.build(this, shrinkOffset, overlapsContent),
        null,
      );
    });
  }

 ...
}

大致的流程是这样的

  • RenderSliverPersistentHeader.performLayout() 在滚动时触发;
  • 它会调用 _element._build(shrinkOffset, overlapsContent);
  • _build() 会重新构建 header 对应的 Widget;
  • 若 floating 模式,则额外包一层 _FloatingHeader;
  • 通过 updateChild() 更新或替换当前子 Element;
  • 生成的 child 会对应到 renderObject.child。

_FloatingHeader

scss 复制代码
class _FloatingHeaderState extends State<_FloatingHeader> {
  ScrollPosition? _position;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    _position = Scrollable.maybeOf(context)?.position;
    if (_position != null) {
      _position!.isScrollingNotifier.addListener(_isScrollingListener);
    }
  }

  @override
  void dispose() {
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    super.dispose();
  }

  RenderSliverFloatingPersistentHeader? _headerRenderer() {
    return context.findAncestorRenderObjectOfType<RenderSliverFloatingPersistentHeader>();
  }

  void _isScrollingListener() {
    assert(_position != null);

    // When a scroll stops, then maybe snap the app bar into view.
    // Similarly, when a scroll starts, then maybe stop the snap animation.
    // Update the scrolling direction as well for pointer scrolling updates.
    final RenderSliverFloatingPersistentHeader? header = _headerRenderer();
    if (_position!.isScrollingNotifier.value) {
      header?.updateScrollStartDirection(_position!.userScrollDirection);
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStopSnapAnimation(_position!.userScrollDirection);
    } else {
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStartSnapAnimation(_position!.userScrollDirection);
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

这里面监听滚动状态并控制吸附动画的触发/停止,而控制吸附动画的触发和停止的就是RenderSliverFloatingPersistentHeader,也就是前面_RenderSliverFloatingPersistentHeaderForWidgets所继承的类

_RenderSliverFloatingPersistentHeaderForWidgets

ini 复制代码
void updateScrollStartDirection(ScrollDirection direction) {
  _lastStartedScrollDirection = direction;
}

void maybeStopSnapAnimation(ScrollDirection direction) {
  _controller?.stop();
}

void maybeStartSnapAnimation(ScrollDirection direction) {
  final FloatingHeaderSnapConfiguration? snap = snapConfiguration;
  if (snap == null) {
    return;
  }
  if (direction == ScrollDirection.forward && _effectiveScrollOffset! <= 0.0) {
    return;
  }
  if (direction == ScrollDirection.reverse && _effectiveScrollOffset! >= maxExtent) {
    return;
  }

  _updateAnimation(
    snap.duration,
    direction == ScrollDirection.forward ? 0.0 : maxExtent,
    snap.curve,
  );
  _controller?.forward(from: 0.0);
}

void _updateAnimation(Duration duration, double endValue, Curve curve) {
  assert(
    vsync != null,
    'vsync must not be null if the floating header changes size animatedly.',
  );

  final AnimationController effectiveController =
    _controller ??= AnimationController(vsync: vsync!, duration: duration)
      ..addListener(() {
          if (_effectiveScrollOffset == _animation.value) {
            return;
          }
          _effectiveScrollOffset = _animation.value;
          markNeedsLayout();
        });

  _animation = effectiveController.drive(
    Tween<double>(
      begin: _effectiveScrollOffset,
      end: endValue,
    ).chain(CurveTween(curve: curve)),
  );
}

可以看到核心思路就是: 创建 AnimationController

  • 如果 _controller 为空,则创建一个新的,绑定 vsync(防止动画掉帧);
  • 添加监听器,每帧更新 _effectiveScrollOffset 并调用 markNeedsLayout() 通知 RenderObject 重新布局。 创建 Tween + Curve
  • _animation 表示 header 从当前偏移量 _effectiveScrollOffset 到目标 endValue 的动画;
  • 使用 CurveTween 实现动画曲线(如 easeInOut)。 动画驱动布局
  • 每次动画值变化,RenderObject 会重新计算 header 的位置;
  • _effectiveScrollOffset 在 Render 层直接影响 layout 时 header 的显示/收缩状态。

那为什么SliverPersistentHeader的滑动会被优先消费呢?

php 复制代码
@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  final double maxExtent = this.maxExtent;
  final bool overlapsContent = constraints.overlap > 0.0;
  layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
  final double effectiveRemainingPaintExtent = math.max(0, constraints.remainingPaintExtent - constraints.overlap);
  final double layoutExtent = clampDouble(maxExtent - constraints.scrollOffset, 0.0, effectiveRemainingPaintExtent);
  final double stretchOffset = stretchConfiguration != null ?
    constraints.overlap.abs() :
    0.0;
  geometry = SliverGeometry(
    scrollExtent: maxExtent,
    paintOrigin: constraints.overlap,
    paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
    layoutExtent: layoutExtent,
    maxPaintExtent: maxExtent + stretchOffset,
    maxScrollObstructionExtent: minExtent,
    cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
    hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
  );
}

其中layoutExtent的计算就是Header "优先消费滚动"的关键:

  • constraints.scrollOffset:代表当前 sliver 被上层滚动消耗的距离。
  • maxExtent:header 最大高度。
  • 当滚动时,scrollOffset 增大 ⇒ layoutExtent 减小 ⇒ header 收起;
  • 当滚动向下 ⇒ scrollOffset 减小 ⇒ layoutExtent 增大 ⇒ header 露出。

在header尚未完全隐藏(scrollOffset < maxExtent)之前,layoutExtent仍然大于0,意味着这个Header还在继续"吃掉"scrollOffset,下一个Sliver还拿不到这个滚动距离。

换句话说

Header 在Layout阶段主动根据scrollOffset调整可见高度,并在未完全隐藏时持续消耗滚动距离,导致下层列表"迟迟不动"------这就是"优先消费滑动"的根本原因。

利用机制解决问题

那我们又希望有这层动画效果,又不希望滑动被提前消费应该怎么做呢,思路有很多种

  • 重写sliver,去除这层消费
  • 手动接收滑动的offset,模仿实现顶部吸附的动画效果
  • 利用sliver接收的滑动实现我们需要的动画效果

第一种情况下sliver中的很多类是内部类,需要手动复制出来,成本极高

第二种思路需要手动兼容和原本布局的滑动冲突情况

在最快、最简思路下,第三种方案应该是最优解

dart 复制代码
class CustomSnapHeaderDemo extends StatefulWidget {
  const CustomSnapHeaderDemo({super.key});

  @override
  State<CustomSnapHeaderDemo> createState() => _CustomSnapHeaderDemoState();
}

class _CustomSnapHeaderDemoState extends State<CustomSnapHeaderDemo> {
  late final ScrollController _scrollController;
  late ScrollPosition _scrollPosition;

  /// header 的高度
  static const double _headerExtent = 120.0;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();

    /// 等待第一帧绘制后再拿到 ScrollPosition
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollPosition = _scrollController.position;
      // 监听滚动状态变化
      _scrollPosition.isScrollingNotifier.addListener(_onScrollStateChanged);
    });
  }

  @override
  void dispose() {
    _scrollPosition.isScrollingNotifier.removeListener(_onScrollStateChanged);
    _scrollController.dispose();
    super.dispose();
  }

  /// 滚动状态监听器
  void _onScrollStateChanged() {
    final isScrolling = _scrollPosition.isScrollingNotifier.value;
    if (!isScrolling) {
      // 滚动停止时触发吸附逻辑
      _maybeSnapHeader();
    }
  }

  /// 自定义吸附逻辑:
  /// 当 header 显示一半以上时,吸附到完全展开;
  /// 否则隐藏到底部。
  void _maybeSnapHeader() {
    // 当前滚动偏移
    final currentOffset = _scrollPosition.pixels;

    // header 最大可滚动距离
    final maxHeaderOffset = _headerExtent / 2;

    // 如果当前偏移量 < headerExtent,说明 header 仍部分可见
    if (currentOffset >= 0 && currentOffset <= maxHeaderOffset) {
      final visibleRatio = 1.0 - (currentOffset / maxHeaderOffset);
      if (visibleRatio > 0.5) {
        // 吸附展开
        _animateTo(0);
      } else {
        // 吸附隐藏
        _animateTo(maxHeaderOffset);
      }
    }
  }

  /// 平滑滚动到目标位置
  void _animateTo(double targetOffset) {
    _scrollController.animateTo(
      targetOffset,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('SliverPersistentHeader Demo')),
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          SliverPersistentHeader(
            pinned: true,
            floating: false,
            delegate: _CustomHeaderDelegate(
              extent: _headerExtent,
            ),
          ),

          // 模拟长列表内容
          SliverList.builder(
            itemCount: 50,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('Item $index'),
              );
            },
          ),
        ],
      ),
    );
  }
}

class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double extent;

  _CustomHeaderDelegate({required this.extent});

  @override
  double get minExtent => extent / 2; // 最小高度
  @override
  double get maxExtent => extent; // 最大高度

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final percent = 1.0 - (shrinkOffset / maxExtent);
    return Container(
      color: Colors.blue,
      alignment: Alignment.center,
      child: const Text(
        '自定义吸附 Header',
        style: TextStyle(
          color: Colors.white,
          fontSize: 22,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }

  @override
  bool shouldRebuild(covariant _CustomHeaderDelegate oldDelegate) {
    return oldDelegate.extent != extent;
  }
}

公众号柿蒂

相关推荐
假装多好1235 小时前
android三方调试几个常用命令
android·1024程序员节·三方,gms
侧耳4295 小时前
android11禁止安装apk
android·java·1024程序员节
JohnnyDeng946 小时前
ArkTs-Android 与 ArkTS (HarmonyOS) 存储目录全面对比
android·harmonyos·arkts·1024程序员节
2501_915918416 小时前
iOS 26 查看电池容量与健康状态 多工具组合的工程实践
android·ios·小程序·https·uni-app·iphone·webview
limingade6 小时前
手机摄像头如何识别体检的色盲检查图的数字和图案(下)
android·1024程序员节·色盲检查图·手机摄像头识别色盲图案·android识别色盲检测卡·色盲色弱检测卡
文火冰糖的硅基工坊7 小时前
[嵌入式系统-150]:智能机器人(具身智能)内部的嵌入式系统以及各自的功能、硬件架构、操作系统、软件架构
android·linux·算法·ubuntu·机器人·硬件架构
fishofeyes7 小时前
Riverpod使用
flutter
2501_915909068 小时前
iOS 架构设计全解析 从MVC到MVVM与使用 开心上架 跨平台发布 免Mac
android·ios·小程序·https·uni-app·iphone·webview
明道源码9 小时前
Android Studio 创建 Android 模拟器
android·ide·android studio