overscroll_indicator.dart 7.82 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 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' show Timer;

import 'package:flutter/widgets.dart';

import 'theme.dart';

const double _kMinIndicatorExtent = 0.0;
const double _kMaxIndicatorExtent = 64.0;
const double _kMinIndicatorOpacity = 0.0;
const double _kMaxIndicatorOpacity = 0.25;
const Duration _kIndicatorHideDuration = const Duration(milliseconds: 200);
16
const Duration _kIndicatorTimeoutDuration = const Duration(milliseconds: 500);
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
final Tween<double> _kIndicatorOpacity = new Tween<double>(begin: 0.0, end: 0.3);

class _Painter extends CustomPainter {
  _Painter({
    this.scrollDirection,
    this.extent, // Indicator width or height, per scrollDirection.
    this.isLeading, // Similarly true if the indicator appears at the top/left.
    this.color
  });

  final Axis scrollDirection;
  final double extent;
  final bool isLeading;
  final Color color;

  void paintIndicator(Canvas canvas, Size size) {
    final double rectBias = extent / 2.0;
    final double arcBias = extent;

    final Path path = new Path();
    switch(scrollDirection) {
      case Axis.vertical:
        final double width = size.width;
        if (isLeading) {
          path.moveTo(0.0, 0.0);
          path.relativeLineTo(width, 0.0);
          path.relativeLineTo(0.0, rectBias);
          path.relativeQuadraticBezierTo(width / -2.0, arcBias, -width, 0.0);
        } else {
          path.moveTo(0.0, size.height);
          path.relativeLineTo(width, 0.0);
          path.relativeLineTo(0.0, -rectBias);
          path.relativeQuadraticBezierTo(width / -2.0, -arcBias, -width, 0.0);
        }
        break;
      case Axis.horizontal:
        final double height = size.height;
        if (isLeading) {
          path.moveTo(0.0, 0.0);
          path.relativeLineTo(0.0, height);
          path.relativeLineTo(rectBias, 0.0);
          path.relativeQuadraticBezierTo(arcBias, height / -2.0, 0.0, -height);
        } else {
          path.moveTo(size.width, 0.0);
          path.relativeLineTo(0.0, height);
          path.relativeLineTo(-rectBias, 0.0);
          path.relativeQuadraticBezierTo(-arcBias, height / -2.0, 0.0, -height);
        }
        break;
    }
    path.close();

    final Paint paint = new Paint()..color = color;
    canvas.drawPath(path, paint);
  }

  @override
  void paint(Canvas canvas, Size size) {
    if (color.alpha == 0)
      return;
    paintIndicator(canvas, size);
  }

  @override
  bool shouldRepaint(_Painter oldPainter) {
    return oldPainter.scrollDirection != scrollDirection
      || oldPainter.extent != extent
      || oldPainter.isLeading != isLeading
      || oldPainter.color != color;
  }
}

/// When the child's Scrollable descendant overscrolls, displays a
/// a translucent arc over the affected edge of the child.
/// If the OverscrollIndicator's child has more than one Scrollable descendant
/// the scrollableKey parameter can be used to identify the one to track.
class OverscrollIndicator extends StatefulWidget {
94 95 96 97 98 99 100 101
  /// Creates an overscroll indicator.
  ///
  /// The [child] argument must not be null.
  OverscrollIndicator({
    Key key,
    this.scrollableKey,
    this.child
  }) : super(key: key) {
102 103 104
    assert(child != null);
  }

105 106 107
  /// Identifies the [Scrollable] descendant of child that the overscroll
  /// indicator will track. Can be null if there's only one [Scrollable]
  /// descendant.
108
  final Key scrollableKey;
109 110 111 112

  /// The overscroll indicator will be stacked on top of this child. The
  /// indicator will appear when child's [Scrollable] descendant is
  /// over-scrolled.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
  final Widget child;

  @override
  _OverscrollIndicatorState createState() => new _OverscrollIndicatorState();
}

class _OverscrollIndicatorState extends State<OverscrollIndicator> {
  final AnimationController _extentAnimation = new AnimationController(
    lowerBound: _kMinIndicatorExtent,
    upperBound: _kMaxIndicatorExtent,
    duration: _kIndicatorHideDuration
  );

