refresh_indicator.dart 24.8 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/cupertino.dart';
9
import 'package:flutter/foundation.dart' show clampDouble;
10

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
enum _IndicatorType { material, adaptive }

64 65
/// A widget that supports the Material "swipe to refresh" idiom.
///
66 67
/// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM}
///
Adam Barth's avatar
Adam Barth committed
68
/// When the child's [Scrollable] descendant overscrolls, an animated circular
69 70 71 72 73 74
/// 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.
///
75 76
/// The trigger mode is configured by [RefreshIndicator.triggerMode].
///
77 78 79 80 81 82
/// {@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}
///
83 84 85 86 87 88 89
/// {@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}
///
90 91 92 93 94 95 96 97 98
/// ## 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]:
99 100
///
/// ```dart
101
/// ListView(
102
///   physics: const AlwaysScrollableScrollPhysics(),
103
///   // ...
104
/// )
105 106 107
/// ```
///
/// A [RefreshIndicator] can only be used with a vertical scroll view.
108 109 110
///
/// See also:
///
111
///  * <https://material.io/design/platform-guidance/android-swipe-to-refresh.html>
112
///  * [RefreshIndicatorState], can be used to programmatically show the refresh indicator.
113 114
///  * [RefreshProgressIndicator], widget used by [RefreshIndicator] to show
///    the inner circular progress spinner during refreshes.
115
///  * [CupertinoSliverRefreshControl], an iOS equivalent of the pull-to-refresh pattern.
116 117 118
///    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.
119
class RefreshIndicator extends StatefulWidget {
120 121
  /// Creates a refresh indicator.
  ///
122 123
  /// The [onRefresh], [child], and [notificationPredicate] arguments must be
  /// non-null. The default
124
  /// [displacement] is 40.0 logical pixels.
125 126 127 128
  ///
  /// 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.
129
  /// The [semanticsValue] may be used to specify progress on the widget.
130
  const RefreshIndicator({
131
    super.key,
132
    required this.child,
133
    this.displacement = 40.0,
134
    this.edgeOffset = 0.0,
135
    required this.onRefresh,
136
    this.color,
137
    this.backgroundColor,
138
    this.notificationPredicate = defaultScrollNotificationPredicate,
139 140
    this.semanticsLabel,
    this.semanticsValue,
141
    this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth,
142
    this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
  }) : _indicatorType = _IndicatorType.material;

  /// Creates an adaptive [RefreshIndicator] based on whether the target
  /// platform is iOS or macOS, following Material design's
  /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html).
  ///
  /// When the descendant overscrolls, a different spinning progress indicator
  /// is shown depending on platform. On iOS and macOS,
  /// [CupertinoActivityIndicator] is shown, but on all other platforms,
  /// [CircularProgressIndicator] appears.
  ///
  /// If a [CupertinoActivityIndicator] is shown, the following parameters are ignored:
  /// [backgroundColor], [semanticsLabel], [semanticsValue], [strokeWidth].
  ///
  /// The target platform is based on the current [Theme]: [ThemeData.platform].
  ///
  /// Noteably the scrollable widget itself will have slightly different behavior
  /// from [CupertinoSliverRefreshControl], due to a difference in structure.
  const RefreshIndicator.adaptive({
    super.key,
    required this.child,
    this.displacement = 40.0,
    this.edgeOffset = 0.0,
    required this.onRefresh,
    this.color,
    this.backgroundColor,
    this.notificationPredicate = defaultScrollNotificationPredicate,
    this.semanticsLabel,
    this.semanticsValue,
    this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth,
    this.triggerMode = RefreshIndicatorTriggerMode.onEdge,
  }) : _indicatorType = _IndicatorType.adaptive;
175

176 177
  /// The widget below this widget in the tree.
  ///
178 179
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
180 181
  ///
  /// Typically a [ListView] or [CustomScrollView].
182 183
  final Widget child;

184 185 186 187 188 189 190
  /// 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.
191 192
  final double displacement;

193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
  /// 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;

209 210
  /// 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
211
  /// [Future] must complete when the refresh operation is finished.
