refresh_indicator.dart 23.1 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
6
import 'dart:math' as math;
7

8
import 'package:flutter/foundation.dart' show clampDouble;
9 10
import 'package:flutter/widgets.dart';

11 12
import 'debug.dart';
import 'material_localizations.dart';
13
import 'progress_indicator.dart';
14
import 'theme.dart';
15 16 17 18 19 20 21 22 23 24

// The over-scroll distance that moves the indicator to its maximum
// displacement, as a percentage of the scrollable's container extent.
const double _kDragContainerExtentPercentage = 0.25;

// How much the scroll's drag gesture can overshoot the RefreshIndicator's
// displacement; max displacement = _kDragSizeFactorLimit * displacement.
const double _kDragSizeFactorLimit = 1.5;

// When the scroll ends, the duration of the refresh indicator's animation
Josh Soref's avatar
Josh Soref committed
25
// to the RefreshIndicator's displacement.
26
const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150);
27 28 29

// The duration of the ScaleTransition that starts when the refresh action
// has completed.
30
const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200);
31

32 33 34 35 36
/// The signature for a function that's called when the user has dragged a
/// [RefreshIndicator] far enough to demonstrate that they want the app to
/// refresh. The returned [Future] must complete when the refresh operation is
/// finished.
///
37
/// Used by [RefreshIndicator.onRefresh].
38
typedef RefreshCallback = Future<void> Function();
39

40 41
// The state machine moves through these modes only when the scrollable
// identified by scrollableKey has been scrolled to its min or max limit.
42
enum _RefreshIndicatorMode {
43 44 45 46 47 48
  drag,     // Pointer is down.
  armed,    // Dragged far enough that an up event will run the onRefresh callback.
  snap,     // Animating to the indicator's final "displacement".
  refresh,  // Running the refresh callback.
  done,     // Animating the indicator's fade-out after refreshing.
  canceled, // Animating the indicator's fade-out after not arming.
49 50
}

51 52 53 54 55 56 57 58 59 60 61
/// Used to configure how [RefreshIndicator] can be triggered.
enum RefreshIndicatorTriggerMode {
  /// The indicator can be triggered regardless of the scroll position
  /// of the [Scrollable] when the drag starts.
  anywhere,

  /// The indicator can only be triggered if the [Scrollable] is at the edge
  /// when the drag starts.
  onEdge,
}

