From bb278d1da222fb664a8e3daa47e4e21673adeb99 Mon Sep 17 00:00:00 2001 From: Mohammad Ghalayini Date: Thu, 26 Aug 2021 03:21:02 +0300 Subject: [PATCH] [new feature] Add support for a RawScrollbar.shape (#85652) --- .../flutter/lib/src/widgets/scrollbar.dart | 77 +++++++++- .../flutter/test/widgets/scrollbar_test.dart | 135 ++++++++++++++++++ 2 files changed, 209 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 261bebd60d5..0944c07fdf2 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -85,10 +85,12 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { double mainAxisMargin = 0.0, double crossAxisMargin = 0.0, Radius? radius, + OutlinedBorder? shape, double minLength = _kMinThumbExtent, double? minOverscrollLength, ScrollbarOrientation? scrollbarOrientation, }) : assert(color != null), + assert(radius == null || shape == null), assert(thickness != null), assert(fadeoutOpacityAnimation != null), assert(mainAxisMargin != null), @@ -103,6 +105,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { _textDirection = textDirection, _thickness = thickness, _radius = radius, + _shape = shape, _padding = padding, _mainAxisMargin = mainAxisMargin, _crossAxisMargin = crossAxisMargin, @@ -217,6 +220,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { Radius? get radius => _radius; Radius? _radius; set radius(Radius? value) { + assert(shape == null || value == null); if (radius == value) return; @@ -224,6 +228,26 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { notifyListeners(); } + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [color] (or grey if it + /// is unspecified). + /// + OutlinedBorder? get shape => _shape; + OutlinedBorder? _shape; + set shape(OutlinedBorder? value){ + assert(radius == null || value == null); + if(shape == value) + return; + + _shape = value; + notifyListeners(); + } /// The amount of space by which to inset the scrollbar's start and end, as /// well as its side to the nearest edge, in logical pixels. /// @@ -447,10 +471,20 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ); _thumbRect = Offset(x, y) & thumbSize; - if (radius == null) - canvas.drawRect(_thumbRect!, _paintThumb); - else + + if (radius != null) { canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); + return; + } + + if (shape == null) { + canvas.drawRect(_thumbRect!, _paintThumb); + return; + } + + final Path outerPath = shape!.getOuterPath(_thumbRect!); + canvas.drawPath(outerPath, _paintThumb); + shape!.paint(canvas, _thumbRect!); } double _thumbExtent() { @@ -776,6 +810,7 @@ class RawScrollbar extends StatefulWidget { required this.child, this.controller, this.isAlwaysShown, + this.shape, this.radius, this.thickness, this.thumbColor, @@ -795,6 +830,7 @@ class RawScrollbar extends StatefulWidget { assert(minOverscrollLength == null || minOverscrollLength <= minThumbLength), assert(minOverscrollLength == null || minOverscrollLength >= 0), assert(fadeDuration != null), + assert(radius == null || shape == null), assert(timeToFade != null), assert(pressDuration != null), assert(mainAxisMargin != null), @@ -944,6 +980,39 @@ class RawScrollbar extends StatefulWidget { /// {@endtemplate} final bool? isAlwaysShown; + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [thumbColor] (or grey if it + /// is unspecified). + /// + /// Here is an example of using a [StadiumBorder] for drawing the [shape] of the + /// thumb in a [RawScrollbar]: + /// + /// {@tool dartpad --template=stateless_widget_material} + /// ```dart + /// Widget build(BuildContext context) { + /// return Scaffold( + /// body: RawScrollbar( + /// child: ListView( + /// children: List.generate(100, (int index) => Text((index * index).toString())), + /// physics: const BouncingScrollPhysics(), + /// ), + /// shape: const StadiumBorder(side: BorderSide(color: Colors.brown, width: 3.0)), + /// thickness: 15.0, + /// thumbColor: Colors.blue, + /// isAlwaysShown: true, + /// ), + /// ); + /// } + /// ``` + /// {@end-tool} + final OutlinedBorder? shape; + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. /// /// Scrollbar will be rectangular if [radius] is null, which is the default @@ -1124,6 +1193,7 @@ class RawScrollbarState extends State with TickerProv fadeoutOpacityAnimation: _fadeoutOpacityAnimation, scrollbarOrientation: widget.scrollbarOrientation, mainAxisMargin: widget.mainAxisMargin, + shape: widget.shape, crossAxisMargin: widget.crossAxisMargin ); } @@ -1253,6 +1323,7 @@ class RawScrollbarState extends State with TickerProv ..padding = MediaQuery.of(context).padding ..scrollbarOrientation = widget.scrollbarOrientation ..mainAxisMargin = widget.mainAxisMargin + ..shape = widget.shape ..crossAxisMargin = widget.crossAxisMargin ..minLength = widget.minThumbLength ..minOverscrollLength = widget.minOverscrollLength ?? widget.minThumbLength; diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index e9f40b8559f..31d20242d83 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -1445,6 +1445,7 @@ void main() { ), ); }); + testWidgets('ScrollbarPainter asserts if scrollbarOrientation is used with wrong axisDirection', (WidgetTester tester) async { final ScrollbarPainter painter = ScrollbarPainter( color: _kScrollbarColor, @@ -1492,6 +1493,44 @@ void main() { ..rect(rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 358.0)) ); }); + + testWidgets('shape property of RawScrollbar can draw a BeveledRectangleBorder', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + shape: const BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)) + ), + controller: scrollController, + isAlwaysShown: true, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0), + ), + ), + ))); + await tester.pumpAndSettle(); + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) + ..path( + includes: const [ + Offset(797.0, 0.0), + Offset(797.0, 18.0), + ], + excludes: const [ + Offset(796.0, 0.0), + Offset(798.0, 0.0), + ], + ), + ); + }); + testWidgets('minThumbLength property of RawScrollbar is respected', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget( @@ -1518,6 +1557,42 @@ void main() { ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 21.0))); // thumb }); + testWidgets('shape property of RawScrollbar can draw a CircleBorder', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + shape: const CircleBorder(side: BorderSide(width: 2.0)), + thickness: 36.0, + controller: scrollController, + isAlwaysShown: true, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0, width: 1000), + ), + ), + ))); + await tester.pumpAndSettle(); + + expect( + find.byType(RawScrollbar), + paints + ..path( + includes: const [ + Offset(782.0, 180.0), + Offset(782.0, 180.0 - 18.0), + Offset(782.0 + 18.0, 180), + Offset(782.0, 180.0 + 18.0), + Offset(782.0 - 18.0, 180), + ], + ) + ..circle(x: 782.0, y: 180.0, radius: 17.0, strokeWidth: 2.0) + ); + }); + testWidgets('crossAxisMargin property of RawScrollbar is respected', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget( @@ -1543,6 +1618,40 @@ void main() { ..rect(rect: const Rect.fromLTRB(764.0, 0.0, 770.0, 360.0))); }); + testWidgets('shape property of RawScrollbar can draw a RoundedRectangleBorder', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + thickness: 20, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.only(topLeft: Radius.circular(8))), + controller: scrollController, + isAlwaysShown: true, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0, width: 1000.0), + ), + ), + ))); + await tester.pumpAndSettle(); + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 600.0)) + ..path( + includes: const [ + Offset(800.0, 0.0), + ], + excludes: const [ + Offset(780.0, 0.0), + ], + ), + ); + }); + testWidgets('minOverscrollLength property of RawScrollbar is respected', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget( @@ -1575,6 +1684,32 @@ void main() { ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 8.0))); }); + testWidgets('not passing any shape or radius to RawScrollbar will draw the usual rectangular thumb', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + controller: scrollController, + isAlwaysShown: true, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0), + ), + ), + ))); + await tester.pumpAndSettle(); + + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0)) + ); + }); + testWidgets('The bar can show or hide when the viewport size change', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); Widget buildFrame(double height) {