From c058cf2e81f3e31fbc30f9249cca17f2baba921f Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Wed, 6 Apr 2016 16:46:37 -0700 Subject: [PATCH] Overscroll indicator for MaterialList Overscroll indicator for MaterialList --- .../material_gallery/lib/demo/list_demo.dart | 69 +++++---- packages/flutter/lib/material.dart | 1 + .../src/animation/animation_controller.dart | 1 + packages/flutter/lib/src/material/list.dart | 24 +++- .../lib/src/material/overscroll_painter.dart | 134 ++++++++++++++++++ .../lib/src/material/scrollbar_painter.dart | 10 +- .../flutter/lib/src/widgets/scrollable.dart | 56 +++++++- .../lib/src/widgets/scrollable_list.dart | 13 +- 8 files changed, 249 insertions(+), 59 deletions(-) create mode 100644 packages/flutter/lib/src/material/overscroll_painter.dart diff --git a/examples/material_gallery/lib/demo/list_demo.dart b/examples/material_gallery/lib/demo/list_demo.dart index 821e0ed90a..28fd8fc52f 100644 --- a/examples/material_gallery/lib/demo/list_demo.dart +++ b/examples/material_gallery/lib/demo/list_demo.dart @@ -4,12 +4,6 @@ import 'package:flutter/material.dart'; -enum ListDemoItemSize { - oneLine, - twoLine, - threeLine -} - class ListDemo extends StatefulWidget { ListDemo({ Key key }) : super(key: key); @@ -21,7 +15,7 @@ class ListDemoState extends State { final GlobalKey scaffoldKey = new GlobalKey(); PersistentBottomSheetController _bottomSheet; - ListDemoItemSize _itemSize = ListDemoItemSize.threeLine; + MaterialListType _itemType = MaterialListType.threeLine; bool _dense = false; bool _showAvatars = true; bool _showIcons = false; @@ -31,9 +25,9 @@ class ListDemoState extends State { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N' ]; - void changeItemSize(ListDemoItemSize size) { + void changeItemType(MaterialListType type) { setState(() { - _itemSize = size; + _itemType = type; }); _bottomSheet?.setState(() { }); } @@ -51,28 +45,28 @@ class ListDemoState extends State { new ListItem( dense: true, title: new Text('One-line'), - trailing: new Radio( - value: ListDemoItemSize.oneLine, - groupValue: _itemSize, - onChanged: changeItemSize + trailing: new Radio( + value: _showAvatars ? MaterialListType.oneLineWithAvatar : MaterialListType.oneLine, + groupValue: _itemType, + onChanged: changeItemType ) ), new ListItem( dense: true, title: new Text('Two-line'), - trailing: new Radio( - value: ListDemoItemSize.twoLine, - groupValue: _itemSize, - onChanged: changeItemSize + trailing: new Radio( + value: MaterialListType.twoLine, + groupValue: _itemType, + onChanged: changeItemType ) ), new ListItem( dense: true, title: new Text('Three-line'), - trailing: new Radio( - value: ListDemoItemSize.threeLine, - groupValue: _itemSize, - onChanged: changeItemSize + trailing: new Radio( + value: MaterialListType.threeLine, + groupValue: _itemType, + onChanged: changeItemType ) ), new ListItem( @@ -135,17 +129,17 @@ class ListDemoState extends State { Widget buildListItem(BuildContext context, String item) { Widget secondary; - if (_itemSize == ListDemoItemSize.twoLine) { + if (_itemType == MaterialListType.twoLine) { secondary = new Text( "Additional item information." ); - } else if (_itemSize == ListDemoItemSize.threeLine) { + } else if (_itemType == MaterialListType.threeLine) { secondary = new Text( "Even more additional list item information appears on line three." ); } return new ListItem( - isThreeLine: _itemSize == ListDemoItemSize.threeLine, + isThreeLine: _itemType == MaterialListType.threeLine, dense: _dense, leading: _showAvatars ? new CircleAvatar(child: new Text(item)) : null, title: new Text('This item represents $item.'), @@ -157,16 +151,17 @@ class ListDemoState extends State { @override Widget build(BuildContext context) { final String layoutText = _dense ? " \u2013 Dense" : ""; - String itemSizeText; - switch(_itemSize) { - case ListDemoItemSize.oneLine: - itemSizeText = 'Single-Line'; + String itemTypeText; + switch(_itemType) { + case MaterialListType.oneLine: + case MaterialListType.oneLineWithAvatar: + itemTypeText = 'Single-line'; break; - case ListDemoItemSize.twoLine: - itemSizeText = 'Two-Line'; + case MaterialListType.twoLine: + itemTypeText = 'Two-line'; break; - case ListDemoItemSize.threeLine: - itemSizeText = 'Three-Line'; + case MaterialListType.threeLine: + itemTypeText = 'Three-line'; break; } @@ -177,7 +172,7 @@ class ListDemoState extends State { return new Scaffold( key: scaffoldKey, appBar: new AppBar( - title: new Text('Scrolling list\n$itemSizeText$layoutText'), + title: new Text('Scrolling list\n$itemTypeText$layoutText'), actions: [ new IconButton( icon: Icons.sort_by_alpha, @@ -196,9 +191,11 @@ class ListDemoState extends State { ) ] ), - body: new Block( - padding: new EdgeInsets.all(_dense ? 4.0 : 8.0), - children: listItems.toList() + body: new MaterialList( + type: _itemType, + scrollablePadding: new EdgeInsets.all(_dense ? 4.0 : 8.0), + clampOverscrolls: true, + children: listItems ) ); } diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 407e821ec4..bc457621a0 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -40,6 +40,7 @@ export 'src/material/input.dart'; export 'src/material/list.dart'; export 'src/material/list_item.dart'; export 'src/material/material.dart'; +export 'src/material/overscroll_painter.dart'; export 'src/material/page.dart'; export 'src/material/popup_menu.dart'; export 'src/material/progress_indicator.dart'; diff --git a/packages/flutter/lib/src/animation/animation_controller.dart b/packages/flutter/lib/src/animation/animation_controller.dart index ef42916373..2cb9222eef 100644 --- a/packages/flutter/lib/src/animation/animation_controller.dart +++ b/packages/flutter/lib/src/animation/animation_controller.dart @@ -166,6 +166,7 @@ class AnimationController extends Animation Future animateTo(double target, { Duration duration, Curve curve: Curves.linear }) { Duration simulationDuration = duration; if (simulationDuration == null) { + assert(this.duration != null); double range = upperBound - lowerBound; double remainingFraction = range.isFinite ? (target - _value).abs() / range : 1.0; simulationDuration = this.duration * remainingFraction; diff --git a/packages/flutter/lib/src/material/list.dart b/packages/flutter/lib/src/material/list.dart index 8212cd243a..f499997328 100644 --- a/packages/flutter/lib/src/material/list.dart +++ b/packages/flutter/lib/src/material/list.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'constants.dart'; +import 'overscroll_painter.dart'; import 'scrollbar_painter.dart'; import 'theme.dart'; @@ -28,6 +29,7 @@ class MaterialList extends StatefulWidget { this.initialScrollOffset, this.onScroll, this.type: MaterialListType.twoLine, + this.clampOverscrolls: false, this.children, this.scrollablePadding: EdgeInsets.zero, this.scrollableKey @@ -36,6 +38,7 @@ class MaterialList extends StatefulWidget { final double initialScrollOffset; final ScrollListener onScroll; final MaterialListType type; + final bool clampOverscrolls; final Iterable children; final EdgeInsets scrollablePadding; final Key scrollableKey; @@ -45,26 +48,37 @@ class MaterialList extends StatefulWidget { } class _MaterialListState extends State { - ScrollbarPainter _scrollbarPainter; + ScrollableListPainter _scrollbarPainter; + ScrollableListPainter _overscrollPainter; + + Color _getScrollbarThumbColor() => Theme.of(context).highlightColor; + Color _getOverscrollIndicatorColor() => Theme.of(context).accentColor.withOpacity(0.35); @override void initState() { super.initState(); - _scrollbarPainter = new ScrollbarPainter( - getThumbColor: () => Theme.of(context).highlightColor - ); + _scrollbarPainter = new ScrollbarPainter(getThumbColor: _getScrollbarThumbColor); } @override Widget build(BuildContext context) { + ScrollableListPainter painter = _scrollbarPainter; + if (config.clampOverscrolls) { + _overscrollPainter ??= new OverscrollPainter(getIndicatorColor: _getOverscrollIndicatorColor); + painter = new CompoundScrollableListPainter([ + _scrollbarPainter, + _overscrollPainter + ]); + } return new ScrollableList( key: config.scrollableKey, initialScrollOffset: config.initialScrollOffset, scrollDirection: Axis.vertical, + clampOverscrolls: config.clampOverscrolls, onScroll: config.onScroll, itemExtent: kListItemExtent[config.type], padding: const EdgeInsets.symmetric(vertical: 8.0) + config.scrollablePadding, - scrollableListPainter: _scrollbarPainter, + scrollableListPainter: painter, children: config.children ); } diff --git a/packages/flutter/lib/src/material/overscroll_painter.dart b/packages/flutter/lib/src/material/overscroll_painter.dart new file mode 100644 index 0000000000..c8ee9bd9ec --- /dev/null +++ b/packages/flutter/lib/src/material/overscroll_painter.dart @@ -0,0 +1,134 @@ +// 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'; + +import 'package:flutter/widgets.dart'; + +const double _kMinIndicatorLength = 0.0; +const double _kMaxIndicatorLength = 64.0; +const double _kMinIndicatorOpacity = 0.0; +const double _kMaxIndicatorOpacity = 0.25; +const Duration _kIndicatorVanishDuration = const Duration(milliseconds: 200); +const Duration _kIndicatorTimeoutDuration = const Duration(seconds: 1); +final Tween _kIndicatorOpacity = new Tween(begin: 0.0, end: 0.3); + +typedef Color GetOverscrollIndicatorColor(); + +class OverscrollPainter extends ScrollableListPainter { + OverscrollPainter({ GetOverscrollIndicatorColor getIndicatorColor }) { + this.getIndicatorColor = getIndicatorColor ?? _defaultIndicatorColor; + } + + GetOverscrollIndicatorColor getIndicatorColor; + bool _indicatorActive = false; + AnimationController _indicatorLength; + Timer _indicatorTimer; + + Color _defaultIndicatorColor() => const Color(0xFF00FF00); + + @override + void paint(PaintingContext context, Offset offset) { + if (_indicatorLength == null || (scrollOffset >= _minScrollOffset && scrollOffset <= _maxScrollOffset)) + return; + + final double rectBias = _indicatorLength.value / 2.0; + final double arcBias = _indicatorLength.value; + final Rect viewportRect = offset & viewportSize; + + final Path path = new Path(); + switch(scrollDirection) { + case Axis.vertical: + final double width = viewportRect.width; + if (scrollOffset < _minScrollOffset) { + path.moveTo(viewportRect.left, viewportRect.top); + path.relativeLineTo(width, 0.0); + path.relativeLineTo(0.0, rectBias); + path.relativeQuadraticBezierTo(width / -2.0, arcBias, -width, 0.0); + } else { + path.moveTo(viewportRect.left, viewportRect.bottom); + 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 = viewportRect.height; + if (scrollOffset < _minScrollOffset) { + path.moveTo(viewportRect.left, viewportRect.top); + path.relativeLineTo(0.0, height); + path.relativeLineTo(rectBias, 0.0); + path.relativeQuadraticBezierTo(arcBias, height / -2.0, 0.0, -height); + } else { + path.moveTo(viewportRect.right, viewportRect.top); + path.relativeLineTo(0.0, height); + path.relativeLineTo(-rectBias, 0.0); + path.relativeQuadraticBezierTo(-arcBias, height / -2.0, 0.0, -height); + } + break; + } + path.close(); + + final double t = (_indicatorLength.value - _kMinIndicatorLength) / (_kMaxIndicatorLength - _kMinIndicatorLength); + final Paint paint = new Paint() + ..color = getIndicatorColor().withOpacity(_kIndicatorOpacity.lerp(Curves.easeIn.transform(t))); + context.canvas.drawPath(path, paint); + } + + void _hide() { + _indicatorTimer?.cancel(); + _indicatorTimer = null; + _indicatorActive = false; + _indicatorLength?.reverse(); + } + + double get _minScrollOffset => 0.0; + + double get _maxScrollOffset { + switch(scrollDirection) { + case Axis.vertical: + return contentExtent - viewportSize.height; + case Axis.horizontal: + return contentExtent - viewportSize.width; + } + } + + @override + void scrollStarted() { + _indicatorActive = true; + _indicatorLength ??= new AnimationController( + lowerBound: _kMinIndicatorLength, + upperBound: _kMaxIndicatorLength, + duration: _kIndicatorVanishDuration + ) + ..addListener(() { + renderObject?.markNeedsPaint(); + }); + } + + @override + void set scrollOffset (double value) { + if (_indicatorActive && + (value < _minScrollOffset || value > _maxScrollOffset) && + ((value - scrollOffset).abs() > kPixelScrollTolerance.distance)) { + _indicatorTimer?.cancel(); + _indicatorTimer = new Timer(_kIndicatorTimeoutDuration, _hide); + _indicatorLength.value = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset; + } + super.scrollOffset = value; + } + + @override + void scrollEnded() { + _hide(); + } + + @override + void detach() { + super.detach(); + _indicatorTimer?.cancel(); + _indicatorTimer = null; + _indicatorLength?.stop(); + } +} diff --git a/packages/flutter/lib/src/material/scrollbar_painter.dart b/packages/flutter/lib/src/material/scrollbar_painter.dart index f6bd583669..748d3b0022 100644 --- a/packages/flutter/lib/src/material/scrollbar_painter.dart +++ b/packages/flutter/lib/src/material/scrollbar_painter.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:flutter/widgets.dart'; const double _kMinScrollbarThumbLength = 18.0; @@ -66,7 +64,7 @@ class ScrollbarPainter extends ScrollableListPainter { AnimationController _fade; @override - Future scrollStarted() { + void scrollStarted() { if (_fade == null) { _fade = new AnimationController(duration: _kScrollbarThumbFadeDuration); CurvedAnimation curve = new CurvedAnimation(parent: _fade, curve: Curves.ease); @@ -75,12 +73,12 @@ class ScrollbarPainter extends ScrollableListPainter { renderObject?.markNeedsPaint(); }); } - return _fade.forward(); + _fade.forward(); } @override - Future scrollEnded() { - return _fade.reverse(); + void scrollEnded() { + _fade.reverse(); } @override diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 25499c3a5e..89c800861c 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -786,13 +786,59 @@ abstract class ScrollableListPainter extends RenderObjectPainter { } /// Called when a scroll starts. Subclasses may override this method to - /// initialize some state or to play an animation. The returned Future should - /// complete when the computation triggered by this method has finished. - Future scrollStarted() => new Future.value(); - + /// initialize some state or to play an animation. + void scrollStarted() { } /// Similar to scrollStarted(). Called when a scroll ends. For fling scrolls /// "ended" means that the scroll animation either stopped of its own accord /// or was canceled by the user. - Future scrollEnded() => new Future.value(); + void scrollEnded() { } +} + +class CompoundScrollableListPainter extends ScrollableListPainter { + CompoundScrollableListPainter(this.painters); + + final List painters; + + @override + void attach(RenderObject renderObject) { + for(ScrollableListPainter painter in painters) + painter.attach(renderObject); + } + + @override + void detach() { + for(ScrollableListPainter painter in painters) + painter.detach(); + } + + @override + void set contentExtent (double value) { + for(ScrollableListPainter painter in painters) + painter.contentExtent = value; + } + + @override + void paint(PaintingContext context, Offset offset) { + for(ScrollableListPainter painter in painters) + painter.paint(context, offset); + } + + @override + void set scrollOffset (double value) { + for(ScrollableListPainter painter in painters) + painter.scrollOffset = value; + } + + @override + void scrollStarted() { + for(ScrollableListPainter painter in painters) + painter.scrollStarted(); + } + + @override + void scrollEnded() { + for(ScrollableListPainter painter in painters) + painter.scrollEnded(); + } } diff --git a/packages/flutter/lib/src/widgets/scrollable_list.dart b/packages/flutter/lib/src/widgets/scrollable_list.dart index 2a228cb76f..2631847362 100644 --- a/packages/flutter/lib/src/widgets/scrollable_list.dart +++ b/packages/flutter/lib/src/widgets/scrollable_list.dart @@ -21,6 +21,7 @@ class ScrollableList extends Scrollable { SnapOffsetCallback snapOffsetCallback, this.itemExtent, this.itemsWrap: false, + this.clampOverscrolls: false, this.padding, this.scrollableListPainter, this.children @@ -37,6 +38,7 @@ class ScrollableList extends Scrollable { final double itemExtent; final bool itemsWrap; + final bool clampOverscrolls; final EdgeInsets padding; final ScrollableListPainter scrollableListPainter; final Iterable children; @@ -75,17 +77,14 @@ class _ScrollableListState extends ScrollableState { config.scrollableListPainter?.scrollOffset = scrollOffset; } - @override - void dispatchOnScrollEnd() { - super.dispatchOnScrollEnd(); - config.scrollableListPainter?.scrollEnded(); - } - @override Widget buildContent(BuildContext context) { + final double listScrollOffset = config.clampOverscrolls + ? scrollOffset.clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset) + : scrollOffset; return new ListViewport( onExtentsChanged: _handleExtentsChanged, - scrollOffset: scrollOffset, + scrollOffset: listScrollOffset, mainAxis: config.scrollDirection, anchor: config.scrollAnchor, itemExtent: config.itemExtent,