62 63
/// A widget that supports the Material "swipe to refresh" idiom.
///
64 65
/// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM}
///
Adam Barth's avatar
Adam Barth committed
66
/// When the child's [Scrollable] descendant overscrolls, an animated circular
67 68 69 70 71 72
/// progress indicator is faded into view. When the scroll ends, if the
/// indicator has been dragged far enough for it to become completely opaque,
/// the [onRefresh] callback is called. The callback is expected to update the
/// scrollable's contents and then complete the [Future] it returns. The refresh
/// indicator disappears after the callback's [Future] has completed.
///
73 74
/// The trigger mode is configured by [RefreshIndicator.triggerMode].
///
75 76 77 78 79 80
/// {@tool dartpad}
/// This example shows how [RefreshIndicator] can be triggered in different ways.
///
/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.0.dart **
/// {@end-tool}
///
81 82 83 84 85 86 87
/// {@tool dartpad}
/// This example shows how to trigger [RefreshIndicator] in a nested scroll view using
/// the [notificationPredicate] property.
///
/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.1.dart **
/// {@end-tool}
///
88 89 90 91 92 93 94 95 96
/// ## Troubleshooting
///
/// ### Refresh indicator does not show up
///
/// The [RefreshIndicator] will appear if its scrollable descendant can be
/// overscrolled, i.e. if the scrollable's content is bigger than its viewport.
/// To ensure that the [RefreshIndicator] will always appear, even if the
/// scrollable's content fits within its viewport, set the scrollable's
/// [Scrollable.physics] property to [AlwaysScrollableScrollPhysics]:
97 98
///
/// ```dart
99
/// ListView(
100
///   physics: const AlwaysScrollableScrollPhysics(),
101
///   // ...
102
/// )
103 104 105
/// ```
///
/// A [RefreshIndicator] can only be used with a vertical scroll view.
106 107 108
///
/// See also:
///
109
///  * <https://material.io/design/platform-guidance/android-swipe-to-refresh.html>
110
///  * [RefreshIndicatorState], can be used to programmatically show the refresh indicator.
111 112
///  * [RefreshProgressIndicator], widget used by [RefreshIndicator] to show
///    the inner circular progress spinner during refreshes.
113
///  * [CupertinoSliverRefreshControl], an iOS equivalent of the pull-to-refresh pattern.
114 115 116
///    Must be used as a sliver inside a [CustomScrollView] instead of wrapping
///    around a [ScrollView] because it's a part of the scrollable instead of
///    being overlaid on top of it.
117
class RefreshIndicator extends StatefulWidget {
118 119
  /// Creates a refresh indicator.
  ///
120 121
  /// The [onRefresh], [child], and [notificationPredicate] arguments must be
  /// non-null. The default
122
  /// [displacement] is 40.0 logical pixels.
123 124 125 126
  ///
  /// The [semanticsLabel] is used to specify an accessibility label for this widget.
  /// If it is null, it will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel].
  /// An empty string may be passed to avoid having anything read by screen reading software.
127
  /// The [semanticsValue] may be used to specify progress on the widget.
128
  const RefreshIndicator({
129
    super.key,
130
    required this.child,
131
    this.displacement = 40.0,
132
    this.edgeOffset = 0.0,
133
    required this.onRefresh,
134
    this.color,
135
    this.backgroundColor,
136
    this.notificationPredicate = defaultScrollNotificationPredicate,
137 138
    this.semanticsLabel,
    this.semanticsValue,
139
    this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth,
140
    this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
141 142
  }) : assert(child != null),
       assert(onRefresh != null),
143
       assert(notificationPredicate != null),
144
       assert(strokeWidth != null),
145
       assert(triggerMode != null);
146

147 148
  /// The widget below this widget in the tree.
  ///
149 150
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
151 152
  ///
  /// Typically a [ListView] or [CustomScrollView].
153 154
  final Widget child;

155 156 157 158 159 160 161
  /// The distance from the child's top or bottom [edgeOffset] where
  /// the refresh indicator will settle. During the drag that exposes the refresh
  /// indicator, its actual displacement may significantly exceed this value.
  ///
  /// In most cases, [displacement] distance starts counting from the parent's
  /// edges. However, if [edgeOffset] is larger than zero then the [displacement]
  /// value is calculated from that offset instead of the parent's edge.
162 163
  final double displacement;

164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
  /// The offset where [RefreshProgressIndicator] starts to appear on drag start.
  ///
  /// Depending whether the indicator is showing on the top or bottom, the value
  /// of this variable controls how far from the parent's edge the progress
  /// indicator starts to appear. This may come in handy when, for example, the
  /// UI contains a top [Widget] which covers the parent's edge where the progress
  /// indicator would otherwise appear.
  ///
  /// By default, the edge offset is set to 0.
  ///
  /// See also:
  ///
  ///  * [displacement], can be used to change the distance from the edge that
  ///    the indicator settles.
  final double edgeOffset;

180 181
  /// A function that's called when the user has dragged the refresh indicator
  /// far enough to demonstrate that they want the app to refresh. The returned
182
  /// [Future] must complete when the refresh operation is finished.
183
  final RefreshCallback onRefresh;
184

185
  /// The progress indicator's foreground color. The current theme's
186
  /// [ColorScheme.primary] by default.
187
  final Color? color;
188 189 190

  /// The progress indicator's background color. The current theme's
  /// [ThemeData.canvasColor] by default.
191
  final Color? backgroundColor;
192

193 194 195 196 197 198
  /// A check that specifies whether a [ScrollNotification] should be
  /// handled by this widget.
  ///
  /// By default, checks whether `notification.depth == 0`. Set it to something
  /// else for more complicated layouts.
  final ScrollNotificationPredicate notificationPredicate;
199

200
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel}
201 202 203
  ///
  /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]
  /// if it is null.
204
  final String? semanticsLabel;
205

206
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue}
207
  final String? semanticsValue;
208

209
  /// Defines [strokeWidth] for `RefreshIndicator`.
210
  ///
211
  /// By default, the value of [strokeWidth] is 2.0 pixels.
212 213
  final double strokeWidth;

214 215 216 217 218 219 220 221 222 223 224 225 226 227 228
  /// Defines how this [RefreshIndicator] can be triggered when users overscroll.
  ///
  /// The [RefreshIndicator] can be pulled out in two cases,
  /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position
  ///    when the drag starts.
  /// 2, Keep dragging after overscroll occurs if the scrollable widget has
  ///    a non-zero scroll position when the drag starts.
  ///
  /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered.
  ///
  /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered.
  ///
  /// Defaults to [RefreshIndicatorTriggerMode.onEdge].
  final RefreshIndicatorTriggerMode triggerMode;