212
  final RefreshCallback onRefresh;
213

214
  /// The progress indicator's foreground color. The current theme's
215
  /// [ColorScheme.primary] by default.
216
  final Color? color;
217 218 219

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

222 223 224 225 226 227
  /// 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;
228

229
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel}
230 231 232
  ///
  /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]
  /// if it is null.
233
  final String? semanticsLabel;
234

235
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue}
236
  final String? semanticsValue;
237

238
  /// Defines [strokeWidth] for `RefreshIndicator`.
239
  ///
240
  /// By default, the value of [strokeWidth] is 2.0 pixels.
241 242
  final double strokeWidth;

243 244
  final _IndicatorType _indicatorType;

245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
  /// 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;

260
  @override
261
  RefreshIndicatorState createState() => RefreshIndicatorState();
262 263
}

264 265
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
266
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin<RefreshIndicator> {
267 268 269 270 271 272 273 274
  late AnimationController _positionController;
  late AnimationController _scaleController;
  late Animation<double> _positionFactor;
  late Animation<double> _scaleFactor;
  late Animation<double> _value;
  late Animation<Color?> _valueColor;

  _RefreshIndicatorMode? _mode;
275
  late Future<void> _pendingRefreshFuture;
276 277
  bool? _isIndicatorAtTop;
  double? _dragOffset;
278

279 280 281 282
  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);

283 284 285
  @override
  void initState() {
    super.initState();
286
    _positionController = AnimationController(vsync: this);
287 288
    _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween);
    _value = _positionController.drive(_threeQuarterTween); // The "value" of the circular progress indicator during a drag.
289

290
    _scaleController = AnimationController(vsync: this);
291
    _scaleFactor = _scaleController.drive(_oneToZeroTween);
292 293 294
  }

  @override
295
  void didChangeDependencies() {
296
    final ThemeData theme = Theme.of(context);
297 298
    _valueColor = _positionController.drive(
      ColorTween(
299 300
        begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0),
        end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0),
301
      ).chain(CurveTween(
302
        curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit),
303 304
      )),
    );
305
    super.didChangeDependencies();
306 307
  }

308 309 310 311 312 313 314
  @override
  void didUpdateWidget(covariant RefreshIndicator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.color != widget.color) {
      final ThemeData theme = Theme.of(context);
      _valueColor = _positionController.drive(
        ColorTween(
315 316
          begin: (widget.color ?? theme.colorScheme.primary).withOpacity(0.0),
          end: (widget.color ?? theme.colorScheme.primary).withOpacity(1.0),
317
        ).chain(CurveTween(
318
            curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit),
319 320 321 322 323
        )),
      );
    }
  }

324 325
  @override
  void dispose() {
326
    _positionController.dispose();
327 328 329 330
    _scaleController.dispose();
    super.dispose();
  }

331
  bool _shouldStart(ScrollNotification notification) {
332 333 334 335 336
    // 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))
337 338
      && (( notification.metrics.axisDirection == AxisDirection.up && notification.metrics.extentAfter == 0.0)
            || (notification.metrics.axisDirection == AxisDirection.down && notification.metrics.extentBefore == 0.0))
339 340 341 342
      && _mode == null
      && _start(notification.metrics.axisDirection);
  }

Adam Barth's avatar
Adam Barth committed
343
  bool _handleScrollNotification(ScrollNotification notification) {
344
    if (!widget.notificationPredicate(notification)) {
345
      return false;
346
    }
347
    if (_shouldStart(notification)) {
348 349 350
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
351
      return false;
352
    }
353
    bool? indicatorAtTopNow;
354
    switch (notification.metrics.axisDirection) {
355 356
      case AxisDirection.down:
      case AxisDirection.up:
357
        indicatorAtTopNow = true;
358 359 360 361 362 363 364
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        indicatorAtTopNow = null;
        break;
    }
    if (indicatorAtTopNow != _isIndicatorAtTop) {
365
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
366
        _dismiss(_RefreshIndicatorMode.canceled);
367
      }
368 369
    } else if (notification is ScrollUpdateNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
370 371
        if ((notification.metrics.axisDirection  == AxisDirection.down && notification.metrics.extentBefore > 0.0)
            || (notification.metrics.axisDirection  == AxisDirection.up && notification.metrics.extentAfter > 0.0)) {
372 373
          _dismiss(_RefreshIndicatorMode.canceled);
        } else {
374 375 376 377 378
          if (notification.metrics.axisDirection == AxisDirection.down) {
            _dragOffset = _dragOffset! - notification.scrollDelta!;
          } else if (notification.metrics.axisDirection == AxisDirection.up) {
            _dragOffset = _dragOffset! + notification.scrollDelta!;
          }
379 380 381
          _checkDragOffset(notification.metrics.viewportDimension);
        }
      }
