Fix NestedScrollView example crash when switching tabs on desktop (#186993)
Fixes https://github.com/flutter/flutter/issues/183199
The `NestedScrollView` API example that uses a `TabBar`/`TabBarView`
throws on desktop when you scroll an inner list and then switch tabs.
The inner `CustomScrollView`s are coordinated by the `NestedScrollView`
through a shared controller, which can be attached to more than one
`ScrollPosition` at a time (for example mid-transition between tabs).
The automatic desktop scrollbar attaches to that shared controller, and
a single scrollbar cannot represent multiple positions, so
`RawScrollbar` throws the multiple-position assertion.
This leaves the framework unchanged and fixes the example itself: the
`TabBarView` body is wrapped in a `ScrollConfiguration` that disables
the default scrollbars, since a single scrollbar cannot correctly
represent the coordinated inner positions. A regression test reproduces
the exact assertion from the issue (scroll, then a mouse-wheel pointer
signal during a tab transition) and confirms it no longer throws.
## Tests
- `flutter test
examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart`
- `flutter analyze
examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart
examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart`
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart b/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart
index c88b9e9..400eb2e 100644
--- a/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart
+++ b/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart
@@ -64,66 +64,75 @@
),
];
},
- body: TabBarView(
- // These are the contents of the tab views, below the tabs.
- children: tabs.map((String name) {
- return SafeArea(
- top: false,
- bottom: false,
- child: Builder(
- // This Builder is needed to provide a BuildContext that is
- // "inside" the NestedScrollView, so that
- // sliverOverlapAbsorberHandleFor() can find the
- // NestedScrollView.
- builder: (BuildContext context) {
- return CustomScrollView(
- // The "controller" and "primary" members should be left
- // unset, so that the NestedScrollView can control this
- // inner scroll view.
- // If the "controller" property is set, then this scroll
- // view will not be associated with the NestedScrollView.
- // The PageStorageKey should be unique to this ScrollView;
- // it allows the list to remember its scroll position when
- // the tab view is not on the screen.
- key: PageStorageKey<String>(name),
- slivers: <Widget>[
- SliverOverlapInjector(
- // This is the flip side of the SliverOverlapAbsorber
- // above.
- handle:
- NestedScrollView.sliverOverlapAbsorberHandleFor(
- context,
- ),
- ),
- SliverPadding(
- padding: const .all(8.0),
- // In this example, the inner scroll view has
- // fixed-height list items, hence the use of
- // SliverFixedExtentList. However, one could use any
- // sliver widget here, e.g. SliverList or SliverGrid.
- sliver: SliverFixedExtentList.builder(
- // The items in this example are fixed to 48 pixels
- // high. This matches the Material Design spec for
- // ListTile widgets.
- itemExtent: 48.0,
- // The itemCount of the SliverFixedExtentList.builder
- // specifies how many children this inner list
- // has. In this example, each tab has a list of
- // exactly 30 items, but this is arbitrary.
- itemCount: 30,
- itemBuilder: (BuildContext context, int index) {
- // This builder is called for each child.
- // In this example, we just number each list item.
- return ListTile(title: Text('Item $index'));
- },
+ body: ScrollConfiguration(
+ // Scrollbars have different default behaviors based on platform
+ // expectations. For the purpose of this sample, which can be run on
+ // any platform, default scrollbars are disabled for the inner
+ // scrollables.
+ behavior: ScrollConfiguration.of(
+ context,
+ ).copyWith(scrollbars: false),
+ child: TabBarView(
+ // These are the contents of the tab views, below the tabs.
+ children: tabs.map((String name) {
+ return SafeArea(
+ top: false,
+ bottom: false,
+ child: Builder(
+ // This Builder is needed to provide a BuildContext that is
+ // "inside" the NestedScrollView, so that
+ // sliverOverlapAbsorberHandleFor() can find the
+ // NestedScrollView.
+ builder: (BuildContext context) {
+ return CustomScrollView(
+ // The "controller" and "primary" members should be left
+ // unset, so that the NestedScrollView can control this
+ // inner scroll view.
+ // If the "controller" property is set, then this scroll
+ // view will not be associated with the NestedScrollView.
+ // The PageStorageKey should be unique to this ScrollView;
+ // it allows the list to remember its scroll position when
+ // the tab view is not on the screen.
+ key: PageStorageKey<String>(name),
+ slivers: <Widget>[
+ SliverOverlapInjector(
+ // This is the flip side of the SliverOverlapAbsorber
+ // above.
+ handle:
+ NestedScrollView.sliverOverlapAbsorberHandleFor(
+ context,
+ ),
),
- ),
- ],
- );
- },
- ),
- );
- }).toList(),
+ SliverPadding(
+ padding: const .all(8.0),
+ // In this example, the inner scroll view has
+ // fixed-height list items, hence the use of
+ // SliverFixedExtentList. However, one could use any
+ // sliver widget here, e.g. SliverList or SliverGrid.
+ sliver: SliverFixedExtentList.builder(
+ // The items in this example are fixed to 48 pixels
+ // high. This matches the Material Design spec for
+ // ListTile widgets.
+ itemExtent: 48.0,
+ // The itemCount of the SliverFixedExtentList.builder
+ // specifies how many children this inner list
+ // has. In this example, each tab has a list of
+ // exactly 30 items, but this is arbitrary.
+ itemCount: 30,
+ itemBuilder: (BuildContext context, int index) {
+ // This builder is called for each child.
+ // In this example, we just number each list item.
+ return ListTile(title: Text('Item $index'));
+ },
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }).toList(),
+ ),
),
),
),
diff --git a/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart b/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart
index 7768df8..b183365 100644
--- a/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart
+++ b/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/nested_scroll_view/nested_scroll_view.0.dart'
as example;
@@ -50,4 +51,52 @@
lessThan(initialAppBarHeight),
);
});
+
+ testWidgets(
+ 'Does not crash when scrolling an inner list then switching tabs on desktop',
+ (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/183199
+ //
+ // Without the ScrollConfiguration in this example, the default desktop
+ // scrollbar attaches to the NestedScrollView's coordinated controller and
+ // throws once more than one ScrollPosition is attached to it, which happens
+ // mid tab transition.
+ await tester.pumpWidget(const example.NestedScrollViewExampleApp());
+ await tester.pumpAndSettle();
+
+ // The example opts out of the default scrollbars for its body.
+ expect(find.byType(Scrollbar), findsNothing);
+
+ // Scroll the first tab's inner list.
+ await tester.drag(
+ find.text('Item 0'),
+ const Offset(0.0, -100.0),
+ touchSlopY: 0.0,
+ );
+ await tester.pump();
+
+ // Begin, but do not finish, a tab transition so both tabs' inner scroll
+ // views are attached to the coordinated controller at the same time.
+ await tester.fling(
+ find.byType(TabBarView),
+ const Offset(-300.0, 0.0),
+ 800.0,
+ );
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 30));
+
+ // A mouse-wheel pointer signal mid-transition previously drove the
+ // scrollbar validation while more than one position was attached.
+ final TestPointer pointer = TestPointer(1, PointerDeviceKind.mouse);
+ final Offset center = tester.getCenter(find.byType(TabBarView));
+ await tester.sendEventToBinding(pointer.hover(center));
+ await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 60.0)));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 700));
+ await tester.pumpAndSettle();
+
+ expect(tester.takeException(), isNull);
+ },
+ variant: TargetPlatformVariant.desktop(),
+ );
}