229
  @override
230
  RefreshIndicatorState createState() => RefreshIndicatorState();
231 232
}

233 234
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
235
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin<RefreshIndicator> {
236 237 238 239 240 241 242 243
  late AnimationController _positionController;
  late AnimationController _scaleController;
  late Animation<double> _positionFactor;
  late Animation<double> _scaleFactor;
  late Animation<double> _value;
  late Animation<Color?> _valueColor;

  _RefreshIndicatorMode? _mode;
244
  late Future<void> _pendingRefreshFuture;
245 246
  bool? _isIndicatorAtTop;
  double? _dragOffset;
247

248 249 250 251
  static final Animatable<double> _threeQuarterTween = Tween<double>(begin: 0.0, end: 0.75);
  static final Animatable<double> _kDragSizeFactorLimitTween = Tween<double>(begin: 0.0, end: _kDragSizeFactorLimit);
  static final Animatable<double> _oneToZeroTween = Tween<double>(begin: 1.0, end: 0.0);

252 253 254
  @override
  void initState() {
    super.initState();
255
    _positionController = AnimationController(vsync: this);
256 257
    _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween);
    _value = _positionController.drive(_threeQuarterTween); // The "value" of the circular progress indicator during a drag.
258

259
    _scaleController = AnimationController(vsync: this);
260
    _scaleFactor = _scaleController.drive(_oneToZeroTween);
261 262 263
  }

  @override
264
  void didChangeDependencies() {
265
    final ThemeData theme = Theme.of(context);
266 267
    _valueColor = _positionController.drive(
      ColorTween(
268 269
        begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0),
        end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0),
270
      ).chain(CurveTween(
271
        curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit),
272 273
      )),
    );
274
    super.didChangeDependencies();
275 276
  }

277 278 279 280 281 282 283
  @override
  void didUpdateWidget(covariant RefreshIndicator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.color != widget.color) {
      final ThemeData theme = Theme.of(context);
      _valueColor = _positionController.drive(
        ColorTween(
284 285
          begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0),
          end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0),
286
        ).chain(CurveTween(
287
            curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit),
288 289 290 291 292
        )),
      );
    }
  }

293 294
  @override
  void dispose() {
295
    _positionController.dispose();
296 297 298 299
    _scaleController.dispose();
    super.dispose();
  }

300
  bool _shouldStart(ScrollNotification notification) {
301 302 303 304 305
    // If the notification.dragDetails is null, this scroll is not triggered by
    // user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll.
    // In this case, we don't want to trigger the refresh indicator.
    return ((notification is ScrollStartNotification && notification.dragDetails != null)
            || (notification is ScrollUpdateNotification && notification.dragDetails != null && widget.triggerMode == RefreshIndicatorTriggerMode.anywhere))
306 307
      && (( notification.metrics.axisDirection == AxisDirection.up && notification.metrics.extentAfter == 0.0)
            || (notification.metrics.axisDirection == AxisDirection.down && notification.metrics.extentBefore == 0.0))
308 309 310 311
      && _mode == null
      && _start(notification.metrics.axisDirection);
  }

Adam Barth's avatar
Adam Barth committed
312
  bool _handleScrollNotification(ScrollNotification notification) {
313
    if (!widget.notificationPredicate(notification)) {
314
      return false;
315
    }
316
    if (_shouldStart(notification)) {
317 318 319
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
320
      return false;
321
    }
322
    bool? indicatorAtTopNow;
323
    switch (notification.metrics.axisDirection) {
324 325
      case AxisDirection.down:
      case AxisDirection.up:
326
        indicatorAtTopNow = true;
327 328 329 330 331 332 333
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        indicatorAtTopNow = null;
        break;
    }
    if (indicatorAtTopNow != _isIndicatorAtTop) {
334
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
335
        _dismiss(_RefreshIndicatorMode.canceled);
336
      }
337 338
    } else if (notification is ScrollUpdateNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
339 340
        if ((notification.metrics.axisDirection  == AxisDirection.down && notification.metrics.extentBefore > 0.0)
            || (notification.metrics.axisDirection  == AxisDirection.up && notification.metrics.extentAfter > 0.0)) {
341 342
          _dismiss(_RefreshIndicatorMode.canceled);
        } else {
343 344 345 346 347
          if (notification.metrics.axisDirection == AxisDirection.down) {
            _dragOffset = _dragOffset! - notification.scrollDelta!;
          } else if (notification.metrics.axisDirection == AxisDirection.up) {
            _dragOffset = _dragOffset! + notification.scrollDelta!;
          }
348 349 350
          _checkDragOffset(notification.metrics.viewportDimension);
        }
      }
