refresh_indicator.dart 15.7 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
import 'package:flutter/foundation.dart';
9 10 11
import 'package:flutter/widgets.dart';

import 'progress_indicator.dart';
12
import 'theme.dart';
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

// 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
// to the RefreshIndicator's displacment.
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);

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

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

49 50
/// A widget that supports the Material "swipe to refresh" idiom.
///
Adam Barth's avatar
Adam Barth committed
51
/// When the child's [Scrollable] descendant overscrolls, an animated circular
52 53 54 55 56 57
/// 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.
///
58 59 60 61 62 63 64 65 66 67 68 69 70 71
/// 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.
72 73 74
///
/// See also:
///
75
///  * <https://material.google.com/patterns/swipe-to-refresh.html>
76
///  * [RefreshIndicatorState], can be used to programmatically show the refresh indicator.
77
///  * [RefreshProgressIndicator].
78
class RefreshIndicator extends StatefulWidget {
79 80
  /// Creates a refresh indicator.
  ///
81 82
  /// The [onRefresh], [child], and [notificationPredicate] arguments must be
  /// non-null. The default
83
  /// [displacement] is 40.0 logical pixels.
84
  const RefreshIndicator({
85
    Key key,
86
    @required this.child,
87
    this.displacement: 40.0,
88
    @required this.onRefresh,
89
    this.color,
90 91
    this.backgroundColor,
    this.notificationPredicate: defaultScrollNotificationPredicate,
92 93
  }) : assert(child != null),
       assert(onRefresh != null),
94
       assert(notificationPredicate != null),
95
       super(key: key);
96

97 98 99 100
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
  final Widget child;

101 102 103
  /// 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.
104 105 106 107
  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
108
  /// [Future] must complete when the refresh operation is finished.
109
  final RefreshCallback onRefresh;
110

111
  /// The progress indicator's foreground color. The current theme's
112
  /// [ThemeData.accentColor] by default.
113 114 115 116 117
  final Color color;

  /// The progress indicator's background color. The current theme's
  /// [ThemeData.canvasColor] by default.
  final Color backgroundColor;
118 119 120 121 122 123 124
  
  /// 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;
125

126
  @override
127
  RefreshIndicatorState createState() => new RefreshIndicatorState();
128 129
}

130 131
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
132
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin {
133
  AnimationController _positionController;
134
  AnimationController _scaleController;
135
  Animation<double> _positionFactor;
136
  Animation<double> _scaleFactor;
137
  Animation<double> _value;
138 139
  Animation<Color> _valueColor;

140 141
  _RefreshIndicatorMode _mode;
  Future<Null> _pendingRefreshFuture;
142 143
  bool _isIndicatorAtTop;
  double _dragOffset;
144 145 146 147 148

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

149 150 151 152 153
    _positionController = new AnimationController(vsync: this);
    _positionFactor = new Tween<double>(
      begin: 0.0,
      end: _kDragSizeFactorLimit,
    ).animate(_positionController);
154
    _value = new Tween<double>( // The "value" of the circular progress indicator during a drag.
155
      begin: 0.0,
156 157
      end: 0.75,
    ).animate(_positionController);
158 159

    _scaleController = new AnimationController(vsync: this);
160 161 162 163 164 165 166
    _scaleFactor = new Tween<double>(
      begin: 1.0,
      end: 0.0,
    ).animate(_scaleController);
  }

  @override
167
  void didChangeDependencies() {
168 169
    final ThemeData theme = Theme.of(context);
    _valueColor = new ColorTween(
170 171
      begin: (widget.color ?? theme.accentColor).withOpacity(0.0),
      end: (widget.color ?? theme.accentColor).withOpacity(1.0)
172 173 174 175
    ).animate(new CurvedAnimation(
      parent: _positionController,
      curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit)
    ));
176
    super.didChangeDependencies();
177 178 179 180
  }

  @override
  void dispose() {
181
    _positionController.dispose();
182 183 184 185
    _scaleController.dispose();
    super.dispose();
  }