382 383 384 385 386 387
      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();
      }
388 389
    } else if (notification is OverscrollNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
390 391 392 393 394
        if (notification.metrics.axisDirection == AxisDirection.down) {
          _dragOffset = _dragOffset! - notification.overscroll;
        } else if (notification.metrics.axisDirection == AxisDirection.up) {
          _dragOffset = _dragOffset! + notification.overscroll;
        }
395 396 397 398 399 400 401 402 403 404
        _checkDragOffset(notification.metrics.viewportDimension);
      }
    } else if (notification is ScrollEndNotification) {
      switch (_mode) {
        case _RefreshIndicatorMode.armed:
          _show();
          break;
        case _RefreshIndicatorMode.drag:
          _dismiss(_RefreshIndicatorMode.canceled);
          break;
405 406 407 408 409
        case _RefreshIndicatorMode.canceled:
        case _RefreshIndicatorMode.done:
        case _RefreshIndicatorMode.refresh:
        case _RefreshIndicatorMode.snap:
        case null:
410 411 412
          // do nothing
          break;
      }
413 414
    }
    return false;
415 416
  }

417
  bool _handleIndicatorNotification(OverscrollIndicatorNotification notification) {
418
    if (notification.depth != 0 || !notification.leading) {
419
      return false;
420
    }
421
    if (_mode == _RefreshIndicatorMode.drag) {
422
      notification.disallowIndicator();
423
      return true;
424
    }
425
    return false;
426 427
  }

428 429 430 431 432 433 434
  bool _start(AxisDirection direction) {
    assert(_mode == null);
    assert(_isIndicatorAtTop == null);
    assert(_dragOffset == null);
    switch (direction) {
      case AxisDirection.down:
      case AxisDirection.up:
435
        _isIndicatorAtTop = true;
436 437 438 439 440 441 442
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        _isIndicatorAtTop = null;
        // we do not support horizontal scroll views.
        return false;
    }
443 444
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
445 446
    _positionController.value = 0.0;
    return true;
447 448
  }

449 450
  void _checkDragOffset(double containerExtent) {
    assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed);
451
    double newValue = _dragOffset! / (containerExtent * _kDragContainerExtentPercentage);
452
    if (_mode == _RefreshIndicatorMode.armed) {
453
      newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
454
    }
455
    _positionController.value = clampDouble(newValue, 0.0, 1.0); // this triggers various rebuilds
456
    if (_mode == _RefreshIndicatorMode.drag && _valueColor.value!.alpha == 0xFF) {
457
      _mode = _RefreshIndicatorMode.armed;
458
    }
459 460
  }

461
  // Stop showing the refresh indicator.
462
  Future<void> _dismiss(_RefreshIndicatorMode newMode) async {
463
    await Future<void>.value();
464 465 466 467
    // 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);
468
    setState(() {
469
      _mode = newMode;
470
    });
471
    switch (_mode!) {
472
      case _RefreshIndicatorMode.done:
473 474
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
475 476 477
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
478 479 480 481
      case _RefreshIndicatorMode.armed:
      case _RefreshIndicatorMode.drag:
      case _RefreshIndicatorMode.refresh:
      case _RefreshIndicatorMode.snap:
482
        assert(false);
483
    }
484 485 486
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
487 488 489 490
      setState(() {
        _mode = null;
      });
    }
491 492
  }

493 494 495
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
496
    final Completer<void> completer = Completer<void>();