351 352 353 354 355 356
      if (_mode == _RefreshIndicatorMode.armed && notification.dragDetails == null) {
        // On iOS start the refresh when the Scrollable bounces back from the
        // overscroll (ScrollNotification indicating this don't have dragDetails
        // because the scroll activity is not directly triggered by a drag).
        _show();
      }
357 358
    } else if (notification is OverscrollNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
359 360 361 362 363
        if (notification.metrics.axisDirection == AxisDirection.down) {
          _dragOffset = _dragOffset! - notification.overscroll;
        } else if (notification.metrics.axisDirection == AxisDirection.up) {
          _dragOffset = _dragOffset! + notification.overscroll;
        }
364 365 366 367 368 369 370 371 372 373
        _checkDragOffset(notification.metrics.viewportDimension);
      }
    } else if (notification is ScrollEndNotification) {
      switch (_mode) {
        case _RefreshIndicatorMode.armed:
          _show();
          break;
        case _RefreshIndicatorMode.drag:
          _dismiss(_RefreshIndicatorMode.canceled);
          break;
374 375 376 377 378
        case _RefreshIndicatorMode.canceled:
        case _RefreshIndicatorMode.done:
        case _RefreshIndicatorMode.refresh:
        case _RefreshIndicatorMode.snap:
        case null:
379 380 381
          // do nothing
          break;
      }
382 383
    }
    return false;
384 385
  }

386
  bool _handleIndicatorNotification(OverscrollIndicatorNotification notification) {
387
    if (notification.depth != 0 || !notification.leading) {
388
      return false;
389
    }
390
    if (_mode == _RefreshIndicatorMode.drag) {
391
      notification.disallowIndicator();
392
      return true;
393
    }
394
    return false;
395 396
  }

397 398 399 400 401 402 403
  bool _start(AxisDirection direction) {
    assert(_mode == null);
    assert(_isIndicatorAtTop == null);
    assert(_dragOffset == null);
    switch (direction) {
      case AxisDirection.down:
      case AxisDirection.up:
404
        _isIndicatorAtTop = true;
405 406 407 408 409 410 411
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        _isIndicatorAtTop = null;
        // we do not support horizontal scroll views.
        return false;
    }
412 413
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
414 415
    _positionController.value = 0.0;
    return true;
416 417
  }

418 419
  void _checkDragOffset(double containerExtent) {
    assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed);
420
    double newValue = _dragOffset! / (containerExtent * _kDragContainerExtentPercentage);
421
    if (_mode == _RefreshIndicatorMode.armed) {
422
      newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
423
    }
424
    _positionController.value = clampDouble(newValue, 0.0, 1.0); // this triggers various rebuilds
425
    if (_mode == _RefreshIndicatorMode.drag && _valueColor.value!.alpha == 0xFF) {
426
      _mode = _RefreshIndicatorMode.armed;
427
    }
428 429
  }

430
  // Stop showing the refresh indicator.
431
  Future<void> _dismiss(_RefreshIndicatorMode newMode) async {
432
    await Future<void>.value();
433 434 435 436
    // This can only be called from _show() when refreshing and
    // _handleScrollNotification in response to a ScrollEndNotification or
    // direction change.
    assert(newMode == _RefreshIndicatorMode.canceled || newMode == _RefreshIndicatorMode.done);
437
    setState(() {
438
      _mode = newMode;
439
    });
440
    switch (_mode!) {
441
      case _RefreshIndicatorMode.done:
442 443
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
444 445 446
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
447 448 449 450
      case _RefreshIndicatorMode.armed:
      case _RefreshIndicatorMode.drag:
      case _RefreshIndicatorMode.refresh:
      case _RefreshIndicatorMode.snap:
451
        assert(false);
452
    }
453 454 455
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
456 457 458 459
      setState(() {
        _mode = null;
      });
    }
460 461
  }