Adam Barth's avatar
Adam Barth committed
186
  bool _handleScrollNotification(ScrollNotification notification) {
187
    if (!widget.notificationPredicate(notification))
188 189
      return false;
    if (notification is ScrollStartNotification && notification.metrics.extentBefore == 0.0 &&
190
        _mode == null && _start(notification.metrics.axisDirection)) {
191 192 193
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
194
      return false;
195 196
    }
    bool indicatorAtTopNow;
197
    switch (notification.metrics.axisDirection) {
198 199 200 201 202 203 204 205 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
      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;
      }
238 239
    }
    return false;
240 241
  }

242 243 244 245 246 247
  bool _handleGlowNotification(OverscrollIndicatorNotification notification) {
    if (notification.depth != 0 || !notification.leading)
      return false;
    if (_mode == _RefreshIndicatorMode.drag) {
      notification.disallowGlow();
      return true;
248
    }
249
    return false;
250 251
  }

252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
  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;
    }
269 270
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
271 272
    _positionController.value = 0.0;
    return true;
273 274
  }

275 276 277 278 279 280 281 282
  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;
283 284
  }

285 286 287 288 289 290
  // 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);
291
    setState(() {
292
      _mode = newMode;
293
    });
294 295
    switch (_mode) {
      case _RefreshIndicatorMode.done:
296 297
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
298 299 300 301 302
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
      default:
        assert(false);
303
    }
304 305 306
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
307 308 309 310
      setState(() {
        _mode = null;
      });
    }
311 312
  }

313 314 315
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
316
    final Completer<Null> completer = new Completer<Null>();
317
    _pendingRefreshFuture = completer.future;
318
    _mode = _RefreshIndicatorMode.snap;
319
    _positionController
320
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
321
      .then<Null>((Null value) {
322
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
323
          assert(widget.onRefresh != null);
324 325 326 327 328
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
          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;
          });
          if (refreshResult == null)
            return;
          refreshResult.whenComplete(() {
345 346
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
347
              _dismiss(_RefreshIndicatorMode.done);
348 349 350
            }
          });
        }
351 352 353 354 355 356 357
      });
  }

  /// 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.
  ///
358
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
359
  /// makes it possible to refer to the [RefreshIndicatorState].
360
  ///
361 362
  /// The future returned from this method completes when the
  /// [RefreshIndicator.onRefresh] callback's future completes.
363 364 365 366 367 368 369 370
  ///
  /// 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 }) {
371 372
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
373 374
      if (_mode == null)
        _start(atTop ? AxisDirection.down : AxisDirection.up);
375
      _show();
376
    }
377
    return _pendingRefreshFuture;
378 379
  }

380
  final GlobalKey _key = new GlobalKey();
381

382 383
  @override
  Widget build(BuildContext context) {
384
    final Widget child = new NotificationListener<ScrollNotification>(
385 386 387 388
      key: _key,
      onNotification: _handleScrollNotification,
      child: new NotificationListener<OverscrollIndicatorNotification>(
        onNotification: _handleGlowNotification,
389
        child: widget.child,
390 391 392 393 394 395 396 397 398
      ),
    );
    if (_mode == null) {
      assert(_dragOffset == null);
      assert(_isIndicatorAtTop == null);
      return child;
    }
    assert(_dragOffset != null);
    assert(_isIndicatorAtTop != null);
399

400 401 402 403 404 405 406 407 408 409 410 411
    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(
412
            axisAlignment: _isIndicatorAtTop ? 1.0 : -1.0,
413 414 415
            sizeFactor: _positionFactor, // this is what brings it down
            child: new Container(
              padding: _isIndicatorAtTop
416 417
                ? new EdgeInsets.only(top: widget.displacement)
                : new EdgeInsets.only(bottom: widget.displacement),
418
              alignment: _isIndicatorAtTop
419 420
                ? Alignment.topCenter
                : Alignment.bottomCenter,
421 422 423 424 425 426 427 428
              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,
429
                      backgroundColor: widget.backgroundColor,
430 431 432 433 434
                    );
                  },
                ),
              ),
            ),
435
          ),
436 437
        ),
      ],
438 439 440
    );
  }
}