聊聊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;
  }
}

公众号柿蒂

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui