refresh_indicator.dart 18.4 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 9

import 'package:flutter/widgets.dart';

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

// 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
24
// to the RefreshIndicator's displacement.
25
const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150);
26 27 28

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

31 32 33 34 35
/// 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.
///
36
/// Used by [RefreshIndicator.onRefresh].
37
typedef RefreshCallback = Future<void> Function();
38

39 40
// The state machine moves through these modes only when the scrollable
// identified by scrollableKey has been scrolled to its min or max limit.
41
enum _RefreshIndicatorMode {
42 43 44 45 46 47
  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.
48 49
}

50 51
/// A widget that supports the Material "swipe to refresh" idiom.
///
Adam Barth's avatar
Adam Barth committed
52
/// When the child's [Scrollable] descendant overscrolls, an animated circular
53 54 55 56 57 58
/// 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.
///
59 60 61 62 63 64 65 66 67
/// ## 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]:
68 69
///
/// ```dart
70
/// ListView(
71 72
///   physics: const AlwaysScrollableScrollPhysics(),
///   children: ...
73
/// )
74 75 76
/// ```
///
/// A [RefreshIndicator] can only be used with a vertical scroll view.
77 78 79
///
/// See also:
///
80
///  * <https://material.io/design/platform-guidance/android-swipe-to-refresh.html>
81
///  * [RefreshIndicatorState], can be used to programmatically show the refresh indicator.
82 83
///  * [RefreshProgressIndicator], widget used by [RefreshIndicator] to show
///    the inner circular progress spinner during refreshes.
84
///  * [CupertinoSliverRefreshControl], an iOS equivalent of the pull-to-refresh pattern.
85 86 87
///    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.
88
class RefreshIndicator extends StatefulWidget {
89 90
  /// Creates a refresh indicator.
  ///
91 92
  /// The [onRefresh], [child], and [notificationPredicate] arguments must be
  /// non-null. The default
93
  /// [displacement] is 40.0 logical pixels.
94 95 96 97
  ///
  /// 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.
98
  /// The [semanticsValue] may be used to specify progress on the widget.
99
  const RefreshIndicator({
100 101
    Key? key,
    required this.child,
102
    this.displacement = 40.0,
103
    required this.onRefresh,
104
    this.color,
105
    this.backgroundColor,
106
    this.notificationPredicate = defaultScrollNotificationPredicate,
107 108
    this.semanticsLabel,
    this.semanticsValue,
109
    this.strokeWidth = 2.0
110 111
  }) : assert(child != null),
       assert(onRefresh != null),
112
       assert(notificationPredicate != null),
113
       assert(strokeWidth != null),
114
       super(key: key);
115

116 117
  /// The widget below this widget in the tree.
  ///
118 119
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
120 121
  ///
  /// Typically a [ListView] or [CustomScrollView].
122 123
  final Widget child;

124 125 126
  /// The distance from the child's top or bottom edge to where the refresh
  /// indicator will settle. During the drag that exposes the refresh indicator,
  /// its actual displacement may significantly exceed this value.
127 128 129 130
  final double displacement;

  /// 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
131
  /// [Future] must complete when the refresh operation is finished.
132
  final RefreshCallback onRefresh;
133

134
  /// The progress indicator's foreground color. The current theme's
135
  /// [ThemeData.accentColor] by default.
136
  final Color? color;
137 138 139

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

142 143 144 145 146 147
  /// 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;
148

149
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel}
150 151 152
  ///
  /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]
  /// if it is null.
153
  final String? semanticsLabel;
154

155
  /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue}
156
  final String? semanticsValue;
157

158 159 160 161 162
  /// Defines `strokeWidth` for `RefreshIndicator`.
  ///
  /// By default, the value of `strokeWidth` is 2.0 pixels.
  final double strokeWidth;

163
  @override
164
  RefreshIndicatorState createState() => RefreshIndicatorState();
165 166
}

167 168
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
169
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin<RefreshIndicator> {
170 171 172 173 174 175 176 177
  late AnimationController _positionController;
  late AnimationController _scaleController;
  late Animation<double> _positionFactor;
  late Animation<double> _scaleFactor;
  late Animation<double> _value;
  late Animation<Color?> _valueColor;

  _RefreshIndicatorMode? _mode;
178
  late Future<void> _pendingRefreshFuture;
179 180
  bool? _isIndicatorAtTop;
  double? _dragOffset;
181

182 183 184 185
  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);

