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({
69 super.key,
70 this.animationStyle,
71 this.snapMode,
72 required this.child
73 });
74
75 /// Non null properties override the default durations (300ms) and
76 /// curves (Curves.easeInOut) for subsequent header animations.
77 ///
78 /// The reverse duration and curve apply to the animation that hides the header.
79 final AnimationStyle? animationStyle;
80
81 /// Specifies how a partially visible [SliverFloatingHeader] animates
82 /// into a view when a user scroll gesture ends.
83 ///
84 /// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
85 /// modify an animation in progress, just subsequent animations.
86 final FloatingHeaderSnapMode? snapMode;
87
88 /// The widget contained by this sliver.
89 final Widget child;
90
91 @override
92 State<SliverFloatingHeader> createState() => _SliverFloatingHeaderState();
93}
94
95class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with SingleTickerProviderStateMixin {
96 ScrollPosition? position;
97
98 @override
99 Widget build(BuildContext context) {
100 return _SliverFloatingHeader(
101 vsync: this,
102 animationStyle: widget.animationStyle,
103 snapMode: widget.snapMode,
104 child: _SnapTrigger(widget.child),
105 );
106 }
107}
108
109class _SnapTrigger extends StatefulWidget {
110 const _SnapTrigger(this.child);
111
112 final Widget child;
113
114 @override
115 _SnapTriggerState createState() => _SnapTriggerState();
116}
117
118class _SnapTriggerState extends State<_SnapTrigger> {
119 ScrollPosition? position;
120
121 @override
122 void didChangeDependencies() {
123 super.didChangeDependencies();
124 if (position != null) {
125 position!.isScrollingNotifier.removeListener(isScrollingListener);
126 }
127 position = Scrollable.maybeOf(context)?.position;
128 if (position != null) {
129 position!.isScrollingNotifier.addListener(isScrollingListener);
130 }
131 }
132
133 @override
134 void dispose() {
135 if (position != null) {
136 position!.isScrollingNotifier.removeListener(isScrollingListener);
137 }
138 super.dispose();
139 }
140
141 // Called when the sliver starts or ends scrolling.
142 void isScrollingListener() {
143 assert(position != null);
144 final _RenderSliverFloatingHeader? renderer = context.findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>();
145 renderer?.isScrollingUpdate(position!);
146 }
147
148 @override
149 Widget build(BuildContext context) => widget.child;
150}
151
152class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
153 const _SliverFloatingHeader({
154 this.vsync,
155 this.animationStyle,
156 this.snapMode,
157 super.child,
158 });
159
160 final TickerProvider? vsync;
161 final AnimationStyle? animationStyle;
162 final FloatingHeaderSnapMode? snapMode;
163
164 @override
165 _RenderSliverFloatingHeader createRenderObject(BuildContext context) {
166 return _RenderSliverFloatingHeader(
167 vsync: vsync,
168 animationStyle: animationStyle,
169 snapMode: snapMode,
170 );
171 }
172
173 @override
174 void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
175 renderObject
176 ..vsync = vsync
177 ..animationStyle = animationStyle
178 ..snapMode = snapMode;
179 }
180}
181
182class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
183 _RenderSliverFloatingHeader({
184 TickerProvider? vsync,
185 this.animationStyle,
186 this.snapMode,
187 }) : _vsync = vsync;
188
189 late Animation<double> snapAnimation;
190 AnimationController? snapController;
191 double? lastScrollOffset;
192
193 // The distance from the start of the header to the start of the viewport. Whent the
194 // header is showing it varies between 0 (completely visible) and childExtent (not visible
195 // because it's just abopve the viewport's starting edge). It's used to compute the
196 // header's paintExtent which defines where the header will appear - see paint().
197 late double effectiveScrollOffset;
198
199 TickerProvider? get vsync => _vsync;
200 TickerProvider? _vsync;
201 set vsync(TickerProvider? value) {
202 if (value == _vsync) {
203 return;
204 }
205 _vsync = value;
206 if (value == null) {
207 snapController?.dispose();
208 snapController = null;
209 } else {
210 snapController?.resync(value);
211 }
212 }
213
214 AnimationStyle? animationStyle;
215
216 FloatingHeaderSnapMode? snapMode;
217
218 // Called each time the position's isScrollingNotifier indicates that user scrolling has
219 // stopped or started, i.e. if the sliver "is scrolling".
220 void isScrollingUpdate(ScrollPosition position) {
221 if (position.isScrollingNotifier.value) {
222 snapController?.stop();
223 } else {
224 final ScrollDirection direction = position.userScrollDirection;
225 final bool headerIsPartiallyVisible = switch (direction) {
226 ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible
227 ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible
228 _ => true,
229 };
230 if (headerIsPartiallyVisible) {
231 snapController ??= AnimationController(vsync: vsync!)
232 ..addListener(() {
233 if (effectiveScrollOffset != snapAnimation.value) {
234 effectiveScrollOffset = snapAnimation.value;
235 markNeedsLayout();
236 }
237 });
238 snapController!.duration = switch (direction) {
239 ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300),
240 _ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300),
241 };
242 snapAnimation = snapController!.drive(
243 Tween<double>(
244 begin: effectiveScrollOffset,
245 end: switch (direction) {
246 ScrollDirection.forward => 0,
247 _ => childExtent,
248 },
249 ).chain(
250 CurveTween(
251 curve: switch (direction) {
252 ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut,
253 _ => animationStyle?.reverseCurve ?? Curves.easeInOut,
254 }
255 ),
256 ),
257 );
258 snapController!.forward(from: 0.0);
259 }
260 }
261 }
262
263 double get childExtent {
264 if (child == null) {
265 return 0.0;
266 }
267 assert(child!.hasSize);
268 return switch (constraints.axis) {
269 Axis.vertical => child!.size.height,
270 Axis.horizontal => child!.size.width,
271 };
272 }
273
274 @override
275 void detach() {
276 snapController?.dispose();
277 snapController = null; // lazily recreated if we're reattached.
278 super.detach();
279 }
280
281 // True if the header has been laid at at least once (lastScrollOffset != null) and either:
282 // - We're scrolling forward: constraints.scrollOffset < lastScrollOffset
283 // - The header's already partially visible: effectiveScrollOffset < childExtent
284 // Scrolling forwards (towards the scrollable's start) is the trigger that causes the
285 // header to be shown.
286 bool get floatingHeaderNeedsToBeUpdated {
287 return lastScrollOffset != null &&
288 (constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent);
289 }
290
291 @override
292 void performLayout() {
293 if (!floatingHeaderNeedsToBeUpdated) {
294 effectiveScrollOffset = constraints.scrollOffset;
295 } else {
296 double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing
297 if (constraints.userScrollDirection == ScrollDirection.forward) {
298 if (effectiveScrollOffset > childExtent) {
299 effectiveScrollOffset = childExtent; // The header is now just above the start edge of viewport.
300 }
301 } else {
302 // delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0).
303 delta = clampDouble(delta, -double.infinity, 0);
304 }
305 effectiveScrollOffset = clampDouble(effectiveScrollOffset - delta, 0.0, constraints.scrollOffset);
306 }
307
308 child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
309 final double paintExtent = childExtent - effectiveScrollOffset;
310 final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
311 FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
312 FloatingHeaderSnapMode.scroll => paintExtent,
313 };
314 geometry = SliverGeometry(
315 paintOrigin: math.min(constraints.overlap, 0.0),
316 scrollExtent: childExtent,
317 paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent),
318 layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent),
319 maxPaintExtent: childExtent,
320 hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
321 );
322
323 lastScrollOffset = constraints.scrollOffset;
324 }
325
326 @override
327 double childMainAxisPosition(covariant RenderObject child) {
328 return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent);
329 }
330
331 @override
332 void applyPaintTransform(RenderObject child, Matrix4 transform) {
333 assert(child == this.child);
334 applyPaintTransformForBoxChild(child as RenderBox, transform);
335 }
336
337 @override
338 void paint(PaintingContext context, Offset offset) {
339 if (child != null && geometry!.visible) {
340 offset += switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {
341 AxisDirection.up => Offset(0.0, geometry!.paintExtent - childMainAxisPosition(child!) - childExtent),
342 AxisDirection.left => Offset(geometry!.paintExtent - childMainAxisPosition(child!) - childExtent, 0.0),
343 AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0),
344 AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)),
345 };
346 context.paintChild(child!, offset);
347 }
348 }
349}
350