refresh_indicator.dart 16.1 KB
Newer Older
1 2 3 4 5
// Copyright 2016 The Chromium Authors. All rights reserved.
// 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 10

import 'package:flutter/widgets.dart';

import 'progress_indicator.dart';
11
import 'theme.dart';
12 13 14 15 16 17 18 19 20 21

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

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

29 30 31 32 33
/// 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.
///
34
/// Used by [RefreshIndicator.onRefresh].
35 36
typedef Future<Null> RefreshCallback();

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

48 49
/// A widget that supports the Material "swipe to refresh" idiom.
///
Adam Barth's avatar
Adam Barth committed
50
/// When the child's [Scrollable] descendant overscrolls, an animated circular
51 52 53 54 55 56
/// 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.
///
57 58 59 60 61 62 63 64 65 66 67 68 69 70
/// If the [Scrollable] might not have enough content to overscroll, consider
/// settings its `physics` property to [AlwaysScrollableScrollPhysics]:
///
/// ```dart
/// new ListView(
///   physics: const AlwaysScrollableScrollPhysics(),
///   children: ...
//  )
/// ```
///
/// Using [AlwaysScrollableScrollPhysics] will ensure that the scroll view is
/// always scrollable and, therefore, can trigger the [RefreshIndicator].
///
/// A [RefreshIndicator] can only be used with a vertical scroll view.
71 72 73
///
/// See also:
///
74
///  * <https://material.google.com/patterns/swipe-to-refresh.html>
75
///  * [RefreshIndicatorState], can be used to programmatically show the refresh indicator.
76 77 78 79 80 81
///  * [RefreshProgressIndicator], widget used by [RefreshIndicator] to show
///    the inner circular progress spinner during refreshes.
///  * [CupertinoRefreshControl], an iOS equivalent of the pull-to-refresh pattern.
///    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.
82
class RefreshIndicator extends StatefulWidget {
83 84
  /// Creates a refresh indicator.
  ///
85 86
  /// The [onRefresh], [child], and [notificationPredicate] arguments must be
  /// non-null. The default
87
  /// [displacement] is 40.0 logical pixels.
88
  const RefreshIndicator({
89
    Key key,
90
    @required this.child,
91
    this.displacement: 40.0,
92
    @required this.onRefresh,
93
    this.color,
94 95
    this.backgroundColor,
    this.notificationPredicate: defaultScrollNotificationPredicate,
96 97
  }) : assert(child != null),
       assert(onRefresh != null),
98
       assert(notificationPredicate != null),
99
       super(key: key);
100

101 102
  /// The widget below this widget in the tree.
  ///
103 104
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
105 106
  ///
  /// Typically a [ListView] or [CustomScrollView].
107 108
  final Widget child;

109 110 111
  /// 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.
112 113 114 115
  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
116
  /// [Future] must complete when the refresh operation is finished.
117
  final RefreshCallback onRefresh;
118

119
  /// The progress indicator's foreground color. The current theme's
120
  /// [ThemeData.accentColor] by default.
121 122 123 124 125
  final Color color;

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

127 128 129 130 131 132
  /// 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;
133

134
  @override
135
  RefreshIndicatorState createState() => new RefreshIndicatorState();
136 137
}

138 139
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
140
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin {
141
  AnimationController _positionController;
142
  AnimationController _scaleController;
143
  Animation<double> _positionFactor;
144
  Animation<double> _scaleFactor;
145
  Animation<double> _value;
146 147
  Animation<Color> _valueColor;

148 149
  _RefreshIndicatorMode _mode;
  Future<Null> _pendingRefreshFuture;
150 151
  bool _isIndicatorAtTop;
  double _dragOffset;
152 153 154 155 156

  @override
  void initState() {
    super.initState();

157 158 159 160 161
    _positionController = new AnimationController(vsync: this);
    _positionFactor = new Tween<double>(
      begin: 0.0,
      end: _kDragSizeFactorLimit,
    ).animate(_positionController);
162
    _value = new Tween<double>( // The "value" of the circular progress indicator during a drag.
163
      begin: 0.0,
164 165
      end: 0.75,
    ).animate(_positionController);
166 167

    _scaleController = new AnimationController(vsync: this);
168 169 170 171 172 173 174
    _scaleFactor = new Tween<double>(
      begin: 1.0,
      end: 0.0,
    ).animate(_scaleController);
  }

  @override
175
  void didChangeDependencies() {
176 177
    final ThemeData theme = Theme.of(context);
    _valueColor = new ColorTween(
178 179
      begin: (widget.color ?? theme.accentColor).withOpacity(0.0),
      end: (widget.color ?? theme.accentColor).withOpacity(1.0)
180 181 182 183
    ).animate(new CurvedAnimation(
      parent: _positionController,
      curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit)
    ));
184
    super.didChangeDependencies();
185 186 187 188
  }

  @override
  void dispose() {
189
    _positionController.dispose();
190 191 192 193
    _scaleController.dispose();
    super.dispose();
  }

Adam Barth's avatar
Adam Barth committed
194
  bool _handleScrollNotification(ScrollNotification notification) {
195
    if (!widget.notificationPredicate(notification))
196 197
      return false;
    if (notification is ScrollStartNotification && notification.metrics.extentBefore == 0.0 &&
198
        _mode == null && _start(notification.metrics.axisDirection)) {
199 200 201
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
202
      return false;
203 204
    }
    bool indicatorAtTopNow;
205
    switch (notification.metrics.axisDirection) {
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
      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 {
          _dragOffset -= notification.scrollDelta;
          _checkDragOffset(notification.metrics.viewportDimension);
        }
      }
    } else if (notification is OverscrollNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
        _dragOffset -= notification.overscroll / 2.0;
        _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;
      }