186 187 188
  @override
  void initState() {
    super.initState();
189
    _positionController = AnimationController(vsync: this);
190 191
    _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween);
    _value = _positionController.drive(_threeQuarterTween); // The "value" of the circular progress indicator during a drag.
192

193
    _scaleController = AnimationController(vsync: this);
194
    _scaleFactor = _scaleController.drive(_oneToZeroTween);
195 196 197
  }

  @override
198
  void didChangeDependencies() {
199
    final ThemeData theme = Theme.of(context);
200 201
    _valueColor = _positionController.drive(
      ColorTween(
202 203
        begin: (widget.color ?? theme.accentColor).withOpacity(0.0),
        end: (widget.color ?? theme.accentColor).withOpacity(1.0),
204 205 206 207
      ).chain(CurveTween(
        curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit)
      )),
    );
208
    super.didChangeDependencies();
209 210 211 212
  }

  @override
  void dispose() {
213
    _positionController.dispose();
214 215 216 217
    _scaleController.dispose();
    super.dispose();
  }

Adam Barth's avatar
Adam Barth committed
218
  bool _handleScrollNotification(ScrollNotification notification) {
219
    if (!widget.notificationPredicate(notification))
220 221
      return false;
    if (notification is ScrollStartNotification && notification.metrics.extentBefore == 0.0 &&
222
        _mode == null && _start(notification.metrics.axisDirection)) {
223 224 225
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
226
      return false;
227
    }
228
    bool? indicatorAtTopNow;
229
    switch (notification.metrics.axisDirection) {
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
      case AxisDirection.down:
        indicatorAtTopNow = true;
        break;
      case AxisDirection.up:
        indicatorAtTopNow = false;
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        indicatorAtTopNow = null;
        break;
    }
    if (indicatorAtTopNow != _isIndicatorAtTop) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed)
        _dismiss(_RefreshIndicatorMode.canceled);
    } else if (notification is ScrollUpdateNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
        if (notification.metrics.extentBefore > 0.0) {
          _dismiss(_RefreshIndicatorMode.canceled);
        } else {
249
          _dragOffset = _dragOffset! - notification.scrollDelta!;
250 251 252
          _checkDragOffset(notification.metrics.viewportDimension);
        }
      }
253 254 255 256 257 258
      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();
      }
259 260
    } else if (notification is OverscrollNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
261
        _dragOffset = _dragOffset! - notification.overscroll;
262 263 264 265 266 267 268 269 270 271 272 273 274 275
        _checkDragOffset(notification.metrics.viewportDimension);
      }
    } else if (notification is ScrollEndNotification) {
      switch (_mode) {
        case _RefreshIndicatorMode.armed:
          _show();
          break;
        case _RefreshIndicatorMode.drag:
          _dismiss(_RefreshIndicatorMode.canceled);
          break;
        default:
          // do nothing
          break;
      }
276 277
    }
    return false;
278 279
  }

280 281 282 283 284 285
  bool _handleGlowNotification(OverscrollIndicatorNotification notification) {
    if (notification.depth != 0 || !notification.leading)
      return false;
    if (_mode == _RefreshIndicatorMode.drag) {
      notification.disallowGlow();
      return true;
286
    }
287
    return false;
288 289
  }

290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
  bool _start(AxisDirection direction) {
    assert(_mode == null);
    assert(_isIndicatorAtTop == null);
    assert(_dragOffset == null);
    switch (direction) {
      case AxisDirection.down:
        _isIndicatorAtTop = true;
        break;
      case AxisDirection.up:
        _isIndicatorAtTop = false;
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        _isIndicatorAtTop = null;
        // we do not support horizontal scroll views.
        return false;
    }
307 308
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
309 310
    _positionController.value = 0.0;
    return true;
311 312
  }

313 314
  void _checkDragOffset(double containerExtent) {
    assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed);
315
    double newValue = _dragOffset! / (containerExtent * _kDragContainerExtentPercentage);
316 317
    if (_mode == _RefreshIndicatorMode.armed)
      newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
318 319
    _positionController.value = newValue.clamp(0.0, 1.0); // this triggers various rebuilds
    if (_mode == _RefreshIndicatorMode.drag && _valueColor.value!.alpha == 0xFF)
320
      _mode = _RefreshIndicatorMode.armed;
321 322
  }

323
  // Stop showing the refresh indicator.
