Unverified Commit f05c409a authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Improve error messaging and documentation for Scrollbar.isAlwaysShown (#77107)

parent fed35b4d
......@@ -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<T extends RawScrollbar> extends State<T> 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(<DiagnosticsNode>[
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(<DiagnosticsNode>[
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);
}
});
......
......@@ -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: <Widget>[
_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.'),
);
}
});
}
......@@ -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.'),
);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment