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
  late Color _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary;
279

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

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

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

  @override
296
  void didChangeDependencies() {
297
    _setupColorTween();
298
    super.didChangeDependencies();
299 300
  }

301 302 303 304
  @override
  void didUpdateWidget(covariant RefreshIndicator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.color != widget.color) {
305
      _setupColorTween();
306 307 308
    }
  }

309 310
  @override
  void dispose() {
311
    _positionController.dispose();
312 313 314 315
    _scaleController.dispose();
    super.dispose();
  }

316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
  void _setupColorTween() {
    // Reset the current value color.
    _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary;
    final Color color = _effectiveValueColor;
    if (color.alpha == 0x00) {
      // Set an always stopped animation instead of a driven tween.
      _valueColor = AlwaysStoppedAnimation<Color>(color);
    } else {
      // Respect the alpha of the given color.
      _valueColor = _positionController.drive(
        ColorTween(
          begin: color.withAlpha(0),
          end: color.withAlpha(color.alpha),
        ).chain(
          CurveTween(
            curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit),
          ),
        ),
      );
    }
  }

338
  bool _shouldStart(ScrollNotification notification) {
339 340 341 342 343
    // 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))
344 345
      && (( notification.metrics.axisDirection == AxisDirection.up && notification.metrics.extentAfter == 0.0)
            || (notification.metrics.axisDirection == AxisDirection.down && notification.metrics.extentBefore == 0.0))
346 347 348 349
      && _mode == null
      && _start(notification.metrics.axisDirection);
  }

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

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

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

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

463
  // Stop showing the refresh indicator.
464
  Future<void> _dismiss(_RefreshIndicatorMode newMode) async {
465
    await Future<void>.value();
466 467 468 469
    // 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);
470
    setState(() {
471
      _mode = newMode;
472
    });
473
    switch (_mode!) {
474
      case _RefreshIndicatorMode.done:
475
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
476 477
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
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

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

606
                    switch (widget._indicatorType) {
607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623
                      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
    );
  }
}