diff --git a/examples/widgets/scrollbar.dart b/examples/widgets/scrollbar.dart new file mode 100644 index 00000000000..b488467a24a --- /dev/null +++ b/examples/widgets/scrollbar.dart @@ -0,0 +1,67 @@ +// Copyright 2015 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 'package:intl/intl.dart'; +import 'package:flutter/material.dart'; + +class ScrollbarApp extends StatefulComponent { + ScrollbarApp({ this.navigator }); + + final NavigatorState navigator; + + ScrollbarAppState createState() => new ScrollbarAppState(); +} + +class ScrollbarAppState extends State { + final int _itemCount = 20; + final double _itemExtent = 50.0; + final ScrollbarPainter _scrollbarPainter = new ScrollbarPainter(); + + Widget _buildMenu(BuildContext context) { + NumberFormat dd = new NumberFormat("00", "en_US"); + return new ScrollableList( + items: new List.generate(_itemCount, (int i) => i), + itemExtent: _itemExtent, + itemBuilder: (BuildContext _, int i) => new Text('Item ${dd.format(i)}', style: Theme.of(context).text.title), + scrollableListPainter: _scrollbarPainter + ); + } + + Widget build(BuildContext context) { + Widget scrollable = new Container( + margin: new EdgeDims.symmetric(horizontal: 6.0), // TODO(hansmuller) 6.0 should be based on _kScrollbarThumbWidth + child: new Center( + shrinkWrap: ShrinkWrap.both, + child: new Container( + width: 80.0, + height: _itemExtent * 5.0, + child: _buildMenu(context) + ) + ) + ); + + return new Scaffold( + toolBar: new ToolBar(center: new Text('Scrollbar Demo')), + body: new Container( + decoration: new BoxDecoration(backgroundColor: Theme.of(context).primarySwatch[50]), + padding: new EdgeDims.all(12.0), + child: new Center(child: new Card(child: scrollable)) + ) + ); + } +} + +void main() { + runApp(new MaterialApp( + title: 'ScrollbarApp', + theme: new ThemeData( + brightness: ThemeBrightness.light, + primarySwatch: Colors.blue, + accentColor: Colors.redAccent[200] + ), + routes: { + '/': (RouteArguments args) => new ScrollbarApp(navigator: args.navigator), + } + )); +} diff --git a/sky/packages/sky/lib/material.dart b/sky/packages/sky/lib/material.dart index e2e50c720cc..88adadfc680 100644 --- a/sky/packages/sky/lib/material.dart +++ b/sky/packages/sky/lib/material.dart @@ -33,6 +33,7 @@ export 'src/material/progress_indicator.dart'; export 'src/material/radio.dart'; export 'src/material/raised_button.dart'; export 'src/material/scaffold.dart'; +export 'src/material/scrollbar_painter.dart'; export 'src/material/shadows.dart'; export 'src/material/snack_bar.dart'; export 'src/material/switch.dart'; diff --git a/sky/packages/sky/lib/src/material/scrollbar_painter.dart b/sky/packages/sky/lib/src/material/scrollbar_painter.dart new file mode 100644 index 00000000000..16b21755530 --- /dev/null +++ b/sky/packages/sky/lib/src/material/scrollbar_painter.dart @@ -0,0 +1,82 @@ +// Copyright 2015 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 'dart:ui' as ui; + +import 'package:flutter/animation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +const double _kMinScrollbarThumbLength = 18.0; +const double _kScrollbarThumbGirth = 6.0; +const Duration _kScrollbarThumbFadeDuration = const Duration(milliseconds: 300); + +class ScrollbarPainter extends ScrollableListPainter { + + double _opacity = 0.0; + int get _alpha => (_opacity * 0xFF).round(); + + // TODO(hansmuller): thumb color should come from ThemeData. + Color get thumbColor => const Color(0xFF9E9E9E); + + void paintThumb(PaintingContext context, Rect thumbBounds) { + final Paint paint = new Paint()..color = thumbColor.withAlpha(_alpha); + context.canvas.drawRect(thumbBounds, paint); + } + + void paintScrollbar(PaintingContext context, Offset offset) { + final Rect viewportBounds = offset & viewportSize; + Point thumbOrigin; + Size thumbSize; + + if (isVertical) { + double thumbHeight = viewportBounds.height * viewportBounds.height / contentExtent; + thumbHeight = thumbHeight.clamp(_kMinScrollbarThumbLength, viewportBounds.height); + final double maxThumbTop = viewportBounds.height - thumbHeight; + double thumbTop = (scrollOffset / (contentExtent - viewportBounds.height)) * maxThumbTop; + thumbTop = viewportBounds.top + thumbTop.clamp(0.0, maxThumbTop); + thumbOrigin = new Point(viewportBounds.right - _kScrollbarThumbGirth, thumbTop); + thumbSize = new Size(_kScrollbarThumbGirth, thumbHeight); + } else { + double thumbWidth = viewportBounds.width * viewportBounds.width / contentExtent; + thumbWidth = thumbWidth.clamp(_kMinScrollbarThumbLength, viewportBounds.width); + final double maxThumbLeft = viewportBounds.width - thumbWidth; + double thumbLeft = (scrollOffset / (contentExtent - viewportBounds.width)) * maxThumbLeft; + thumbLeft = viewportBounds.left + thumbLeft.clamp(0.0, maxThumbLeft); + thumbOrigin = new Point(thumbLeft, viewportBounds.height - _kScrollbarThumbGirth); + thumbSize = new Size(thumbWidth, _kScrollbarThumbGirth); + } + + paintThumb(context, thumbOrigin & thumbSize); + } + + void paint(PaintingContext context, Offset offset) { + if (_alpha == 0) + return; + paintScrollbar(context, offset); + } + + ValuePerformance _fade; + + Future scrollStarted() { + _fade ??= new ValuePerformance() + ..duration = _kScrollbarThumbFadeDuration + ..variable = new AnimatedValue(0.0, end: 1.0, curve: ease) + ..addListener(() { + _opacity = _fade.value; + renderer?.markNeedsPaint(); + }); + return _fade.forward(); + } + + Future scrollEnded() { + return _fade.reverse(); + } + + void detach() { + super.detach(); + _fade?.stop(); + } +} diff --git a/sky/packages/sky/lib/src/rendering/block.dart b/sky/packages/sky/lib/src/rendering/block.dart index 18745490dc6..edbc0eae431 100644 --- a/sky/packages/sky/lib/src/rendering/block.dart +++ b/sky/packages/sky/lib/src/rendering/block.dart @@ -237,6 +237,7 @@ class RenderBlockViewport extends RenderBlockBase { ExtentCallback totalExtentCallback, ExtentCallback maxCrossAxisDimensionCallback, ExtentCallback minCrossAxisDimensionCallback, + Painter overlayPainter, BlockDirection direction: BlockDirection.vertical, double itemExtent, double minExtent: 0.0, @@ -246,6 +247,7 @@ class RenderBlockViewport extends RenderBlockBase { _totalExtentCallback = totalExtentCallback, _maxCrossAxisExtentCallback = maxCrossAxisDimensionCallback, _minCrossAxisExtentCallback = minCrossAxisDimensionCallback, + _overlayPainter = overlayPainter, _startOffset = startOffset, super(children: children, direction: direction, itemExtent: itemExtent, minExtent: minExtent); @@ -298,6 +300,27 @@ class RenderBlockViewport extends RenderBlockBase { markNeedsLayout(); } + Painter get overlayPainter => _overlayPainter; + Painter _overlayPainter; + void set overlayPainter(Painter value) { + if (_overlayPainter == value) + return; + _overlayPainter?.detach(); + _overlayPainter = value; + _overlayPainter?.attach(this); + markNeedsPaint(); + } + + void attach() { + super.attach(); + _overlayPainter?.attach(this); + } + + void detach() { + super.detach(); + _overlayPainter?.detach(); + } + /// The offset at which to paint the first child /// /// Note: you can modify this property from within [callback], if necessary. @@ -377,11 +400,15 @@ class RenderBlockViewport extends RenderBlockBase { void paint(PaintingContext context, Offset offset) { context.canvas.save(); + context.canvas.clipRect(offset & size); if (isVertical) defaultPaint(context, offset.translate(0.0, startOffset)); else defaultPaint(context, offset.translate(startOffset, 0.0)); + + overlayPainter?.paint(context, offset); + context.canvas.restore(); } diff --git a/sky/packages/sky/lib/src/rendering/object.dart b/sky/packages/sky/lib/src/rendering/object.dart index 7b9c0a1b3dc..243779648be 100644 --- a/sky/packages/sky/lib/src/rendering/object.dart +++ b/sky/packages/sky/lib/src/rendering/object.dart @@ -394,6 +394,27 @@ class PaintingContext { } +/// An encapsulation of a renderer and a paint() method. +/// +/// A renderer may allow its paint() method to be augmented or redefined by +/// providing a Painter. See for example overlayPainter in BlockViewport. +abstract class Painter { + RenderObject get renderObject => _renderObject; + RenderObject _renderObject; + + void attach(RenderObject renderObject) { + assert(_renderObject == null); + _renderObject = renderObject; + } + + void detach() { + assert(_renderObject != null); + _renderObject = null; + } + + void paint(PaintingContext context, Offset offset); +} + /// An abstract set of layout constraints /// /// Concrete layout models (such as box) will create concrete subclasses to diff --git a/sky/packages/sky/lib/src/widgets/homogeneous_viewport.dart b/sky/packages/sky/lib/src/widgets/homogeneous_viewport.dart index 54f33506a26..3ee2735afa0 100644 --- a/sky/packages/sky/lib/src/widgets/homogeneous_viewport.dart +++ b/sky/packages/sky/lib/src/widgets/homogeneous_viewport.dart @@ -18,7 +18,8 @@ class HomogeneousViewport extends RenderObjectWidget { this.itemExtent, // required this.itemCount, // optional, but you cannot shrink-wrap this class or otherwise use its intrinsic dimensions if you don't specify it this.direction: ScrollDirection.vertical, - this.startOffset: 0.0 + this.startOffset: 0.0, + this.overlayPainter }) : super(key: key) { assert(itemExtent != null); } @@ -29,6 +30,7 @@ class HomogeneousViewport extends RenderObjectWidget { final int itemCount; final ScrollDirection direction; final double startOffset; + final Painter overlayPainter; _HomogeneousViewportElement createElement() => new _HomogeneousViewportElement(this); @@ -70,6 +72,7 @@ class _HomogeneousViewportElement extends RenderObjectElement extends State { return _scrollBehavior; } + GestureDragStartCallback _getDragStartHandler(ScrollDirection direction) { + if (config.scrollDirection != direction || !scrollBehavior.isScrollable) + return null; + return _handleDragStart; + } + GestureDragUpdateCallback _getDragUpdateHandler(ScrollDirection direction) { if (config.scrollDirection != direction || !scrollBehavior.isScrollable) return null; @@ -88,8 +98,10 @@ abstract class ScrollableState extends State { Widget build(BuildContext context) { return new GestureDetector( + onVerticalDragStart: _getDragStartHandler(ScrollDirection.vertical), onVerticalDragUpdate: _getDragUpdateHandler(ScrollDirection.vertical), onVerticalDragEnd: _getDragEndHandler(ScrollDirection.vertical), + onHorizontalDragStart: _getDragStartHandler(ScrollDirection.horizontal), onHorizontalDragUpdate: _getDragUpdateHandler(ScrollDirection.horizontal), onHorizontalDragEnd: _getDragEndHandler(ScrollDirection.horizontal), child: new Listener( @@ -199,12 +211,22 @@ abstract class ScrollableState extends State { return _startToEndAnimation(); } + void dispatchOnScrollStart() { + if (config.onScrollStart != null) + config.onScrollStart(_scrollOffset); + } + // Derived classes can override this method and call super.dispatchOnScroll() void dispatchOnScroll() { if (config.onScroll != null) config.onScroll(_scrollOffset); } + void dispatchOnScrollEnd() { + if (config.onScrollEnd != null) + config.onScrollEnd(_scrollOffset); + } + double _scrollVelocity(ui.Offset velocity) { double scrollVelocity = config.scrollDirection == ScrollDirection.horizontal ? -velocity.dx @@ -216,14 +238,20 @@ abstract class ScrollableState extends State { _animation.stop(); } + void _handleDragStart() { + scheduleMicrotask(dispatchOnScrollStart); + } + void _handleDragUpdate(double delta) { // We negate the delta here because a positive scroll offset moves the // the content up (or to the left) rather than down (or the right). scrollBy(-delta); } - void _handleDragEnd(Offset velocity) { - fling(velocity); + Future _handleDragEnd(Offset velocity) { + return fling(velocity).then((_) { + dispatchOnScrollEnd(); + }); } } @@ -379,6 +407,52 @@ class Block extends StatelessComponent { } } +abstract class ScrollableListPainter extends Painter { + void attach(RenderObject renderObject) { + assert(renderObject is RenderBlockViewport); + super.attach(renderObject); + } + + RenderBlockViewport get renderer => renderObject; + + bool get isVertical => renderer.isVertical; + + Size get viewportSize => renderer.size; + + double get contentExtent => _contentExtent; + double _contentExtent = 0.0; + void set contentExtent (double value) { + assert(value != null); + assert(value >= 0.0); + if (_contentExtent == value) + return; + _contentExtent = value; + renderer?.markNeedsPaint(); + } + + double get scrollOffset => _scrollOffset; + double _scrollOffset = 0.0; + void set scrollOffset (double value) { + assert(value != null); + assert(value >= 0.0 && value <= 1.0); + if (_scrollOffset == value) + return; + _scrollOffset = value; + renderer?.markNeedsPaint(); + } + + /// 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(); + + + /// 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(); +} + /// An optimized scrollable widget for a large number of children that are all /// the same size (extent) in the scrollDirection. For example for /// ScrollDirection.vertical itemExtent is the height of each item. Use this @@ -394,7 +468,8 @@ abstract class ScrollableWidgetList extends Scrollable { double snapAlignmentOffset: 0.0, this.itemsWrap: false, this.itemExtent, - this.padding + this.padding, + this.scrollableListPainter }) : super( key: key, initialScrollOffset: initialScrollOffset, @@ -409,6 +484,7 @@ abstract class ScrollableWidgetList extends Scrollable { final bool itemsWrap; final double itemExtent; final EdgeDims padding; + final ScrollableListPainter scrollableListPainter; } abstract class ScrollableWidgetListState extends ScrollableState { @@ -480,18 +556,40 @@ abstract class ScrollableWidgetListState extends return new EdgeDims.only(top: padding.top, bottom: padding.bottom); } - void _updateScrollBehavior() { - // if you don't call this from build(), you must call it from setState(). + double get _contentExtent { double contentExtent = config.itemExtent * itemCount; if (config.padding != null) contentExtent += _leadingPadding + _trailingPadding; + return contentExtent; + } + + void _updateScrollBehavior() { + // if you don't call this from build(), you must call it from setState(). + if (config.scrollableListPainter != null) + config.scrollableListPainter.contentExtent = _contentExtent; scrollTo(scrollBehavior.updateExtents( - contentExtent: contentExtent, + contentExtent: _contentExtent, containerExtent: _containerExtent, scrollOffset: scrollOffset )); } + void dispatchOnScrollStart() { + super.dispatchOnScrollStart(); + config.scrollableListPainter?.scrollStarted(); + } + + void dispatchOnScroll() { + super.dispatchOnScroll(); + if (config.scrollableListPainter != null) + config.scrollableListPainter.scrollOffset = scrollOffset; + } + + void dispatchOnScrollEnd() { + super.dispatchOnScrollEnd(); + config.scrollableListPainter?.scrollEnded(); + } + Widget buildContent(BuildContext context) { if (itemCount != _previousItemCount) { _previousItemCount = itemCount; @@ -508,7 +606,8 @@ abstract class ScrollableWidgetListState extends itemExtent: config.itemExtent, itemCount: itemCount, direction: config.scrollDirection, - startOffset: scrollOffset - _leadingPadding + startOffset: scrollOffset - _leadingPadding, + overlayPainter: config.scrollableListPainter ) ) ); @@ -541,7 +640,8 @@ class ScrollableList extends ScrollableWidgetList { this.itemBuilder, itemsWrap: false, double itemExtent, - EdgeDims padding + EdgeDims padding, + ScrollableListPainter scrollableListPainter }) : super( key: key, initialScrollOffset: initialScrollOffset, @@ -551,7 +651,9 @@ class ScrollableList extends ScrollableWidgetList { snapAlignmentOffset: snapAlignmentOffset, itemsWrap: itemsWrap, itemExtent: itemExtent, - padding: padding); + padding: padding, + scrollableListPainter: scrollableListPainter + ); final List items; final ItemBuilder itemBuilder;