diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 81456969ad..ea5d0df304 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -16,6 +16,7 @@ export 'src/cupertino/icons.dart'; export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/page_scaffold.dart'; export 'src/cupertino/route.dart'; +export 'src/cupertino/scrollbar.dart'; export 'src/cupertino/slider.dart'; export 'src/cupertino/switch.dart'; export 'src/cupertino/tab_scaffold.dart'; diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart new file mode 100644 index 0000000000..878d70a31f --- /dev/null +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -0,0 +1,140 @@ +// Copyright 2017 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +// All values eyeballed. +const Color _kScrollbarColor = const Color(0x99777777); +const double _kScrollbarThickness = 2.5; +const double _kScrollbarMainAxisMargin = 4.0; +const double _kScrollbarCrossAxisMargin = 2.5; +const double _kScrollbarMinLength = 4.0; +const Radius _kScrollbarRadius = const Radius.circular(1.25); +const Duration _kScrollbarTimeToFade = const Duration(milliseconds: 50); +const Duration _kScrollbarFadeDuration = const Duration(milliseconds: 250); + +/// A iOS style scrollbar. +/// +/// A scrollbar indicates which portion of a [Scrollable] widget is actually +/// visible. +/// +/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in +/// a [CupertinoScrollbar] widget. +/// +/// See also: +/// +/// * [ListView], which display a linear, scrollable list of children. +/// * [GridView], which display a 2 dimensional, scrollable array of children. +/// * [Scrollbar], a Material Design scrollbar that dynamically adapts to the +/// platform showing either an Android style or iOS style scrollbar. +class CupertinoScrollbar extends StatefulWidget { + /// Creates an iOS style scrollbar that wraps the given [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + const CupertinoScrollbar({ + Key key, + @required this.child, + }) : super(key: key); + + /// The subtree to place inside the [CupertinoScrollbar]. + /// + /// This should include a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + final Widget child; + + @override + _CupertinoScrollbarState createState() => new _CupertinoScrollbarState(); +} + +class _CupertinoScrollbarState extends State with TickerProviderStateMixin { + ScrollbarPainter _painter; + TextDirection _textDirection; + + AnimationController _fadeoutAnimationController; + Animation _fadeoutOpacityAnimation; + Timer _fadeoutTimer; + + @override + void initState() { + super.initState(); + _fadeoutAnimationController = new AnimationController( + vsync: this, + duration: _kScrollbarFadeDuration, + ); + _fadeoutOpacityAnimation = new CurvedAnimation( + parent: _fadeoutAnimationController, + curve: Curves.fastOutSlowIn + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _textDirection = Directionality.of(context); + _painter = _buildCupertinoScrollbarPainter(); + } + + /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. + ScrollbarPainter _buildCupertinoScrollbarPainter() { + return new ScrollbarPainter( + color: _kScrollbarColor, + textDirection: _textDirection, + thickness: _kScrollbarThickness, + fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + mainAxisMargin: _kScrollbarMainAxisMargin, + crossAxisMargin: _kScrollbarCrossAxisMargin, + radius: _kScrollbarRadius, + minLength: _kScrollbarMinLength, + ); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification) { + // Any movements always makes the scrollbar start showing up. + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + _painter.update(notification.metrics, notification.metrics.axisDirection); + } else if (notification is ScrollEndNotification) { + // On iOS, the scrollbar can only go away once the user lifted the finger. + + _fadeoutTimer?.cancel(); + _fadeoutTimer = new Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + return false; + } + + @override + void dispose() { + _fadeoutAnimationController.dispose(); + _fadeoutTimer?.cancel(); + _painter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return new NotificationListener( + onNotification: _handleScrollNotification, + child: new RepaintBoundary( + child: new CustomPaint( + foregroundPainter: _painter, + child: new RepaintBoundary( + child: widget.child, + ), + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index 419986f9ba..c1bb6fcd5f 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -3,19 +3,25 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:math' as math; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'theme.dart'; +const double _kScrollbarThickness = 6.0; +const Duration _kScrollbarFadeDuration = const Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = const Duration(milliseconds: 600); + /// A material design scrollbar. /// /// A scrollbar indicates which portion of a [Scrollable] widget is actually /// visible. /// +/// Dynamically changes to a iOS style scrollbar that looks like +/// [CupertinoScrollbar] on iOS platform. +/// /// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in /// a [Scrollbar] widget. /// @@ -45,182 +51,112 @@ class Scrollbar extends StatefulWidget { _ScrollbarState createState() => new _ScrollbarState(); } + class _ScrollbarState extends State with TickerProviderStateMixin { - _ScrollbarPainter _painter; + ScrollbarPainter _materialPainter; + TargetPlatform _currentPlatform; + TextDirection _textDirection; + Color _themeColor; + + AnimationController _fadeoutAnimationController; + Animation _fadeoutOpacityAnimation; + Timer _fadeoutTimer; + + @override + void initState() { + super.initState(); + _fadeoutAnimationController = new AnimationController( + vsync: this, + duration: _kScrollbarFadeDuration, + ); + _fadeoutOpacityAnimation = new CurvedAnimation( + parent: _fadeoutAnimationController, + curve: Curves.fastOutSlowIn + ); + } @override void didChangeDependencies() { super.didChangeDependencies(); - _painter ??= new _ScrollbarPainter(this); - _painter - ..color = Theme.of(context).highlightColor - ..textDirection = Directionality.of(context); + + final ThemeData theme = Theme.of(context); + _currentPlatform = theme.platform; + + switch (_currentPlatform) { + case TargetPlatform.iOS: + // On iOS, stop all local animations. CupertinoScrollbar has its own + // animations. + _fadeoutTimer?.cancel(); + _fadeoutTimer = null; + _fadeoutAnimationController.reset(); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + _themeColor = theme.highlightColor.withOpacity(1.0); + _textDirection = Directionality.of(context); + _materialPainter = _buildMaterialScrollbarPainter(); + break; + } + } + + ScrollbarPainter _buildMaterialScrollbarPainter() { + return new ScrollbarPainter( + color: _themeColor, + textDirection: _textDirection, + thickness: _kScrollbarThickness, + fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + ); } bool _handleScrollNotification(ScrollNotification notification) { - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification) - _painter.update(notification.metrics, notification.metrics.axisDirection); + // iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle + // scroll notifications here. + if (_currentPlatform != TargetPlatform.iOS + && (notification is ScrollUpdateNotification + || notification is OverscrollNotification)) { + if (_fadeoutAnimationController.status != AnimationStatus.forward) { + _fadeoutAnimationController.forward(); + } + + _materialPainter.update(notification.metrics, notification.metrics.axisDirection); + _fadeoutTimer?.cancel(); + _fadeoutTimer = new Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } return false; } @override void dispose() { - _painter.dispose(); + _fadeoutAnimationController.dispose(); + _fadeoutTimer?.cancel(); + _materialPainter?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return new NotificationListener( - onNotification: _handleScrollNotification, - // TODO(ianh): Maybe we should try to collapse out these repaint - // boundaries when the scroll bars are invisible. - child: new RepaintBoundary( - child: new CustomPaint( - foregroundPainter: _painter, + switch (_currentPlatform) { + case TargetPlatform.iOS: + return new CupertinoScrollbar( + child: widget.child, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return new NotificationListener( + onNotification: _handleScrollNotification, child: new RepaintBoundary( - child: widget.child, + child: new CustomPaint( + foregroundPainter: _materialPainter, + child: new RepaintBoundary( + child: widget.child, + ), + ), ), - ), - ), - ); + ); + } + throw new FlutterError('Unknown platform for scrollbar insertion'); } } - -class _ScrollbarPainter extends ChangeNotifier implements CustomPainter { - _ScrollbarPainter(TickerProvider vsync) - : assert(vsync != null) { - _fadeController = new AnimationController(duration: _kThumbFadeDuration, vsync: vsync); - _opacity = new CurvedAnimation(parent: _fadeController, curve: Curves.fastOutSlowIn) - ..addListener(notifyListeners); - } - - // animation of the main axis direction - AnimationController _fadeController; - Animation _opacity; - - // fade-out timer - Timer _fadeOut; - - Color get color => _color; - Color _color; - set color(Color value) { - assert(value != null); - if (_color == value) - return; - _color = value; - notifyListeners(); - } - - TextDirection get textDirection => _textDirection; - TextDirection _textDirection; - set textDirection(TextDirection value) { - assert(value != null); - if (_textDirection == value) - return; - _textDirection = value; - notifyListeners(); - } - - @override - void dispose() { - _fadeOut?.cancel(); - _fadeController.dispose(); - super.dispose(); - } - - ScrollMetrics _lastMetrics; - AxisDirection _lastAxisDirection; - - static const double _kMinThumbExtent = 18.0; - static const double _kThumbGirth = 6.0; - static const Duration _kThumbFadeDuration = const Duration(milliseconds: 300); - static const Duration _kFadeOutTimeout = const Duration(milliseconds: 600); - - void update(ScrollMetrics metrics, AxisDirection axisDirection) { - _lastMetrics = metrics; - _lastAxisDirection = axisDirection; - if (_fadeController.status == AnimationStatus.completed) { - notifyListeners(); - } else if (_fadeController.status != AnimationStatus.forward) { - _fadeController.forward(); - } - _fadeOut?.cancel(); - _fadeOut = new Timer(_kFadeOutTimeout, startFadeOut); - } - - void startFadeOut() { - _fadeOut = null; - _fadeController.reverse(); - } - - Paint get _paint => new Paint()..color = color.withOpacity(_opacity.value); - - double _getThumbX(Size size) { - assert(textDirection != null); - switch (textDirection) { - case TextDirection.rtl: - return 0.0; - case TextDirection.ltr: - return size.width - _kThumbGirth; - } - return null; - } - - void _paintVerticalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { - final Offset thumbOrigin = new Offset(_getThumbX(size), thumbOffset); - final Size thumbSize = new Size(_kThumbGirth, thumbExtent); - canvas.drawRect(thumbOrigin & thumbSize, _paint); - } - - void _paintHorizontalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { - final Offset thumbOrigin = new Offset(thumbOffset, size.height - _kThumbGirth); - final Size thumbSize = new Size(thumbExtent, _kThumbGirth); - canvas.drawRect(thumbOrigin & thumbSize, _paint); - } - - void _paintThumb(double before, double inside, double after, double viewport, Canvas canvas, Size size, - void painter(Canvas canvas, Size size, double thumbOffset, double thumbExtent)) { - double thumbExtent = math.min(viewport, _kMinThumbExtent); - if (before + inside + after > 0.0) - thumbExtent = math.max(thumbExtent, viewport * inside / (before + inside + after)); - - final double thumbOffset = (before + after > 0.0) ? - before * (viewport - thumbExtent) / (before + after) : 0.0; - - painter(canvas, size, thumbOffset, thumbExtent); - } - - @override - void paint(Canvas canvas, Size size) { - if (_lastAxisDirection == null || _lastMetrics == null || _opacity.value == 0.0) - return; - switch (_lastAxisDirection) { - case AxisDirection.down: - _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.height, canvas, size, _paintVerticalThumb); - break; - case AxisDirection.up: - _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.height, canvas, size, _paintVerticalThumb); - break; - case AxisDirection.right: - _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.width, canvas, size, _paintHorizontalThumb); - break; - case AxisDirection.left: - _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.width, canvas, size, _paintHorizontalThumb); - break; - } - } - - @override - bool hitTest(Offset position) => null; - - @override - bool shouldRepaint(_ScrollbarPainter oldDelegate) => false; - - @override - bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; - - @override - SemanticsBuilderCallback get semanticsBuilder => null; -} diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart new file mode 100644 index 0000000000..ccfb9fcfce --- /dev/null +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -0,0 +1,222 @@ +// 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:math' as math; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'scroll_metrics.dart'; + +const double _kMinThumbExtent = 18.0; + +/// A [CustomPainter] for painting scrollbars. +/// +/// Unlike [CustomPainter]s that subclasses [CustomPainter] and only repaint +/// when [shouldRepaint] returns true (which requires this [CustomPainter] to +/// be rebuilt), this painter has the added optimization of repainting and not +/// rebuilding when: +/// +/// * the scroll position changes; and +/// * when the scrollbar fades away. +/// +/// Calling [update] with the new [ScrollMetrics] will repaint the new scrollbar +/// position. +/// +/// Updating the value on the provided [fadeoutOpacityAnimation] will repaint +/// with the new opacity. +/// +/// You must call [dispose] on this [ScrollbarPainter] when it's no longer used. +/// +/// See also: +/// +/// * [Scrollbar] for a widget showing a scrollbar around a [Scrollable] in the +/// Material Design style. +/// * [CupertinoScrollbar] for a widget showing a scrollbar around a +/// [Scrollable] in the iOS style. +class ScrollbarPainter extends ChangeNotifier implements CustomPainter { + /// Creates a scrollbar with customizations given by construction arguments. + ScrollbarPainter({ + @required this.color, + @required this.textDirection, + @required this.thickness, + @required this.fadeoutOpacityAnimation, + this.mainAxisMargin: 0.0, + this.crossAxisMargin: 0.0, + this.radius, + this.minLength: _kMinThumbExtent, + }) : assert(color != null), + assert(textDirection != null), + assert(thickness != null), + assert(fadeoutOpacityAnimation != null), + assert(mainAxisMargin != null), + assert(crossAxisMargin != null), + assert(minLength != null) { + fadeoutOpacityAnimation.addListener(notifyListeners); + } + + /// [Color] of the thumb. Mustn't be null. + final Color color; + + /// [TextDirection] of the [BuildContext] which dictates the side of the + /// screen the scrollbar appears in (the trailing side). Mustn't be null. + final TextDirection textDirection; + + /// Thickness of the scrollbar in its cross-axis in pixels. Mustn't be null. + final double thickness; + + /// An opacity [Animation] that dictates the opacity of the thumb. + /// Changes in value of this [Listenable] will automatically trigger repaints. + /// Mustn't be null. + final Animation fadeoutOpacityAnimation; + + /// Distance from the scrollbar's start and end to the edge of the viewport in + /// pixels. Mustn't be null. + final double mainAxisMargin; + + /// Distance from the scrollbar's side to the nearest edge in pixels. Musn't + /// be null. + final double crossAxisMargin; + + /// [Radius] of corners if the scrollbar should have rounded corners. + /// + /// Scrollbar will be rectangular if [radius] is null. + final Radius radius; + + /// The smallest size the scrollbar can shrink to when the total scrollable + /// extent is large and the current visible viewport is small. Mustn't be + /// null. + final double minLength; + + ScrollMetrics _lastMetrics; + AxisDirection _lastAxisDirection; + + /// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself + /// based on these new metrics. + /// + /// The scrollbar will remain on screen. + void update( + ScrollMetrics metrics, + AxisDirection axisDirection, + ) { + _lastMetrics = metrics; + _lastAxisDirection = axisDirection; + notifyListeners(); + } + + Paint get _paint { + return new Paint()..color = + color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); + } + + double _getThumbX(Size size) { + assert(textDirection != null); + switch (textDirection) { + case TextDirection.rtl: + return crossAxisMargin; + case TextDirection.ltr: + return size.width - thickness - crossAxisMargin; + } + return null; + } + + void _paintVerticalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { + final Offset thumbOrigin = new Offset(_getThumbX(size), thumbOffset); + final Size thumbSize = new Size(thickness, thumbExtent); + final Rect thumbRect = thumbOrigin & thumbSize; + if (radius == null) + canvas.drawRect(thumbRect, _paint); + else + canvas.drawRRect(new RRect.fromRectAndRadius(thumbRect, radius), _paint); + } + + void _paintHorizontalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { + final Offset thumbOrigin = new Offset(thumbOffset, size.height - thickness); + final Size thumbSize = new Size(thumbExtent, thickness); + final Rect thumbRect = thumbOrigin & thumbSize; + if (radius == null) + canvas.drawRect(thumbRect, _paint); + else + canvas.drawRRect(new RRect.fromRectAndRadius(thumbRect, radius), _paint); + } + + void _paintThumb( + double before, + double inside, + double after, + double viewport, + Canvas canvas, + Size size, + void painter(Canvas canvas, Size size, double thumbOffset, double thumbExtent), + ) { + // Establish the minimum size possible. + double thumbExtent = math.min(viewport, minLength); + if (before + inside + after > 0.0) { + final double fractionVisible = inside / (before + inside + after); + thumbExtent = math.max( + thumbExtent, + viewport * fractionVisible - 2 * mainAxisMargin, + ); + } + + final double fractionPast = before / (before + after); + final double thumbOffset = (before + after > 0.0) + ? fractionPast * (viewport - thumbExtent - 2 * mainAxisMargin) + mainAxisMargin + : mainAxisMargin; + + painter(canvas, size, thumbOffset, thumbExtent); + } + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } + + @override + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null + || _lastMetrics == null + || fadeoutOpacityAnimation.value == 0.0) + return; + switch (_lastAxisDirection) { + case AxisDirection.down: + _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.height, canvas, size, _paintVerticalThumb); + break; + case AxisDirection.up: + _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.height, canvas, size, _paintVerticalThumb); + break; + case AxisDirection.right: + _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.width, canvas, size, _paintHorizontalThumb); + break; + case AxisDirection.left: + _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.width, canvas, size, _paintHorizontalThumb); + break; + } + } + + // Scrollbars are (currently) not interactive. + @override + bool hitTest(Offset position) => null; + + @override + bool shouldRepaint(ScrollbarPainter old) { + // Should repaint if any properties changed. + return color != old.color + || textDirection != old.textDirection + || thickness != old.thickness + || fadeoutOpacityAnimation != old.fadeoutOpacityAnimation + || mainAxisMargin != old.mainAxisMargin + || crossAxisMargin != old.crossAxisMargin + || radius != old.radius + || minLength != old.minLength; + } + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback get semanticsBuilder => null; +} \ No newline at end of file diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 10c961c96e..7f6d9652f1 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -77,6 +77,7 @@ export 'src/widgets/scroll_position_with_single_context.dart'; export 'src/widgets/scroll_simulation.dart'; export 'src/widgets/scroll_view.dart'; export 'src/widgets/scrollable.dart'; +export 'src/widgets/scrollbar.dart'; export 'src/widgets/semantics_debugger.dart'; export 'src/widgets/single_child_scroll_view.dart'; export 'src/widgets/size_changed_layout_notifier.dart'; diff --git a/packages/flutter/test/cupertino/scrollbar_paint_test.dart b/packages/flutter/test/cupertino/scrollbar_paint_test.dart new file mode 100644 index 0000000000..f4d3771921 --- /dev/null +++ b/packages/flutter/test/cupertino/scrollbar_paint_test.dart @@ -0,0 +1,41 @@ +// Copyright 2017 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:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +void main() { + testWidgets('Paints iOS spec', (WidgetTester tester) async { + await tester.pumpWidget(new Directionality( + textDirection: TextDirection.ltr, + child: new CupertinoScrollbar( + child: new SingleChildScrollView( + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + )); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); + await gesture.moveBy(const Offset(0.0, -10.0)); + // Move back to original position. + await gesture.moveBy(const Offset(0.0, 10.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: const Color(0x99777777), + rrect: new RRect.fromRectAndRadius( + new Rect.fromLTWH( + 800.0 - 2.5 - 2.5, // Screen width - margin - thickness. + 4.0, // Initial position is the top margin. + 2.5, // Thickness. + // Fraction in viewport * scrollbar height - top, bottom margin. + 600.0 / 4000.0 * 600.0 - 4.0 - 4.0, + ), + const Radius.circular(1.25), + ), + )); + }); +} diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart new file mode 100644 index 0000000000..a63c8c1592 --- /dev/null +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -0,0 +1,45 @@ +// Copyright 2017 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:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +void main() { + testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { + await tester.pumpWidget(new Directionality( + textDirection: TextDirection.ltr, + child: new CupertinoScrollbar( + child: new SingleChildScrollView( + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + )); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); + await gesture.moveBy(const Offset(0.0, -10.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: const Color(0x99777777), + )); + + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 3)); + // Still there. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: const Color(0x99777777), + )); + + await gesture.up(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + + // Opacity going down now. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: const Color(0x15777777), + )); + }); +} diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 752c2bff16..9be3ef7f8f 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../rendering/mock_canvas.dart'; + class TestCanvas implements Canvas { TestCanvas([this.invocations]); @@ -77,8 +79,16 @@ void main() { ), )); - final CustomPaint custom = tester.widget(find.descendant(of: find.byType(Scrollbar), matching: find.byType(CustomPaint)).first); + final CustomPaint custom = tester.widget(find.descendant( + of: find.byType(Scrollbar), + matching: find.byType(CustomPaint)).first + ); final dynamic scrollPainter = custom.foregroundPainter; + // Dragging makes the scrollbar first appear. + await tester.drag(find.text('0'), const Offset(0.0, -10.0)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + final ScrollMetrics metrics = new FixedScrollMetrics( minScrollExtent: 0.0, maxScrollExtent: 0.0, @@ -87,8 +97,6 @@ void main() { axisDirection: AxisDirection.down ); scrollPainter.update(metrics, AxisDirection.down); - await tester.pump(const Duration(milliseconds: 200)); - await tester.pump(const Duration(milliseconds: 200)); final List invocations = []; final TestCanvas canvas = new TestCanvas(invocations); @@ -96,4 +104,39 @@ void main() { final Rect thumbRect = invocations.single.positionalArguments[0]; expect(thumbRect.isFinite, isTrue); }); + + testWidgets('Adaptive scrollbar', (WidgetTester tester) async { + Widget viewWithScroll(TargetPlatform platform) { + return new Directionality( + textDirection: TextDirection.ltr, + child: new Theme( + data: new ThemeData( + platform: platform + ), + child: new Scrollbar( + child: new SingleChildScrollView( + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll(TargetPlatform.android)); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.pumpWidget(viewWithScroll(TargetPlatform.iOS)); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)) + ); + await gesture.moveBy(const Offset(0.0, -10.0)); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(Scrollbar), paints..rrect()); + }); }