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 'package:flutter/material.dart';
6///
7/// @docImport 'app.dart';
8/// @docImport 'nav_bar.dart';
9library;
10
11import 'dart:math';
12
13import 'package:flutter/foundation.dart' show clampDouble;
14import 'package:flutter/rendering.dart';
15import 'package:flutter/scheduler.dart';
16import 'package:flutter/services.dart';
17import 'package:flutter/widgets.dart';
18
19import 'activity_indicator.dart';
20
21const double _kActivityIndicatorRadius = 14.0;
22const double _kActivityIndicatorMargin = 16.0;
23
24class _CupertinoSliverRefresh extends SingleChildRenderObjectWidget {
25 const _CupertinoSliverRefresh({
26 this.refreshIndicatorLayoutExtent = 0.0,
27 this.hasLayoutExtent = false,
28 super.child,
29 }) : assert(refreshIndicatorLayoutExtent >= 0.0);
30
31 // The amount of space the indicator should occupy in the sliver in a
32 // resting state when in the refreshing mode.
33 final double refreshIndicatorLayoutExtent;
34
35 // _RenderCupertinoSliverRefresh will paint the child in the available
36 // space either way but this instructs the _RenderCupertinoSliverRefresh
37 // on whether to also occupy any layoutExtent space or not.
38 final bool hasLayoutExtent;
39
40 @override
41 _RenderCupertinoSliverRefresh createRenderObject(BuildContext context) {
42 return _RenderCupertinoSliverRefresh(
43 refreshIndicatorExtent: refreshIndicatorLayoutExtent,
44 hasLayoutExtent: hasLayoutExtent,
45 );
46 }
47
48 @override
49 void updateRenderObject(
50 BuildContext context,
51 covariant _RenderCupertinoSliverRefresh renderObject,
52 ) {
53 renderObject
54 ..refreshIndicatorLayoutExtent = refreshIndicatorLayoutExtent
55 ..hasLayoutExtent = hasLayoutExtent;
56 }
57}
58
59// RenderSliver object that gives its child RenderBox object space to paint
60// in the overscrolled gap and may or may not hold that overscrolled gap
61// around the RenderBox depending on whether [layoutExtent] is set.
62//
63// The [layoutExtentOffsetCompensation] field keeps internal accounting to
64// prevent scroll position jumps as the [layoutExtent] is set and unset.
65class _RenderCupertinoSliverRefresh extends RenderSliver
66 with RenderObjectWithChildMixin<RenderBox> {
67 _RenderCupertinoSliverRefresh({
68 required double refreshIndicatorExtent,
69 required bool hasLayoutExtent,
70 RenderBox? child,
71 }) : assert(refreshIndicatorExtent >= 0.0),
72 _refreshIndicatorExtent = refreshIndicatorExtent,
73 _hasLayoutExtent = hasLayoutExtent {
74 this.child = child;
75 }
76
77 // The amount of layout space the indicator should occupy in the sliver in a
78 // resting state when in the refreshing mode.
79 double get refreshIndicatorLayoutExtent => _refreshIndicatorExtent;
80 double _refreshIndicatorExtent;
81 set refreshIndicatorLayoutExtent(double value) {
82 assert(value >= 0.0);
83 if (value == _refreshIndicatorExtent) {
84 return;
85 }
86 _refreshIndicatorExtent = value;
87 markNeedsLayout();
88 }
89
90 // The child box will be laid out and painted in the available space either
91 // way but this determines whether to also occupy any
92 // [SliverGeometry.layoutExtent] space or not.
93 bool get hasLayoutExtent => _hasLayoutExtent;
94 bool _hasLayoutExtent;
95 set hasLayoutExtent(bool value) {
96 if (value == _hasLayoutExtent) {
97 return;
98 }
99 _hasLayoutExtent = value;
100 markNeedsLayout();
101 }
102
103 // This keeps track of the previously applied scroll offsets to the scrollable
104 // so that when [refreshIndicatorLayoutExtent] or [hasLayoutExtent] changes,
105 // the appropriate delta can be applied to keep everything in the same place
106 // visually.
107 double layoutExtentOffsetCompensation = 0.0;
108
109 @override
110 void performLayout() {
111 final SliverConstraints constraints = this.constraints;
112 // Only pulling to refresh from the top is currently supported.
113 assert(constraints.axisDirection == AxisDirection.down);
114 assert(constraints.growthDirection == GrowthDirection.forward);
115
116 // The new layout extent this sliver should now have.
117 final double layoutExtent = (_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent;
118 // If the new layoutExtent instructive changed, the SliverGeometry's
119 // layoutExtent will take that value (on the next performLayout run). Shift
120 // the scroll offset first so it doesn't make the scroll position suddenly jump.
121 if (layoutExtent != layoutExtentOffsetCompensation) {
122 geometry = SliverGeometry(
123 scrollOffsetCorrection: layoutExtent - layoutExtentOffsetCompensation,
124 );
125 layoutExtentOffsetCompensation = layoutExtent;
126 // Return so we don't have to do temporary accounting and adjusting the
127 // child's constraints accounting for this one transient frame using a
128 // combination of existing layout extent, new layout extent change and
129 // the overlap.
130 return;
131 }
132
133 final bool active = constraints.overlap < 0.0 || layoutExtent > 0.0;
134 final double overscrolledExtent = constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
135 // Layout the child giving it the space of the currently dragged overscroll
136 // which may or may not include a sliver layout extent space that it will
137 // keep after the user lets go during the refresh process.
138 child!.layout(
139 constraints.asBoxConstraints(
140 maxExtent:
141 layoutExtent
142 // Plus only the overscrolled portion immediately preceding this
143 // sliver.
144 +
145 overscrolledExtent,
146 ),
147 parentUsesSize: true,
148 );
149 if (active) {
150 geometry = SliverGeometry(
151 scrollExtent: layoutExtent,
152 paintOrigin: -overscrolledExtent - constraints.scrollOffset,
153 paintExtent: max(
154 // Check child size (which can come from overscroll) because
155 // layoutExtent may be zero. Check layoutExtent also since even
156 // with a layoutExtent, the indicator builder may decide to not
157 // build anything.
158 max(child!.size.height, layoutExtent) - constraints.scrollOffset,
159 0.0,
160 ),
161 maxPaintExtent: max(max(child!.size.height, layoutExtent) - constraints.scrollOffset, 0.0),
162 layoutExtent: max(layoutExtent - constraints.scrollOffset, 0.0),
163 );
164 } else {
165 // If we never started overscrolling, return no geometry.
166 geometry = SliverGeometry.zero;
167 }
168 }
169
170 @override
171 void paint(PaintingContext paintContext, Offset offset) {
172 if (constraints.overlap < 0.0 || constraints.scrollOffset + child!.size.height > 0) {
173 paintContext.paintChild(child!, offset);
174 }
175 }
176
177 // Nothing special done here because this sliver always paints its child
178 // exactly between paintOrigin and paintExtent.
179 @override
180 void applyPaintTransform(RenderObject child, Matrix4 transform) {}
181}
182
183/// The current state of the refresh control.
184///
185/// Passed into the [RefreshControlIndicatorBuilder] builder function so
186/// users can show different UI in different modes.
187enum RefreshIndicatorMode {
188 /// Initial state, when not being overscrolled into, or after the overscroll
189 /// is canceled or after done and the sliver retracted away.
190 inactive,
191
192 /// While being overscrolled but not far enough yet to trigger the refresh.
193 drag,
194
195 /// Dragged far enough that the onRefresh callback will run and the dragged
196 /// displacement is not yet at the final refresh resting state.
197 armed,
198
199 /// While the onRefresh task is running.
200 refresh,
201
202 /// While the indicator is animating away after refreshing.
203 done,
204}
205
206/// Signature for a builder that can create a different widget to show in the
207/// refresh indicator space depending on the current state of the refresh
208/// control and the space available.
209///
210/// The `refreshTriggerPullDistance` and `refreshIndicatorExtent` parameters are
211/// the same values passed into the [CupertinoSliverRefreshControl].
212///
213/// The `pulledExtent` parameter is the currently available space either from
214/// overscrolling or as held by the sliver during refresh.
215typedef RefreshControlIndicatorBuilder =
216 Widget Function(
217 BuildContext context,
218 RefreshIndicatorMode refreshState,
219 double pulledExtent,
220 double refreshTriggerPullDistance,
221 double refreshIndicatorExtent,
222 );
223
224/// A callback function that's invoked when the [CupertinoSliverRefreshControl] is
225/// pulled a `refreshTriggerPullDistance`. Must return a [Future]. Upon
226/// completion of the [Future], the [CupertinoSliverRefreshControl] enters the
227/// [RefreshIndicatorMode.done] state and will start to go away.
228typedef RefreshCallback = Future<void> Function();
229
230/// A sliver widget implementing the iOS-style pull to refresh content control.
231///
232/// When inserted as the first sliver in a scroll view or behind other slivers
233/// that still lets the scrollable overscroll in front of this sliver (such as
234/// the [CupertinoSliverNavigationBar], this widget will:
235///
236/// * Let the user draw inside the overscrolled area via the passed in [builder].
237/// * Trigger the provided [onRefresh] function when overscrolled far enough to
238/// pass [refreshTriggerPullDistance].
239/// * Continue to hold [refreshIndicatorExtent] amount of space for the [builder]
240/// to keep drawing inside of as the [Future] returned by [onRefresh] processes.
241/// * Scroll away once the [onRefresh] [Future] completes.
242///
243/// The [builder] function will be informed of the current [RefreshIndicatorMode]
244/// when invoking it, except in the [RefreshIndicatorMode.inactive] state when
245/// no space is available and nothing needs to be built. The [builder] function
246/// will otherwise be continuously invoked as the amount of space available
247/// changes from overscroll, as the sliver scrolls away after the [onRefresh]
248/// task is done, etc.
249///
250/// Only one refresh can be triggered until the previous refresh has completed
251/// and the indicator sliver has retracted at least 90% of the way back.
252///
253/// Can only be used in downward-scrolling vertical lists that overscrolls. In
254/// other words, refreshes can't be triggered with [Scrollable]s using
255/// [ClampingScrollPhysics] which is the default on Android. To allow overscroll
256/// on Android, use an overscrolling physics such as [BouncingScrollPhysics].
257/// This can be done via:
258///
259/// * Providing a [BouncingScrollPhysics] (possibly in combination with a
260/// [AlwaysScrollableScrollPhysics]) while constructing the scrollable.
261/// * By inserting a [ScrollConfiguration] with [BouncingScrollPhysics] above
262/// the scrollable.
263/// * By using [CupertinoApp], which always uses a [ScrollConfiguration]
264/// with [BouncingScrollPhysics] regardless of platform.
265///
266/// In a typical application, this sliver should be inserted between the app bar
267/// sliver such as [CupertinoSliverNavigationBar] and your main scrollable
268/// content's sliver.
269///
270/// {@tool dartpad}
271/// When the user scrolls past [refreshTriggerPullDistance],
272/// this sample shows the default iOS pull to refresh indicator for 1 second and
273/// adds a new item to the top of the list view.
274///
275/// ** See code in examples/api/lib/cupertino/refresh/cupertino_sliver_refresh_control.0.dart **
276/// {@end-tool}
277///
278/// See also:
279///
280/// * [CustomScrollView], a typical sliver holding scroll view this control
281/// should go into.
282/// * <https://developer.apple.com/ios/human-interface-guidelines/controls/refresh-content-controls/>
283/// * [RefreshIndicator], a Material Design version of the pull-to-refresh
284/// paradigm. This widget works differently than [RefreshIndicator] because
285/// instead of being an overlay on top of the scrollable, the
286/// [CupertinoSliverRefreshControl] is part of the scrollable and actively occupies
287/// scrollable space.
288class CupertinoSliverRefreshControl extends StatefulWidget {
289 /// Create a new refresh control for inserting into a list of slivers.
290 ///
291 /// The [refreshTriggerPullDistance] and [refreshIndicatorExtent] arguments
292 /// must be greater than or equal to 0.
293 ///
294 /// The [builder] argument may be null, in which case no indicator UI will be
295 /// shown but the [onRefresh] will still be invoked. By default, [builder]
296 /// shows a [CupertinoActivityIndicator].
297 ///
298 /// The [onRefresh] argument will be called when pulled far enough to trigger
299 /// a refresh.
300 const CupertinoSliverRefreshControl({
301 super.key,
302 this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance,
303 this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent,
304 this.builder = buildRefreshIndicator,
305 this.onRefresh,
306 }) : assert(refreshTriggerPullDistance > 0.0),
307 assert(refreshIndicatorExtent >= 0.0),
308 assert(
309 refreshTriggerPullDistance >= refreshIndicatorExtent,
310 'The refresh indicator cannot take more space in its final state '
311 'than the amount initially created by overscrolling.',
312 );
313
314 /// The amount of overscroll the scrollable must be dragged to trigger a reload.
315 ///
316 /// Must be larger than zero and larger than [refreshIndicatorExtent].
317 /// Defaults to 100 pixels when not specified.
318 ///
319 /// When overscrolled past this distance, [onRefresh] will be called if not
320 /// null and the [builder] will build in the [RefreshIndicatorMode.armed] state.
321 final double refreshTriggerPullDistance;
322
323 /// The amount of space the refresh indicator sliver will keep holding while
324 /// [onRefresh]'s [Future] is still running.
325 ///
326 /// Must be a positive number, but can be zero, in which case the sliver will
327 /// start retracting back to zero as soon as the refresh is started. Defaults
328 /// to 60 pixels when not specified.
329 ///
330 /// Must be smaller than [refreshTriggerPullDistance], since the sliver
331 /// shouldn't grow further after triggering the refresh.
332 final double refreshIndicatorExtent;
333
334 /// A builder that's called as this sliver's size changes, and as the state
335 /// changes.
336 ///
337 /// Can be set to null, in which case nothing will be drawn in the overscrolled
338 /// space.
339 ///
340 /// Will not be called when the available space is zero such as before any
341 /// overscroll.
342 final RefreshControlIndicatorBuilder? builder;
343
344 /// Callback invoked when pulled by [refreshTriggerPullDistance].
345 ///
346 /// If provided, must return a [Future] which will keep the indicator in the
347 /// [RefreshIndicatorMode.refresh] state until the [Future] completes.
348 ///
349 /// Can be null, in which case a single frame of [RefreshIndicatorMode.armed]
350 /// state will be drawn before going immediately to the [RefreshIndicatorMode.done]
351 /// where the sliver will start retracting.
352 final RefreshCallback? onRefresh;
353
354 static const double _defaultRefreshTriggerPullDistance = 100.0;
355 static const double _defaultRefreshIndicatorExtent = 60.0;
356
357 /// Retrieve the current state of the CupertinoSliverRefreshControl. The same as the
358 /// state that gets passed into the [builder] function. Used for testing.
359 @visibleForTesting
360 static RefreshIndicatorMode state(BuildContext context) {
361 final _CupertinoSliverRefreshControlState state =
362 context.findAncestorStateOfType<_CupertinoSliverRefreshControlState>()!;
363 return state.refreshState;
364 }
365
366 /// Builds a refresh indicator that reflects the standard iOS pull-to-refresh
367 /// behavior. Specifically, this entails presenting an activity indicator that
368 /// changes depending on the current refreshState. As the user initially drags
369 /// down, the indicator will gradually reveal individual ticks until the refresh
370 /// becomes armed. At this point, the animated activity indicator will begin rotating.
371 /// Once the refresh has completed, the activity indicator shrinks away as the
372 /// space allocation animates back to closed.
373 static Widget buildRefreshIndicator(
374 BuildContext context,
375 RefreshIndicatorMode refreshState,
376 double pulledExtent,
377 double refreshTriggerPullDistance,
378 double refreshIndicatorExtent,
379 ) {
380 final double percentageComplete = clampDouble(
381 pulledExtent / refreshTriggerPullDistance,
382 0.0,
383 1.0,
384 );
385
386 // Place the indicator at the top of the sliver that opens up. We're using a
387 // Stack/Positioned widget because the CupertinoActivityIndicator does some
388 // internal translations based on the current size (which grows as the user drags)
389 // that makes Padding calculations difficult. Rather than be reliant on the
390 // internal implementation of the activity indicator, the Positioned widget allows
391 // us to be explicit where the widget gets placed. The indicator should appear
392 // over the top of the dragged widget, hence the use of Clip.none.
393 return Center(
394 child: Stack(
395 clipBehavior: Clip.none,
396 children: <Widget>[
397 Positioned(
398 top: _kActivityIndicatorMargin,
399 left: 0.0,
400 right: 0.0,
401 child: _buildIndicatorForRefreshState(
402 refreshState,
403 _kActivityIndicatorRadius,
404 percentageComplete,
405 ),
406 ),
407 ],
408 ),
409 );
410 }
411
412 static Widget _buildIndicatorForRefreshState(
413 RefreshIndicatorMode refreshState,
414 double radius,
415 double percentageComplete,
416 ) {
417 switch (refreshState) {
418 case RefreshIndicatorMode.drag:
419 // While we're dragging, we draw individual ticks of the spinner while simultaneously
420 // easing the opacity in. The opacity curve values here were derived using
421 // Xcode through inspecting a native app running on iOS 13.5.
422 const Curve opacityCurve = Interval(0.0, 0.35, curve: Curves.easeInOut);
423 return Opacity(
424 opacity: opacityCurve.transform(percentageComplete),
425 child: CupertinoActivityIndicator.partiallyRevealed(
426 radius: radius,
427 progress: percentageComplete,
428 ),
429 );
430 case RefreshIndicatorMode.armed:
431 case RefreshIndicatorMode.refresh:
432 // Once we're armed or performing the refresh, we just show the normal spinner.
433 return CupertinoActivityIndicator(radius: radius);
434 case RefreshIndicatorMode.done:
435 // When the user lets go, the standard transition is to shrink the spinner.
436 return CupertinoActivityIndicator(radius: radius * percentageComplete);
437 case RefreshIndicatorMode.inactive:
438 // Anything else doesn't show anything.
439 return const SizedBox.shrink();
440 }
441 }
442
443 @override
444 State<CupertinoSliverRefreshControl> createState() => _CupertinoSliverRefreshControlState();
445}
446
447class _CupertinoSliverRefreshControlState extends State<CupertinoSliverRefreshControl> {
448 // Reset the state from done to inactive when only this fraction of the
449 // original `refreshTriggerPullDistance` is left.
450 static const double _inactiveResetOverscrollFraction = 0.1;
451
452 late RefreshIndicatorMode refreshState;
453 // [Future] returned by the widget's `onRefresh`.
454 Future<void>? refreshTask;
455 // The amount of space available from the inner indicator box's perspective.
456 //
457 // The value is the sum of the sliver's layout extent and the overscroll
458 // (which partially gets transferred into the layout extent when the refresh
459 // triggers).
460 //
461 // The value of latestIndicatorBoxExtent doesn't change when the sliver scrolls
462 // away without retracting; it is independent from the sliver's scrollOffset.
463 double latestIndicatorBoxExtent = 0.0;
464 bool hasSliverLayoutExtent = false;
465
466 @override
467 void initState() {
468 super.initState();
469 refreshState = RefreshIndicatorMode.inactive;
470 }
471
472 // A state machine transition calculator. Multiple states can be transitioned
473 // through per single call.
474 RefreshIndicatorMode transitionNextState() {
475 RefreshIndicatorMode nextState;
476
477 void goToDone() {
478 nextState = RefreshIndicatorMode.done;
479 // Either schedule the RenderSliver to re-layout on the next frame
480 // when not currently in a frame or schedule it on the next frame.
481 if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
482 setState(() => hasSliverLayoutExtent = false);
483 } else {
484 SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
485 setState(() => hasSliverLayoutExtent = false);
486 }, debugLabel: 'Refresh.goToDone');
487 }
488 }
489
490 switch (refreshState) {
491 case RefreshIndicatorMode.inactive:
492 if (latestIndicatorBoxExtent <= 0) {
493 return RefreshIndicatorMode.inactive;
494 } else {
495 nextState = RefreshIndicatorMode.drag;
496 }
497 continue drag;
498 drag:
499 case RefreshIndicatorMode.drag:
500 if (latestIndicatorBoxExtent == 0) {
501 return RefreshIndicatorMode.inactive;
502 } else if (latestIndicatorBoxExtent < widget.refreshTriggerPullDistance) {
503 return RefreshIndicatorMode.drag;
504 } else {
505 if (widget.onRefresh != null) {
506 HapticFeedback.mediumImpact();
507 // Call onRefresh after this frame finished since the function is
508 // user supplied and we're always here in the middle of the sliver's
509 // performLayout.
510 SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
511 refreshTask =
512 widget.onRefresh!()..whenComplete(() {
513 if (mounted) {
514 setState(() => refreshTask = null);
515 // Trigger one more transition because by this time, BoxConstraint's
516 // maxHeight might already be resting at 0 in which case no
517 // calls to [transitionNextState] will occur anymore and the
518 // state may be stuck in a non-inactive state.
519 refreshState = transitionNextState();
520 }
521 });
522 setState(() => hasSliverLayoutExtent = true);
523 }, debugLabel: 'Refresh.transition');
524 }
525 return RefreshIndicatorMode.armed;
526 }
527 case RefreshIndicatorMode.armed:
528 if (refreshState == RefreshIndicatorMode.armed && refreshTask == null) {
529 goToDone();
530 continue done;
531 }
532
533 if (latestIndicatorBoxExtent > widget.refreshIndicatorExtent) {
534 return RefreshIndicatorMode.armed;
535 } else {
536 nextState = RefreshIndicatorMode.refresh;
537 }
538 continue refresh;
539 refresh:
540 case RefreshIndicatorMode.refresh:
541 if (refreshTask != null) {
542 return RefreshIndicatorMode.refresh;
543 } else {
544 goToDone();
545 }
546 continue done;
547 done:
548 case RefreshIndicatorMode.done:
549 // Let the transition back to inactive trigger before strictly going
550 // to 0.0 since the last bit of the animation can take some time and
551 // can feel sluggish if not going all the way back to 0.0 prevented
552 // a subsequent pull-to-refresh from starting.
553 if (latestIndicatorBoxExtent >
554 widget.refreshTriggerPullDistance * _inactiveResetOverscrollFraction) {
555 return RefreshIndicatorMode.done;
556 } else {
557 nextState = RefreshIndicatorMode.inactive;
558 }
559 }
560
561 return nextState;
562 }
563
564 @override
565 Widget build(BuildContext context) {
566 return _CupertinoSliverRefresh(
567 refreshIndicatorLayoutExtent: widget.refreshIndicatorExtent,
568 hasLayoutExtent: hasSliverLayoutExtent,
569 // A LayoutBuilder lets the sliver's layout changes be fed back out to
570 // its owner to trigger state changes.
571 child: LayoutBuilder(
572 builder: (BuildContext context, BoxConstraints constraints) {
573 latestIndicatorBoxExtent = constraints.maxHeight;
574 refreshState = transitionNextState();
575 if (widget.builder != null && latestIndicatorBoxExtent > 0) {
576 return widget.builder!(
577 context,
578 refreshState,
579 latestIndicatorBoxExtent,
580 widget.refreshTriggerPullDistance,
581 widget.refreshIndicatorExtent,
582 );
583 }
584 return const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand());
585 },
586 ),
587 );
588 }
589}
590

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com