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