1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'pinned_header_sliver.dart';
6/// @docImport 'scroll_view.dart';
7/// @docImport 'sliver_persistent_header.dart';
8/// @docImport 'sliver_resizing_header.dart';
9library;
10
11import 'dart:math' as math;
12
13import 'package:flutter/animation.dart';
14import 'package:flutter/foundation.dart';
15import 'package:flutter/rendering.dart';
16
17import 'framework.dart';
18import 'scroll_position.dart';
19import 'scrollable.dart';
20import 'ticker_provider.dart';
21
22/// Specifies how a partially visible [SliverFloatingHeader] animates
23/// into a view when a user scroll gesture ends.
24///
25/// During a user scroll gesture the header and the rest of the scrollable
26/// content move in sync. If the header is partially visible when the
27/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
28/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
29/// content as it expands until it's completely visible, or if the
30/// content should scroll out of the way as the header expands.
31enum FloatingHeaderSnapMode {
32 /// At the end of a user scroll gesture, the [SliverFloatingHeader] will
33 /// expand over the scrollable's content.
34 overlay,
35
36 /// At the end of a user scroll gesture, the [SliverFloatingHeader] will
37 /// expand and the scrollable's content will continue to scroll out
38 /// of the way.
39 scroll,
40}
41
42/// A sliver that shows its [child] when the user scrolls forward and hides it
43/// when the user scrolls backwards.
44///
45/// This sliver is preferable to the general purpose [SliverPersistentHeader]
46/// for its relatively narrow use case because there's no need to create a
47/// [SliverPersistentHeaderDelegate] or to predict the header's size.
48///
49/// {@tool dartpad}
50/// This example shows how to create a SliverFloatingHeader.
51///
52/// ** See code in examples/api/lib/widgets/sliver/sliver_floating_header.0.dart **
53/// {@end-tool}
54///
55/// See also:
56///
57/// * [PinnedHeaderSliver] - which just pins the header at the top
58/// of the [CustomScrollView].
59/// * [SliverResizingHeader] - which similarly pins the header at the top
60/// of the [CustomScrollView] but reacts to scrolling by resizing the header
61/// between its minimum and maximum extent limits.
62/// * [SliverPersistentHeader] - a general purpose header that can be
63/// configured as a pinned, resizing, or floating header.
64class SliverFloatingHeader extends StatefulWidget {
65 /// Create a floating header sliver that animates into view when the user
66 /// scrolls forward, and disappears the user starts scrolling in the
67 /// opposite direction.
68 const SliverFloatingHeader({super.key, this.animationStyle, this.snapMode, required this.child});
69
70 /// Non null properties override the default durations (300ms) and
71 /// curves (Curves.easeInOut) for subsequent header animations.
72 ///
73 /// The reverse duration and curve apply to the animation that hides the header.
74 final AnimationStyle? animationStyle;
75
76 /// Specifies how a partially visible [SliverFloatingHeader] animates
77 /// into a view when a user scroll gesture ends.
78 ///
79 /// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
80 /// modify an animation in progress, just subsequent animations.
81 final FloatingHeaderSnapMode? snapMode;
82
83 /// The widget contained by this sliver.
84 final Widget child;
85
86 @override
87 State<SliverFloatingHeader> createState() => _SliverFloatingHeaderState();
88}
89
90class _SliverFloatingHeaderState extends State<SliverFloatingHeader>
91 with SingleTickerProviderStateMixin {
92 ScrollPosition? position;
93
94 @override
95 Widget build(BuildContext context) {
96 return _SliverFloatingHeader(
97 vsync: this,
98 animationStyle: widget.animationStyle,
99 snapMode: widget.snapMode,
100 child: _SnapTrigger(widget.child),
101 );
102 }
103}
104
105class _SnapTrigger extends StatefulWidget {
106 const _SnapTrigger(this.child);
107
108 final Widget child;
109
110 @override
111 _SnapTriggerState createState() => _SnapTriggerState();
112}
113
114class _SnapTriggerState extends State<_SnapTrigger> {
115 ScrollPosition? position;
116
117 @override
118 void didChangeDependencies() {
119 super.didChangeDependencies();
120 if (position != null) {
121 position!.isScrollingNotifier.removeListener(isScrollingListener);
122 }
123 position = Scrollable.maybeOf(context)?.position;
124 if (position != null) {
125 position!.isScrollingNotifier.addListener(isScrollingListener);
126 }
127 }
128
129 @override
130 void dispose() {
131 if (position != null) {
132 position!.isScrollingNotifier.removeListener(isScrollingListener);
133 }
134 super.dispose();
135 }
136
137 // Called when the sliver starts or ends scrolling.
138 void isScrollingListener() {
139 assert(position != null);
140 final _RenderSliverFloatingHeader? renderer = context
141 .findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>();
142 renderer?.isScrollingUpdate(position!);
143 }
144
145 @override
146 Widget build(BuildContext context) => widget.child;
147}
148
149class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
150 const _SliverFloatingHeader({this.vsync, this.animationStyle, this.snapMode, super.child});
151
152 final TickerProvider? vsync;
153 final AnimationStyle? animationStyle;
154 final FloatingHeaderSnapMode? snapMode;
155
156 @override
157 _RenderSliverFloatingHeader createRenderObject(BuildContext context) {
158 return _RenderSliverFloatingHeader(
159 vsync: vsync,
160 animationStyle: animationStyle,
161 snapMode: snapMode,
162 );
163 }
164
165 @override
166 void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
167 renderObject
168 ..vsync = vsync
169 ..animationStyle = animationStyle
170 ..snapMode = snapMode;
171 }
172}
173
174class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
175 _RenderSliverFloatingHeader({TickerProvider? vsync, this.animationStyle, this.snapMode})
176 : _vsync = vsync;
177
178 late Animation<double> snapAnimation;
179 AnimationController? snapController;
180 double? lastScrollOffset;
181
182 // The distance from the start of the header to the start of the viewport. When the
183 // header is showing it varies between 0 (completely visible) and childExtent (not visible
184 // because it's just above the viewport's starting edge). It's used to compute the
185 // header's paintExtent which defines where the header will appear - see paint().
186 late double effectiveScrollOffset;
187
188 TickerProvider? get vsync => _vsync;
189 TickerProvider? _vsync;
190 set vsync(TickerProvider? value) {
191 if (value == _vsync) {
192 return;
193 }
194 _vsync = value;
195 if (value == null) {
196 snapController?.dispose();
197 snapController = null;
198 } else {
199 snapController?.resync(value);
200 }
201 }
202
203 AnimationStyle? animationStyle;
204
205 FloatingHeaderSnapMode? snapMode;
206
207 // Called each time the position's isScrollingNotifier indicates that user scrolling has
208 // stopped or started, i.e. if the sliver "is scrolling".
209 void isScrollingUpdate(ScrollPosition position) {
210 if (position.isScrollingNotifier.value) {
211 snapController?.stop();
212 } else {
213 final ScrollDirection direction = position.userScrollDirection;
214 final bool headerIsPartiallyVisible = switch (direction) {
215 ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible
216 ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible
217 _ => true,
218 };
219 if (headerIsPartiallyVisible) {
220 snapController ??= AnimationController(vsync: vsync!)
221 ..addListener(() {
222 if (effectiveScrollOffset != snapAnimation.value) {
223 effectiveScrollOffset = snapAnimation.value;
224 markNeedsLayout();
225 }
226 });
227 snapController!.duration = switch (direction) {
228 ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300),
229 _ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300),
230 };
231 snapAnimation = snapController!.drive(
232 Tween<double>(
233 begin: effectiveScrollOffset,
234 end: switch (direction) {
235 ScrollDirection.forward => 0,
236 _ => childExtent,
237 },
238 ).chain(
239 CurveTween(
240 curve: switch (direction) {
241 ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut,
242 _ => animationStyle?.reverseCurve ?? Curves.easeInOut,
243 },
244 ),
245 ),
246 );
247 snapController!.forward(from: 0.0);
248 }
249 }
250 }
251
252 double get childExtent {
253 if (child == null) {
254 return 0.0;
255 }
256 assert(child!.hasSize);
257 return switch (constraints.axis) {
258 Axis.vertical => child!.size.height,
259 Axis.horizontal => child!.size.width,
260 };
261 }
262
263 @override
264 void detach() {
265 snapController?.dispose();
266 snapController = null; // lazily recreated if we're reattached.
267 super.detach();
268 }
269
270 // True if the header has been laid at at least once (lastScrollOffset != null) and either:
271 // - We're scrolling forward: constraints.scrollOffset < lastScrollOffset
272 // - The header's already partially visible: effectiveScrollOffset < childExtent
273 // Scrolling forwards (towards the scrollable's start) is the trigger that causes the
274 // header to be shown.
275 bool get floatingHeaderNeedsToBeUpdated {
276 return lastScrollOffset != null &&
277 (constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent);
278 }
279
280 @override
281 void performLayout() {
282 if (!floatingHeaderNeedsToBeUpdated) {
283 effectiveScrollOffset = constraints.scrollOffset;
284 } else {
285 double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing
286 if (constraints.userScrollDirection == ScrollDirection.forward) {
287 if (effectiveScrollOffset > childExtent) {
288 effectiveScrollOffset =
289 childExtent; // The header is now just above the start edge of viewport.
290 }
291 } else {
292 // delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
293 delta = clampDouble(delta, -double.infinity, 0);
294 }
295 effectiveScrollOffset = clampDouble(
296 effectiveScrollOffset - delta,
297 0.0,
298 constraints.scrollOffset,
299 );
300 }
301
302 child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
303 final double paintExtent = childExtent - effectiveScrollOffset;
304 final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
305 FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
306 FloatingHeaderSnapMode.scroll => paintExtent,
307 };
308 geometry = SliverGeometry(
309 paintOrigin: math.min(constraints.overlap, 0.0),
310 scrollExtent: childExtent,
311 paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),
312 layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent),
313 maxPaintExtent: childExtent,
314 hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
315 );
316
317 lastScrollOffset = constraints.scrollOffset;
318 }
319
320 @override
321 double childMainAxisPosition(covariant RenderObject child) {
322 return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent);
323 }
324
325 @override
326 void applyPaintTransform(RenderObject child, Matrix4 transform) {
327 assert(child == this.child);
328 applyPaintTransformForBoxChild(child as RenderBox, transform);
329 }
330
331 @override
332 void paint(PaintingContext context, Offset offset) {
333 if (child != null && geometry!.visible) {
334 offset += switch (applyGrowthDirectionToAxisDirection(
335 constraints.axisDirection,
336 constraints.growthDirection,
337 )) {
338 AxisDirection.up => Offset(
339 0.0,
340 geometry!.paintExtent - childMainAxisPosition(child!) - childExtent,
341 ),
342 AxisDirection.left => Offset(
343 geometry!.paintExtent - childMainAxisPosition(child!) - childExtent,
344 0.0,
345 ),
346 AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0),
347 AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)),
348 };
349 context.paintChild(child!, offset);
350 }
351 }
352}
353