497
    _pendingRefreshFuture = completer.future;
498
    _mode = _RefreshIndicatorMode.snap;
499
    _positionController
500
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
501
      .then<void>((void value) {
502 503 504 505 506 507
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

508
          final Future<void> refreshResult = widget.onRefresh();
509
          refreshResult.whenComplete(() {
510 511
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
512
              _dismiss(_RefreshIndicatorMode.done);
513 514 515
            }
          });
        }
516 517 518 519 520 521 522
      });
  }

  /// 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.
  ///
523
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
524
  /// makes it possible to refer to the [RefreshIndicatorState].
525
  ///
526 527
  /// The future returned from this method completes when the
  /// [RefreshIndicator.onRefresh] callback's future completes.
528 529 530 531 532 533 534
  ///
  /// 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.
535
  Future<void> show({ bool atTop = true }) {
536 537
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
538
      if (_mode == null) {
539
        _start(atTop ? AxisDirection.down : AxisDirection.up);
540
      }
541
      _show();
542
    }
543
    return _pendingRefreshFuture;
544 545
  }

546 547
  @override
  Widget build(BuildContext context) {
548
    assert(debugCheckHasMaterialLocalizations(context));
549
    final Widget child = NotificationListener<ScrollNotification>(
550
      onNotification: _handleScrollNotification,
551
      child: NotificationListener<OverscrollIndicatorNotification>(
552
        onNotification: _handleIndicatorNotification,
553
        child: widget.child,
554 555
      ),
    );
556 557 558 559 560 561 562 563 564 565
    assert(() {
      if (_mode == null) {
        assert(_dragOffset == null);
        assert(_isIndicatorAtTop == null);
      } else {
        assert(_dragOffset != null);
        assert(_isIndicatorAtTop != null);
      }
      return true;
    }());
566

567 568 569
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done;

570
    return Stack(
571 572
      children: <Widget>[
        child,
573
        if (_mode != null) Positioned(
574 575
          top: _isIndicatorAtTop! ? widget.edgeOffset : null,
          bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null,
576 577
          left: 0.0,
          right: 0.0,
578
          child: SizeTransition(
579
            axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0,
580
            sizeFactor: _positionFactor, // this is what brings it down
581
            child: Container(
582
              padding: _isIndicatorAtTop!
583 584
                ? EdgeInsets.only(top: widget.displacement)
                : EdgeInsets.only(bottom: widget.displacement),
585
              alignment: _isIndicatorAtTop!
586 587
                ? Alignment.topCenter
                : Alignment.bottomCenter,
588
              child: ScaleTransition(
589
                scale: _scaleFactor,
590
                child: AnimatedBuilder(
591
                  animation: _positionController,
592
                  builder: (BuildContext context, Widget? child) {
593
                    final Widget materialIndicator = RefreshProgressIndicator(
594
                      semanticsLabel: widget.semanticsLabel ?? MaterialLocalizations.of(context).refreshIndicatorSemanticLabel,
595
                      semanticsValue: widget.semanticsValue,
596 597
                      value: showIndeterminateIndicator ? null : _value.value,
                      valueColor: _valueColor,
598
                      backgroundColor: widget.backgroundColor,
599
                      strokeWidth: widget.strokeWidth,
600
                    );
601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623

                    final Widget cupertinoIndicator = CupertinoActivityIndicator(
                      color: widget.color,
                    );

                    switch(widget._indicatorType) {
                      case _IndicatorType.material:
                        return materialIndicator;

                      case _IndicatorType.adaptive: {
                        final ThemeData theme = Theme.of(context);
                        switch (theme.platform) {
                          case TargetPlatform.android:
                          case TargetPlatform.fuchsia:
                          case TargetPlatform.linux:
                          case TargetPlatform.windows:
                            return materialIndicator;
                          case TargetPlatform.iOS:
                          case TargetPlatform.macOS:
                            return cupertinoIndicator;
                        }
                      }
                    }
624 625 626 627
                  },
                ),
              ),
            ),
628
          ),
629 630
        ),
      ],
631 632 633
    );
  }
}