324
  Future<void> _dismiss(_RefreshIndicatorMode newMode) async {
325
    await Future<void>.value();
326 327 328 329
    // 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);
330
    setState(() {
331
      _mode = newMode;
332
    });
333 334
    switch (_mode) {
      case _RefreshIndicatorMode.done:
335 336
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
337 338 339 340 341
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
      default:
        assert(false);
342
    }
343 344 345
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
346 347 348 349
      setState(() {
        _mode = null;
      });
    }
350 351
  }

352 353 354
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
355
    final Completer<void> completer = Completer<void>();
356
    _pendingRefreshFuture = completer.future;
357
    _mode = _RefreshIndicatorMode.snap;
358
    _positionController
359
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
360
      .then<void>((void value) {
361
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
362
          assert(widget.onRefresh != null);
363 364 365 366 367
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

368
          final Future<void> refreshResult = widget.onRefresh();
369 370
          assert(() {
            if (refreshResult == null)
371 372
              FlutterError.reportError(FlutterErrorDetails(
                exception: FlutterError(
373 374 375
                  'The onRefresh callback returned null.\n'
                  'The RefreshIndicator onRefresh callback must return a Future.'
                ),
376
                context: ErrorDescription('when calling onRefresh'),
377 378 379
                library: 'material library',
              ));
            return true;
380
          }());
381 382 383 384
          // `refreshResult` has a non-nullable type, but might be null when
          // running with weak checking, so we need to null check it anyway (and
          // ignore the warning that the null-handling logic is dead code).
          if (refreshResult == null) // ignore: dead_code
385 386
            return;
          refreshResult.whenComplete(() {
387 388
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
389
              _dismiss(_RefreshIndicatorMode.done);
390 391 392
            }
          });
        }
393 394 395 396 397 398 399
      });
  }

  /// 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.
  ///
400
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
401
  /// makes it possible to refer to the [RefreshIndicatorState].
402
  ///
403 404
  /// The future returned from this method completes when the
  /// [RefreshIndicator.onRefresh] callback's future completes.
405 406 407 408 409 410 411
  ///
  /// 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.
412
  Future<void> show({ bool atTop = true }) {
413 414
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
415 416
      if (_mode == null)
        _start(atTop ? AxisDirection.down : AxisDirection.up);
417
      _show();
418
    }
419
    return _pendingRefreshFuture;
420 421
  }

422 423
  @override
  Widget build(BuildContext context) {
424
    assert(debugCheckHasMaterialLocalizations(context));
425
    final Widget child = NotificationListener<ScrollNotification>(
426
      onNotification: _handleScrollNotification,
427
      child: NotificationListener<OverscrollIndicatorNotification>(
428
        onNotification: _handleGlowNotification,
429
        child: widget.child,
430 431
      ),
    );
432 433 434 435 436 437 438 439 440 441
    assert(() {
      if (_mode == null) {
        assert(_dragOffset == null);
        assert(_isIndicatorAtTop == null);
      } else {
        assert(_dragOffset != null);
        assert(_isIndicatorAtTop != null);
      }
      return true;
    }());
442

443 444 445
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done;

446
    return Stack(
447 448
      children: <Widget>[
        child,
449
        if (_mode != null) Positioned(
450 451
          top: _isIndicatorAtTop! ? 0.0 : null,
          bottom: !_isIndicatorAtTop! ? 0.0 : null,
452 453
          left: 0.0,
          right: 0.0,
454
          child: SizeTransition(
455
            axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0,
456
            sizeFactor: _positionFactor, // this is what brings it down
457
            child: Container(
458
              padding: _isIndicatorAtTop!
459 460
                ? EdgeInsets.only(top: widget.displacement)
                : EdgeInsets.only(bottom: widget.displacement),
461
              alignment: _isIndicatorAtTop!
462 463
                ? Alignment.topCenter
                : Alignment.bottomCenter,
464
              child: ScaleTransition(
465
                scale: _scaleFactor,
466
                child: AnimatedBuilder(
467
                  animation: _positionController,
468
                  builder: (BuildContext context, Widget? child) {
469
                    return RefreshProgressIndicator(
470
                      semanticsLabel: widget.semanticsLabel ?? MaterialLocalizations.of(context).refreshIndicatorSemanticLabel,
471
                      semanticsValue: widget.semanticsValue,
472 473
                      value: showIndeterminateIndicator ? null : _value.value,
                      valueColor: _valueColor,
474
                      backgroundColor: widget.backgroundColor,
475
                      strokeWidth: widget.strokeWidth,
476 477 478 479 480
                    );
                  },
                ),
              ),
            ),
481
          ),
482 483
        ),
      ],
484 485 486
    );
  }
}