From e7772d0e2ecb9529f692556e8b446de65219372c Mon Sep 17 00:00:00 2001 From: xubaolin Date: Wed, 20 Jan 2021 07:44:03 +0800 Subject: [PATCH] Reland "Improve the ScrollBar behavior when nested (#71843)" (#74104) --- .../flutter/lib/src/cupertino/scrollbar.dart | 2 + .../flutter/lib/src/material/scrollbar.dart | 8 +++ .../flutter/lib/src/widgets/scrollbar.dart | 17 +++++ .../flutter/test/material/scrollbar_test.dart | 62 ++++++++++++++++++ .../flutter/test/widgets/scrollbar_test.dart | 63 +++++++++++++++++++ 5 files changed, 152 insertions(+) diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index ba07555318..13de5c93a9 100644 --- a/packages/flutter/lib/src/cupertino/scrollbar.dart +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -61,6 +61,7 @@ class CupertinoScrollbar extends RawScrollbar { this.thicknessWhileDragging = defaultThicknessWhileDragging, Radius radius = defaultRadius, this.radiusWhileDragging = defaultRadiusWhileDragging, + ScrollNotificationPredicate? notificationPredicate, }) : assert(thickness != null), assert(thickness < double.infinity), assert(thicknessWhileDragging != null), @@ -77,6 +78,7 @@ class CupertinoScrollbar extends RawScrollbar { fadeDuration: _kScrollbarFadeDuration, timeToFade: _kScrollbarTimeToFade, pressDuration: const Duration(milliseconds: 100), + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, ); /// Default value for [thickness] if it's not specified in [CupertinoScrollbar]. diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index 47fc343dd3..8806c1bd37 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -69,6 +69,7 @@ class Scrollbar extends StatefulWidget { this.hoverThickness, this.thickness, this.radius, + this.notificationPredicate, }) : super(key: key); /// {@macro flutter.widgets.Scrollbar.child} @@ -111,6 +112,9 @@ class Scrollbar extends StatefulWidget { /// default [Radius.circular] of 8.0 pixels. final Radius? radius; + /// {@macro flutter.widgets.Scrollbar.notificationPredicate} + final ScrollNotificationPredicate? notificationPredicate; + @override _ScrollbarState createState() => _ScrollbarState(); } @@ -129,6 +133,7 @@ class _ScrollbarState extends State { radius: widget.radius ?? CupertinoScrollbar.defaultRadius, radiusWhileDragging: widget.radius ?? CupertinoScrollbar.defaultRadiusWhileDragging, controller: widget.controller, + notificationPredicate: widget.notificationPredicate, ); } return _MaterialScrollbar( @@ -139,6 +144,7 @@ class _ScrollbarState extends State { hoverThickness: widget.hoverThickness, thickness: widget.thickness, radius: widget.radius, + notificationPredicate: widget.notificationPredicate, ); } } @@ -153,6 +159,7 @@ class _MaterialScrollbar extends RawScrollbar { this.hoverThickness, double? thickness, Radius? radius, + ScrollNotificationPredicate? notificationPredicate, }) : super( key: key, child: child, @@ -163,6 +170,7 @@ class _MaterialScrollbar extends RawScrollbar { fadeDuration: _kScrollbarFadeDuration, timeToFade: _kScrollbarTimeToFade, pressDuration: Duration.zero, + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, ); final bool? showTrackOnHover; diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index d7831d0df9..186451c1b7 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -565,6 +565,10 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// visible without the fade animation. This requires that a [ScrollController] /// is provided to [controller], or that the [PrimaryScrollController] is available. /// +/// If the scrollbar is wrapped around multiple [ScrollView]s, it only responds to +/// the nearest scrollView and shows the corresponding scrollbar thumb by default. +/// Set [notificationPredicate] to something else for more complicated behaviors. +/// /// Scrollbars are interactive and will also use the [PrimaryScrollController] if /// a [controller] is not set. Scrollbar thumbs can be dragged along the main axis /// of the [ScrollView] to change the [ScrollPosition]. Tapping along the track @@ -607,6 +611,7 @@ class RawScrollbar extends StatefulWidget { this.fadeDuration = _kScrollbarFadeDuration, this.timeToFade = _kScrollbarTimeToFade, this.pressDuration = Duration.zero, + this.notificationPredicate = defaultScrollNotificationPredicate, }) : assert(child != null), assert(fadeDuration != null), assert(timeToFade != null), @@ -767,6 +772,16 @@ class RawScrollbar extends StatefulWidget { /// Cannot be null, defaults to [Duration.zero]. final Duration pressDuration; + /// {@template flutter.widgets.Scrollbar.notificationPredicate} + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. That means if the + /// scrollbar is wrapped around multiple [ScrollView]s, it only responds to the + /// nearest scrollView and shows the corresponding scrollbar thumb. + /// {@endtemplate} + final ScrollNotificationPredicate notificationPredicate; + @override RawScrollbarState createState() => RawScrollbarState(); } @@ -1031,6 +1046,8 @@ class RawScrollbarState extends State with TickerProv } bool _handleScrollNotification(ScrollNotification notification) { + if (!widget.notificationPredicate(notification)) + return false; final ScrollMetrics metrics = notification.metrics; if (metrics.maxScrollExtent <= metrics.minScrollExtent) diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 115ae2a3fe..e679ac399c 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -1059,4 +1059,66 @@ void main() { final CupertinoScrollbar scrollbar = tester.widget(find.byType(CupertinoScrollbar)); expect(scrollbar.controller, isNotNull); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + + testWidgets("Scrollbar doesn't show when scroll the inner scrollable widget", (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + final GlobalKey outerKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scrollbar( + key: key2, + notificationPredicate: null, + child: SingleChildScrollView( + key: outerKey, + child: SizedBox( + height: 1000.0, + width: double.infinity, + child: Column( + children: [ + Scrollbar( + key: key1, + notificationPredicate: null, + child: SizedBox( + height: 300.0, + width: double.infinity, + child: SingleChildScrollView( + key: innerKey, + child: const SizedBox( + key: Key('Inner scrollable'), + height: 1000.0, + width: double.infinity, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + // Drag the inner scrollable widget. + await tester.drag(find.byKey(innerKey), const Offset(0.0, -25.0)); + await tester.pump(); + // Scrollbar fully showing. + await tester.pump(const Duration(milliseconds: 500)); + + expect( + tester.renderObject(find.byKey(key2)), + paintsExactlyCountTimes(#drawRect, 2), // Each bar will call [drawRect] twice. + ); + + expect( + tester.renderObject(find.byKey(key1)), + paintsExactlyCountTimes(#drawRect, 2), + ); + }, variant: TargetPlatformVariant.all()); } diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index 0b33c60f35..f2005c5c85 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -804,4 +804,67 @@ void main() { ), ); }); + + // Regression test for https://github.com/flutter/flutter/issues/66444 + testWidgets("RawScrollbar doesn't show when scroll the inner scrollable widget", (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + final GlobalKey outerKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + key: key2, + thumbColor: const Color(0x11111111), + child: SingleChildScrollView( + key: outerKey, + child: SizedBox( + height: 1000.0, + width: double.infinity, + child: Column( + children: [ + RawScrollbar( + key: key1, + thumbColor: const Color(0x22222222), + child: SizedBox( + height: 300.0, + width: double.infinity, + child: SingleChildScrollView( + key: innerKey, + child: const SizedBox( + key: Key('Inner scrollable'), + height: 1000.0, + width: double.infinity, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + // Drag the inner scrollable widget. + await tester.drag(find.byKey(innerKey), const Offset(0.0, -25.0)); + await tester.pump(); + // Scrollbar fully showing. + await tester.pump(const Duration(milliseconds: 500)); + + expect( + tester.renderObject(find.byKey(key2)), + paintsExactlyCountTimes(#drawRect, 2), // Each bar will call [drawRect] twice. + ); + + expect( + tester.renderObject(find.byKey(key1)), + paintsExactlyCountTimes(#drawRect, 2), + ); + }); }