462 463 464
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
465
    final Completer<void> completer = Completer<void>();
466
    _pendingRefreshFuture = completer.future;
467
    _mode = _RefreshIndicatorMode.snap;
468
    _positionController
469
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
470
      .then<void>((void value) {
471
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
472
          assert(widget.onRefresh != null);
473 474 475 476 477
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

478
          final Future<void> refreshResult = widget.onRefresh();
479
          assert(() {
480
            if (refreshResult == null) {
481 482
              FlutterError.reportError(FlutterErrorDetails(
                exception: FlutterError(
483
                  'The onRefresh callback returned null.\n'
484
                  'The RefreshIndicator onRefresh callback must return a Future.',
485
                ),
486
                context: ErrorDescription('when calling onRefresh'),
487 488
                library: 'material library',
              ));
489
            }
490
            return true;
491
          }());
492
          if (refreshResult == null) {
493
            return;
494
          }
495
          refreshResult.whenComplete(() {
496 497
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
498
              _dismiss(_RefreshIndicatorMode.done);
499 500 501
            }
          });
        }
502 503 504 505 506 507 508
      });
  }

  /// Show the refresh indicator and run the refresh callback as if it had
  /// been started interactively. If this method is called while the refresh
  /// callback is running, it quietly does nothing.
  ///
509
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
510
  /// makes it possible to refer to the [RefreshIndicatorState].
511
  ///
512 513
  /// The future returned from this method completes when the
  /// [RefreshIndicator.onRefresh] callback's future completes.
514 515 516 517 518 519 520
  ///
  /// If you await the future returned by this function from a [State], you
  /// should check that the state is still [mounted] before calling [setState].
  ///
  /// When initiated in this manner, the refresh indicator is independent of any
  /// actual scroll view. It defaults to showing the indicator at the top. To
  /// show it at the bottom, set `atTop` to false.
521
  Future<void> show({ bool atTop = true }) {
522 523
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
524
      if (_mode == null) {
525
        _start(atTop ? AxisDirection.down : AxisDirection.up);
526
      }
527
      _show();
528
    }
529
    return _pendingRefreshFuture;
530 531
  }

532 533
  @override
  Widget build(BuildContext context) {
534
    assert(debugCheckHasMaterialLocalizations(context));
535
    final Widget child = NotificationListener<ScrollNotification>(
536
      onNotification: _handleScrollNotification,
537
      child: NotificationListener<OverscrollIndicatorNotification>(
538
        onNotification: _handleIndicatorNotification,
539
        child: widget.child,
540 541
      ),
    );
542 543 544 545 546 547 548 549 550 551
    assert(() {
      if (_mode == null) {
        assert(_dragOffset == null);
        assert(_isIndicatorAtTop == null);
      } else {
        assert(_dragOffset != null);
        assert(_isIndicatorAtTop != null);
      }
      return true;
    }());
552

553 554 555
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done;

556
    return Stack(
557 558
      children: <Widget>[
        child,
559
        if (_mode != null) Positioned(
560 561
          top: _isIndicatorAtTop! ? widget.edgeOffset : null,
          bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null,
562 563
          left: 0.0,
          right: 0.0,
564
          child: SizeTransition(
565
            axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0,
566
            sizeFactor: _positionFactor, // this is what brings it down
567
            child: Container(
568
              padding: _isIndicatorAtTop!
569 570
                ? EdgeInsets.only(top: widget.displacement)
                : EdgeInsets.only(bottom: widget.displacement),
571
              alignment: _isIndicatorAtTop!
572 573
                ? Alignment.topCenter
                : Alignment.bottomCenter,
574
              child: ScaleTransition(
575
                scale: _scaleFactor,
576
                child: AnimatedBuilder(
577
                  animation: _positionController,
578
                  builder: (BuildContext context, Widget? child) {
579
                    return RefreshProgressIndicator(
580
                      semanticsLabel: widget.semanticsLabel ?? MaterialLocalizations.of(context).refreshIndicatorSemanticLabel,
581
                      semanticsValue: widget.semanticsValue,
582 583
                      value: showIndeterminateIndicator ? null : _value.value,
                      valueColor: _valueColor,
584
                      backgroundColor: widget.backgroundColor,
585
                      strokeWidth: widget.strokeWidth,
586 587 588 589 590
                    );
                  },
                ),
              ),
            ),
591
          ),
592 593
        ),
      ],
594 595 596
    );
  }
}