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'; |
9 | library; |
10 | |
11 | import 'dart:math' as math; |
12 | |
13 | import 'package:flutter/animation.dart'; |
14 | import 'package:flutter/foundation.dart'; |
15 | import 'package:flutter/rendering.dart'; |
16 | |
17 | import 'framework.dart'; |
18 | import 'scroll_position.dart'; |
19 | import 'scrollable.dart'; |
20 | import '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. |
31 | enum 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. |
64 | class 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 | |
95 | class _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 | |
109 | class _SnapTrigger extends StatefulWidget { |
110 | const _SnapTrigger(this.child); |
111 | |
112 | final Widget child; |
113 | |
114 | @override |
115 | _SnapTriggerState createState() => _SnapTriggerState(); |
116 | } |
117 | |
118 | class _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 | |
152 | class _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 | |
182 | class _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 | |