  Timer _hideTimer;
  Axis _scrollDirection;
  double _scrollOffset;
  double _minScrollOffset;
  double _maxScrollOffset;

  void _hide() {
    _hideTimer?.cancel();
    _hideTimer = null;
135 136 137 138
    // Gaurding _hide() being called while indicator is already animating.
    if (!_extentAnimation.isAnimating) {
      _extentAnimation.reverse();
    }
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
  }

  void _updateState(ScrollableState scrollable) {
    if (scrollable.scrollBehavior is! ExtentScrollBehavior)
      return;
    final ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior;
    _scrollDirection = scrollable.config.scrollDirection;
    _scrollOffset = scrollable.scrollOffset;
    _minScrollOffset = scrollBehavior.minScrollOffset;
    _maxScrollOffset = scrollBehavior.maxScrollOffset;
  }

  void _onScrollStarted(ScrollableState scrollable) {
    _updateState(scrollable);
  }

  void _onScrollUpdated(ScrollableState scrollable) {
    final double value = scrollable.scrollOffset;
157 158 159 160 161 162 163 164 165
    if (_isOverscroll(value)) {
      _refreshHideTimer();
      // Hide the indicator as soon as user starts scrolling in the reverse direction of overscroll.
      if (_isReverseScroll(value)) {
        _hide();
      } else {
        // Changing the animation's value causes an implicit setState().
        _extentAnimation.value = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset;
      }
166 167 168 169 170 171 172 173 174
    }
    _updateState(scrollable);
  }

  void _onScrollEnded(ScrollableState scrollable) {
    _updateState(scrollable);
    _hide();
  }

175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
  void _refreshHideTimer() {
    _hideTimer?.cancel();
    _hideTimer = new Timer(_kIndicatorTimeoutDuration, _hide);
  }

  bool _isOverscroll(double scrollOffset) {
    return (scrollOffset < _minScrollOffset || scrollOffset > _maxScrollOffset) &&
      ((scrollOffset - _scrollOffset).abs() > kPixelScrollTolerance.distance);
  }

  bool _isReverseScroll(double scrollOffset) {
    final double delta = _scrollOffset - scrollOffset;
    return scrollOffset < _minScrollOffset ? delta < 0 : delta > 0;
  }

190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
  bool _handleScrollNotification(ScrollNotification notification) {
    if (config.scrollableKey == null || config.scrollableKey == notification.scrollable.config.key) {
      final ScrollableState scrollable = notification.scrollable;
      switch(notification.kind) {
        case ScrollNotificationKind.started:
          _onScrollStarted(scrollable);
          break;
        case ScrollNotificationKind.updated:
          _onScrollUpdated(scrollable);
          break;
        case ScrollNotificationKind.ended:
          _onScrollEnded(scrollable);
          break;
      }
    }
    return false;
  }

  @override
  void dispose() {
    _hideTimer?.cancel();
    _hideTimer = null;
212
    _extentAnimation.dispose();
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
    super.dispose();
  }

  Color get _indicatorColor {
    final Color accentColor = Theme.of(context).accentColor.withOpacity(0.35);
    final double t = (_extentAnimation.value - _kMinIndicatorExtent) / (_kMaxIndicatorExtent - _kMinIndicatorExtent);
    return accentColor.withOpacity(_kIndicatorOpacity.lerp(Curves.easeIn.transform(t)));
  }

  @override
  Widget build(BuildContext context) {
    return new NotificationListener<ScrollNotification>(
      onNotification: _handleScrollNotification,
      child: new AnimatedBuilder(
        animation: _extentAnimation,
        builder: (BuildContext context, Widget child) {
          if (_scrollDirection == null) // Haven't seen a scroll yet.
            return child;
          return new CustomPaint(
            foregroundPainter: new _Painter(
              scrollDirection: _scrollDirection,
              extent: _extentAnimation.value,
              isLeading: _scrollOffset < _minScrollOffset,
              color: _indicatorColor
            ),
            child: child
          );
        },
241 242 243 244
        child: new ClampOverscrolls(
          child: config.child,
          value: true
        )
245 246 247
      )
    );
  }
248
}