diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index 45e2e060fe..70fb3d9292 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -18,18 +18,18 @@ const Radius _kScrollbarRadius = Radius.circular(8.0); const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); -/// A material design scrollbar. +/// A Material Design scrollbar. /// -/// To add a scrollbar thumb to a [ScrollView], simply wrap the scroll view +/// To add a scrollbar to a [ScrollView], wrap the scroll view /// widget in a [Scrollbar] widget. /// /// {@macro flutter.widgets.Scrollbar} /// -/// The color of the Scrollbar will change when dragged, as well as when -/// hovered over. A scrollbar track can also been drawn when triggered by a -/// hover event, which is controlled by [showTrackOnHover]. The thickness of the -/// track and scrollbar thumb will become larger when hovering, unless -/// overridden by [hoverThickness]. +/// The color of the Scrollbar will change when dragged. A hover animation is +/// also triggered when used on web and desktop platforms. A scrollbar track +/// can also been drawn when triggered by a hover event, which is controlled by +/// [showTrackOnHover]. The thickness of the track and scrollbar thumb will +/// become larger when hovering, unless overridden by [hoverThickness]. /// // TODO(Piinks): Add code sample /// @@ -50,8 +50,11 @@ class Scrollbar extends RawScrollbar { /// If the [controller] is null, the default behavior is to /// enable scrollbar dragging using the [PrimaryScrollController]. /// - /// When null, [thickness] and [radius] defaults will result in a rounded - /// rectangular thumb that is 8.0 dp wide with a radius of 8.0 pixels. + /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 + /// pixels when on mobile platforms. A null [radius] will result in a default + /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, + /// except for when executing on [TargetPlatform.android], which will render the + /// thumb without a radius. const Scrollbar({ Key? key, required Widget child, @@ -66,7 +69,7 @@ class Scrollbar extends RawScrollbar { child: child, controller: controller, isAlwaysShown: isAlwaysShown, - thickness: thickness ?? _kScrollbarThickness, + thickness: thickness, radius: radius, fadeDuration: _kScrollbarFadeDuration, timeToFade: _kScrollbarTimeToFade, @@ -93,6 +96,11 @@ class _ScrollbarState extends RawScrollbarState { bool _dragIsActive = false; bool _hoverIsActive = false; late ColorScheme _colorScheme; + // On Android, scrollbars should match native appearance. + late bool _useAndroidScrollbar; + // Hover events should be ignored on mobile, the exit event cannot be + // triggered, but the enter event can on tap. + late bool _isMobile; Set get _states => { if (_dragIsActive) MaterialState.dragged, @@ -165,7 +173,8 @@ class _ScrollbarState extends RawScrollbarState { return MaterialStateProperty.resolveWith((Set states) { if (states.contains(MaterialState.hovered) && widget.showTrackOnHover) return widget.hoverThickness ?? _kScrollbarThicknessWithTrack; - return widget.thickness ?? _kScrollbarThickness; + // The default scrollbar thickness is smaller on mobile. + return widget.thickness ?? (_kScrollbarThickness / (_isMobile ? 2 : 1)); }); } @@ -181,6 +190,29 @@ class _ScrollbarState extends RawScrollbarState { }); } + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + _useAndroidScrollbar = true; + _isMobile = true; + break; + case TargetPlatform.iOS: + _useAndroidScrollbar = false; + _isMobile = true; + break; + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _useAndroidScrollbar = false; + _isMobile = false; + break; + } + super.didChangeDependencies(); + } + @override void updateScrollbarPainter() { _colorScheme = Theme.of(context).colorScheme; @@ -190,8 +222,8 @@ class _ScrollbarState extends RawScrollbarState { ..trackBorderColor = _trackBorderColor.resolve(_states) ..textDirection = Directionality.of(context) ..thickness = _thickness.resolve(_states) - ..radius = widget.radius ?? _kScrollbarRadius - ..crossAxisMargin = _kScrollbarMargin + ..radius = widget.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius) + ..crossAxisMargin = (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin) ..minLength = _kScrollbarMinLength ..padding = MediaQuery.of(context).padding; } @@ -210,6 +242,8 @@ class _ScrollbarState extends RawScrollbarState { @override void handleHover(PointerHoverEvent event) { + // Hover events should not be triggered on mobile. + assert(!_isMobile); super.handleHover(event); // Check if the position of the pointer falls over the painted scrollbar if (isPointerOverScrollbar(event.position)) { @@ -225,6 +259,8 @@ class _ScrollbarState extends RawScrollbarState { @override void handleHoverExit(PointerExitEvent event) { + // Hover events should not be triggered on mobile. + assert(!_isMobile); super.handleHoverExit(event); setState(() { _hoverIsActive = false; }); _hoverAnimationController.reverse(); diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 986576ffb5..55637cb549 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -781,6 +781,7 @@ class RawScrollbarState extends State with TickerProv late Animation _fadeoutOpacityAnimation; final GlobalKey _scrollbarPainterKey = GlobalKey(); bool _hoverIsActive = false; + late bool _isMobile; /// Used to paint the scrollbar. @@ -811,6 +812,18 @@ class RawScrollbarState extends State with TickerProv @override void didChangeDependencies() { super.didChangeDependencies(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + _isMobile = true; + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _isMobile = false; + break; + } _maybeTriggerScrollbar(); } @@ -1145,20 +1158,28 @@ class RawScrollbarState extends State with TickerProv @override Widget build(BuildContext context) { updateScrollbarPainter(); + + Widget child = CustomPaint( + key: _scrollbarPainterKey, + foregroundPainter: scrollbarPainter, + child: RepaintBoundary(child: widget.child), + ); + + if (!_isMobile) { + // Hover events not supported on mobile. + child = MouseRegion( + onExit: handleHoverExit, + onHover: handleHover, + child: child + ); + } + return NotificationListener( onNotification: _handleScrollNotification, child: RepaintBoundary( child: RawGestureDetector( gestures: _gestures, - child: MouseRegion( - onExit: handleHoverExit, - onHover: handleHover, - child: CustomPaint( - key: _scrollbarPainterKey, - foregroundPainter: scrollbarPainter, - child: RepaintBoundary(child: widget.child), - ), - ), + child: child, ), ), ); diff --git a/packages/flutter/test/material/scrollbar_paint_test.dart b/packages/flutter/test/material/scrollbar_paint_test.dart index 350797decf..aba221a636 100644 --- a/packages/flutter/test/material/scrollbar_paint_test.dart +++ b/packages/flutter/test/material/scrollbar_paint_test.dart @@ -30,7 +30,24 @@ void main() { )); expect(find.byType(Scrollbar), isNot(paints..rect())); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); - expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(800.0 - 12.0, 0.0, 800.0, 600.0))); + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 1.5, 800.0, 91.5), + color: const Color(0x1a000000), + ), + ); }); testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async { @@ -40,7 +57,24 @@ void main() { )); expect(find.byType(Scrollbar), isNot(paints..rect())); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); - expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 0.0, 12.0, 600.0))); + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 4.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(0.0, 0.0), + p2: const Offset(0.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(0.0, 1.5, 4.0, 91.5), + color: const Color(0x1a000000), + ), + ); }); testWidgets('works with MaterialApp and Scaffold', (WidgetTester tester) async { @@ -67,15 +101,24 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - expect(find.byType(Scrollbar), paints..rect( - rect: const Rect.fromLTWH( - 800.0 - 12, // screen width - default thickness and margin - 0, // the paint area starts from the bottom of the app bar - 12, // thickness - // 56 being the height of the app bar - 600.0 - 56 - 34 - 20, - ), - )); + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 490.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 490.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTWH(796.0, 0.0, 4.0, (600.0 - 56 - 34 - 20) / 4000 * (600 - 56 - 34 - 20)), + color: const Color(0x1a000000), + ), + ); }); testWidgets("should not paint when there isn't enough space", (WidgetTester tester) async { diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 03ba56ca03..b388b1bf2b 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -502,12 +502,27 @@ void main() { await tester.pump(); // Long press on the scrollbar thumb and expect it to grow - expect(find.byType(Scrollbar), paints..rrect( - rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(778, 0, 20, 300), const Radius.circular(8)), - )); + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(780.0, 0.0), + p2: const Offset(780.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 300.0), + color: const Color(0x1a000000), + ), + ); await tester.pumpWidget(viewWithScroll(radius: const Radius.circular(10))); expect(find.byType(Scrollbar), paints..rrect( - rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(778, 0, 20, 300), const Radius.circular(10)), + rrect: RRect.fromRectAndRadius(const Rect.fromLTRB(780, 0.0, 800.0, 300.0), const Radius.circular(10)), )); await tester.pumpAndSettle(); @@ -536,9 +551,21 @@ void main() { expect(scrollController.offset, 0.0); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromLTRBR(790.0, 0.0, 798.0, 360.0, const Radius.circular(8.0)), - ) + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 360.0), + color: const Color(0x1a000000), + ), ); // Tap on the track area below the thumb. @@ -548,12 +575,21 @@ void main() { expect(scrollController.offset, 400.0); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 240.0, 798.0, 600.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 240.0, 800.0, 600.0), + color: const Color(0x1a000000), ), - ) ); // Tap on the track area above the thumb. @@ -563,9 +599,21 @@ void main() { expect(scrollController.offset, 0.0); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromLTRBR(790.0, 0.0, 798.0, 360.0, const Radius.circular(8.0)), - ) + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 360.0), + color: const Color(0x1a000000), + ), ); }); @@ -586,13 +634,21 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), + color: const Color(0x1a000000), ), - color: const Color(0x1a000000), - ), ); await tester.pump(const Duration(seconds: 3)); @@ -600,13 +656,21 @@ void main() { // Still there. expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), + color: const Color(0x1a000000), ), - color: const Color(0x1a000000), - ), ); await gesture.up(); @@ -616,13 +680,21 @@ void main() { // Opacity going down now. expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), + color: const Color(0x14000000), ), - color: const Color(0x14000000), - ), ); }); @@ -646,13 +718,21 @@ void main() { expect(scrollController.offset, 0.0); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 90.0), + color: const Color(0x1a000000), ), - color: const Color(0x1a000000), - ), ); // Drag the thumb down to scroll down. @@ -662,14 +742,22 @@ void main() { expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 90.0), + // Drag color + color: const Color(0x99000000), ), - // Drag color - color: const Color(0x99000000), - ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); @@ -682,13 +770,21 @@ void main() { expect(scrollController.offset, greaterThan(scrollAmount * 2)); expect( find.byType(Scrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), - const Radius.circular(8.0), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), + color: const Color(0x1a000000), ), - color: const Color(0x1a000000), - ), ); }); @@ -737,7 +833,63 @@ void main() { color: const Color(0x80000000), ), ); - }); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('Hover animation is not triggered on mobile', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + isAlwaysShown: true, + showTrackOnHover: true, + controller: scrollController, + child: const SingleChildScrollView( + child: SizedBox(width: 4000.0, height: 4000.0) + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(794.0, 0.0, 798.0, 90.0), + const Radius.circular(8.0), + ), + color: const Color(0x1a000000), + ), + ); + await tester.tapAt(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + // Tapping on mobile triggers a hover enter event. In this case, the + // Scrollbar should be unchanged since it ignores hover events on mobile. + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(794.0, 0.0, 798.0, 90.0), + const Radius.circular(8.0), + ), + color: const Color(0x1a000000), + ), + ); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.iOS, + }), + ); testWidgets('Scrollbar showTrackOnHover', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); @@ -797,5 +949,12 @@ void main() { color: const Color(0x80000000), ), ); - }); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); }