246 247
    }
    return false;
248 249
  }

250 251 252 253 254 255
  bool _handleGlowNotification(OverscrollIndicatorNotification notification) {
    if (notification.depth != 0 || !notification.leading)
      return false;
    if (_mode == _RefreshIndicatorMode.drag) {
      notification.disallowGlow();
      return true;
256
    }
257
    return false;
258 259
  }

260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
  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;
    }
277 278
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
279 280
    _positionController.value = 0.0;
    return true;
281 282
  }

283 284 285 286 287 288 289 290
  void _checkDragOffset(double containerExtent) {
    assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed);
    double newValue = _dragOffset / (containerExtent * _kDragContainerExtentPercentage);
    if (_mode == _RefreshIndicatorMode.armed)
      newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
    _positionController.value = newValue.clamp(0.0, 1.0); // this triggers various rebuilds
    if (_mode == _RefreshIndicatorMode.drag && _valueColor.value.alpha == 0xFF)
      _mode = _RefreshIndicatorMode.armed;
291 292
  }

293 294 295 296 297 298
  // Stop showing the refresh indicator.
  Future<Null> _dismiss(_RefreshIndicatorMode newMode) async {
    // 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);
299
    setState(() {
300
      _mode = newMode;
301
    });
302 303
    switch (_mode) {
      case _RefreshIndicatorMode.done:
304 305
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
306 307 308 309 310
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
      default:
        assert(false);
311
    }
312 313 314
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
315 316 317 318
      setState(() {
        _mode = null;
      });
    }
319 320
  }

321 322 323
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
324
    final Completer<Null> completer = new Completer<Null>();
325
    _pendingRefreshFuture = completer.future;
326
    _mode = _RefreshIndicatorMode.snap;
327
    _positionController
328
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
329
      .then<void>((Null value) {
330
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
331
          assert(widget.onRefresh != null);
332 333 334 335 336
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

337 338 339 340 341 342 343 344 345 346 347 348
          final Future<Null> refreshResult = widget.onRefresh();
          assert(() {
            if (refreshResult == null)
              FlutterError.reportError(new FlutterErrorDetails(
                exception: new FlutterError(
                  'The onRefresh callback returned null.\n'
                  'The RefreshIndicator onRefresh callback must return a Future.'
                ),
                context: 'when calling onRefresh',
                library: 'material library',
              ));
            return true;
349
          }());
350 351 352
          if (refreshResult == null)
            return;
          refreshResult.whenComplete(() {
353 354
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
355
              _dismiss(_RefreshIndicatorMode.done);
356 357 358
            }
          });
        }
359 360 361 362 363 364 365
      });
  }

  /// 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.
  ///
366
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
367
  /// makes it possible to refer to the [RefreshIndicatorState].
368
  ///
369 370
  /// The future returned from this method completes when the
  /// [RefreshIndicator.onRefresh] callback's future completes.
371 372 373 374 375 376 377 378
  ///
  /// 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.
  Future<Null> show({ bool atTop: true }) {
379 380
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
381 382
      if (_mode == null)
        _start(atTop ? AxisDirection.down : AxisDirection.up);
383
      _show();
384
    }
385
    return _pendingRefreshFuture;
386 387
  }

388
  final GlobalKey _key = new GlobalKey();
389

390 391
  @override
  Widget build(BuildContext context) {
392
    final Widget child = new NotificationListener<ScrollNotification>(
393 394 395 396
      key: _key,
      onNotification: _handleScrollNotification,
      child: new NotificationListener<OverscrollIndicatorNotification>(
        onNotification: _handleGlowNotification,
397
        child: widget.child,
398 399 400 401 402 403 404 405 406
      ),
    );
    if (_mode == null) {
      assert(_dragOffset == null);
      assert(_isIndicatorAtTop == null);
      return child;
    }
    assert(_dragOffset != null);
    assert(_isIndicatorAtTop != null);
407

408 409 410 411 412 413 414 415 416 417 418 419
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done;

    return new Stack(
      children: <Widget>[
        child,
        new Positioned(
          top: _isIndicatorAtTop ? 0.0 : null,
          bottom: !_isIndicatorAtTop ? 0.0 : null,
          left: 0.0,
          right: 0.0,
          child: new SizeTransition(
420
            axisAlignment: _isIndicatorAtTop ? 1.0 : -1.0,
421 422 423
            sizeFactor: _positionFactor, // this is what brings it down
            child: new Container(
              padding: _isIndicatorAtTop
424 425
                ? new EdgeInsets.only(top: widget.displacement)
                : new EdgeInsets.only(bottom: widget.displacement),
426
              alignment: _isIndicatorAtTop
427 428
                ? Alignment.topCenter
                : Alignment.bottomCenter,
429 430 431 432 433 434 435 436
              child: new ScaleTransition(
                scale: _scaleFactor,
                child: new AnimatedBuilder(
                  animation: _positionController,
                  builder: (BuildContext context, Widget child) {
                    return new RefreshProgressIndicator(
                      value: showIndeterminateIndicator ? null : _value.value,
                      valueColor: _valueColor,
437
                      backgroundColor: widget.backgroundColor,
438 439 440 441 442
                    );
                  },
                ),
              ),
            ),
443
          ),
444 445
        ),
      ],
446 447 448
    );
  }
}