diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 2151d206d6..239733dc6a 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -578,8 +578,9 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// /// By default, the thumb will fade in and out as the child scroll view /// scrolls. When [isAlwaysShown] is true, the scrollbar thumb will remain -/// visible without the fade animation. This requires that a [ScrollController] -/// is provided to [controller], or that the [PrimaryScrollController] is available. +/// visible without the fade animation. This requires that the [ScrollController] +/// associated with the Scrollable widget is provided to [controller], or that +/// the [PrimaryScrollController] is being used by that Scrollable widget. /// /// If the scrollbar is wrapped around multiple [ScrollView]s, it only responds to /// the nearest scrollView and shows the corresponding scrollbar thumb by default. @@ -705,9 +706,18 @@ class RawScrollbar extends StatefulWidget { /// When false, the scrollbar will be shown during scrolling /// and will fade out otherwise. /// - /// When true, the scrollbar will always be visible and never fade out. If the - /// [controller] property has not been set, the [PrimaryScrollController] will - /// be used. + /// When true, the scrollbar will always be visible and never fade out. This + /// requires that the Scrollbar can access the [ScrollController] of the + /// associated Scrollable widget. This can either be the provided [controller], + /// or the [PrimaryScrollController] of the current context. + /// + /// * When providing a controller, the same ScrollController must also be + /// provided to the associated Scrollable widget. + /// * The [PrimaryScrollController] is used by default for a [ScrollView] + /// that has not been provided a [ScrollController] and that has an + /// [Axis.vertical] [ScrollDirection]. This automatic behavior does not + /// apply to those with a ScrollDirection of Axis.horizontal. To explicitly + /// use the PrimaryScrollController, set [ScrollView.primary] to true. /// /// Defaults to false when null. /// @@ -759,6 +769,10 @@ class RawScrollbar extends StatefulWidget { /// /// * [RawScrollbarState.showScrollbar], an overridable getter which uses /// this value to override the default behavior. + /// * [ScrollView.primary], which indicates whether the ScrollView is the primary + /// scroll view associated with the parent [PrimaryScrollController]. + /// * [PrimaryScrollController], which associates a [ScrollController] with + /// a subtree. /// {@endtemplate} final bool? isAlwaysShown; @@ -916,11 +930,73 @@ class RawScrollbarState extends State with TickerProv // thumb to show immediately when isAlwaysShown is true. A scroll // event is required in order to paint the thumb. final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.of(context); + final bool tryPrimary = widget.controller == null; + final String controllerForError = tryPrimary + ? 'provided ScrollController' + : 'PrimaryScrollController'; assert( scrollController != null, 'A ScrollController is required when Scrollbar.isAlwaysShown is true. ' - 'Either Scrollbar.controller was not provided, or a PrimaryScrollController could not be found.', + '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, ' + 'and attempted to use the PrimaryScrollController, but none was found.' :''}', ); + assert (() { + if (!scrollController!.hasClients) { + throw FlutterError.fromParts([ + ErrorSummary( + 'The Scrollbar\'s ScrollController has no ScrollPosition attached.' + ), + ErrorDescription( + 'A Scrollbar cannot be painted without a ScrollPosition. ' + ), + ErrorHint( + 'The Scrollbar attempted to use the $controllerForError. This ' + 'ScrollController should be associated with the ScrollView that ' + 'the Scrollbar is being applied to. ' + '${tryPrimary + ? 'A ScrollView with an Axis.vertical ' + 'ScrollDirection will automatically use the ' + 'PrimaryScrollController if the user has not provided a ' + 'ScrollController, but a ScrollDirection of Axis.horizontal will ' + 'not. To use the PrimaryScrollController explicitly, set ScrollView.primary ' + 'to true for the Scrollable widget.' + : 'When providing your own ScrollController, ensure both the ' + 'Scrollbar and the Scrollable widget use the same one.' + }' + ), + ]); + } + return true; + }()); + assert (() { + try { + scrollController!.position; + } catch (_) { + throw FlutterError.fromParts([ + ErrorSummary( + 'The $controllerForError is currently attached to more than one ' + 'ScrollPosition.' + ), + ErrorDescription( + 'The Scrollbar requires a single ScrollPosition in order to be painted.' + ), + ErrorHint( + 'When Scrollbar.isAlwaysShown is true, the associated Scrollable ' + 'widgets must have unique ScrollControllers. ' + '${tryPrimary + ? 'The PrimaryScrollController is used by default for ' + 'ScrollViews with an Axis.vertical ScrollDirection, ' + 'unless the ScrollView has been provided its own ' + 'ScrollController. More than one Scrollable may have tried ' + 'to use the PrimaryScrollController of the current context.' + : 'The provided ScrollController must be unique to a ' + 'Scrollable widget.' + }' + ), + ]); + } + return true; + }()); scrollController!.position.didUpdateScrollPositionBy(0); } }); diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index e8f35ac1bd..f5e56548ce 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -1275,4 +1275,63 @@ void main() { // The offset should not have changed. expect(scrollController.offset, scrollAmount); }); + + testWidgets('Scrollbar.isAlwaysShown triggers assertion when multiple ScrollPositions are attached.', (WidgetTester tester) async { + Widget _getTabContent({ ScrollController? scrollController }) { + return Scrollbar( + isAlwaysShown: true, + controller: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: 200, + itemBuilder: (BuildContext context, int index) => const Text('Test'), + ), + ); + } + + Widget _buildApp({ ScrollController? scrollController }) { + return MaterialApp( + home: DefaultTabController( + length: 2, + child: Scaffold( + body: TabBarView( + children: [ + _getTabContent(scrollController: scrollController), + _getTabContent(scrollController: scrollController), + ], + ), + ), + ), + ); + } + + // Asserts when using the PrimaryScrollController. + await tester.pumpWidget(_buildApp()); + + // Swipe to the second tab, resulting in two attached ScrollPositions during + // the transition. + try { + await tester.drag(find.text('Test').first, const Offset(10.0, 0.0)); + } on FlutterError catch (error) { + expect( + error.message, + contains('The Scrollbar attempted to paint using the position attached to the PrimaryScrollController.'), + ); + } + + // Asserts when using the ScrollController provided by the user. + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget(_buildApp(scrollController: scrollController)); + + // Swipe to the second tab, resulting in two attached ScrollPositions during + // the transition. + try { + await tester.drag(find.text('Test').first, const Offset(10.0, 0.0)); + } on AssertionError catch (error) { + expect( + error.message, + contains('The Scrollbar attempted to paint using the position attached to the provided ScrollController.'), + ); + } + }); } diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index 4220c78cfd..7ea232433f 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -955,4 +955,33 @@ void main() { ), ); }); + + testWidgets('RawScrollbar.isAlwaysShown asserts that a ScrollPosition is attached', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: RawScrollbar( + isAlwaysShown: true, + controller: ScrollController(), + thumbColor: const Color(0x11111111), + child: const SingleChildScrollView( + child: SizedBox( + height: 1000.0, + width: 50.0, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final dynamic exception = tester.takeException(); + expect(exception, isAssertionError); + expect( + (exception as AssertionError).message, + contains('The Scrollbar\'s ScrollController has no ScrollPosition attached.'), + ); + }); }