Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request clamps semantic scroll actions to prevent overscrolling at boundaries when a screen reader is active, while ensuring non-scrollable views are not made scrollable. It also adds corresponding regression tests. Feedback suggests avoiding the null-assertion operator on _physics by using a local non-nullable variable to improve readability and type safety.
| _physics = _configuration.getScrollPhysics(context); | ||
| _physics = physicsFromWidget?.applyTo(_physics) ?? _physics; | ||
|
|
||
| // Semantic scroll actions synthesize a fixed drag from the viewport size, | ||
| // which bounces past the edge with bouncing physics. Clamp them while a | ||
| // screen reader is active. Skip physics that block user scrolling so a | ||
| // non-scrollable view isn't made scrollable. | ||
| // https://github.com/flutter/flutter/issues/11665 | ||
| if ((MediaQuery.maybeAccessibleNavigationOf(context) ?? false) && | ||
| _physics!.allowUserScrolling) { | ||
| _physics = ClampingScrollPhysics(parent: _physics); | ||
| } |
There was a problem hiding this comment.
Using the null-assertion operator ! on _physics can be avoided by introducing a local non-nullable ScrollPhysics variable. This is more idiomatic in Dart, improves type safety, and avoids relying on field promotion which can be fragile.
ScrollPhysics physics = _configuration.getScrollPhysics(context);
if (physicsFromWidget != null) {
physics = physicsFromWidget.applyTo(physics);
}
// Semantic scroll actions synthesize a fixed drag from the viewport size,
// which bounces past the edge with bouncing physics. Clamp them while a
// screen reader is active. Skip physics that block user scrolling so a
// non-scrollable view isn't made scrollable.
// https://github.com/flutter/flutter/issues/11665
if ((MediaQuery.maybeAccessibleNavigationOf(context) ?? false) &&
physics.allowUserScrolling) {
physics = ClampingScrollPhysics(parent: physics);
}
_physics = physics;References
- Optimize for readability: Code is read more often than it is written. Avoiding the null-assertion operator and using a local non-nullable variable makes the code cleaner and easier to read. (link)
3da1464 to
38d7f3b
Compare
38d7f3b to
e0a8017
Compare
| // which bounces past the edge with bouncing physics. Clamp them while a | ||
| // screen reader is active. Skip physics that block user scrolling so a | ||
| // non-scrollable view isn't made scrollable. | ||
| // https://github.com/flutter/flutter/issues/11665 |
There was a problem hiding this comment.
The issue link can be removed from here.
| // screen reader is active. Skip physics that block user scrolling so a | ||
| // non-scrollable view isn't made scrollable. | ||
| // https://github.com/flutter/flutter/issues/11665 | ||
| if ((MediaQuery.maybeAccessibleNavigationOf(context) ?? false) && physics.allowUserScrolling) { |
There was a problem hiding this comment.
I might move this look up of accessible navigation to didChangeDependencies, and store it (bool _accessibleNavigation) like we do with _mediaQueryGestureSettings and _devicePixelRatio. That will make it much more explicit that the widget tracks this dependency
|
|
||
| void main() { | ||
| SemanticsTester semantics; | ||
| late SemanticsTester semantics; |
There was a problem hiding this comment.
Having a top level variable like this is an anti-pattern. State can bleed between tests if one test fails to dispose of it. This was already here, so we can consider refactoring this as out of scope for this PR. Can you file an issue so we can refactor this test file to lean this up in a new PR?
|
Hi @Piinks Removed the issue link from the comment. Moved the accessible navigation lookup into didChangeDependencies and stored it as bool _accessibleNavigation, matching _mediaQueryGestureSettings / _devicePixelRatio. Filed #189799 to track the top-level SemanticsTester refactor separately. |
Thank you so much! |
88c1643 to
2b8194b
Compare
|
autosubmit label was removed for flutter/flutter/188788, because Pull request flutter/flutter/188788 is not in a mergeable state. |
|
Can you resolve the merge conflicts here? |
2b8194b to
e4f430b
Compare
|
@Piinks conflicts resolved. |
|
autosubmit label was removed for flutter/flutter/188788, because - The status or check suite Google testing has failed. Please fix the issues identified (or deflake) before re-applying this label. |
|
I am reviewing the Google testing failures |
|
Rebasing to get a fresh Google testing run. I have been working with customers to unblock this. |
e4f430b to
4d48192
Compare
Piinks
left a comment
There was a problem hiding this comment.
Thanks for your patience here @mbcorona! I learned a lot/refreshed my memory while I was looking into this. It turns out this change as it is right now breaks a few things.
ClampingScrollPhysics.createBallisticSimulationreturns aClampingScrollSimulationon flings without delegating toparent.createBallisticSimulation. As a result,PageViewfails to snap to page boundaries and stops at arbitrary intermediate offsets.- Flings stop at fractional item offsets rather than snapping to integer item boundaries.
ClampingScrollPhysics.applyBoundaryConditionsstrictly rejects negative overscroll, completely disabling pull-to-refresh gestures in iOS-styled scroll views when accessible navigation is active.- Changing
_physicsaltersshouldAcceptUserOffsetandsetCanDrag, which unregistersRawGestureDetectordrag recognizers, causing child semantics action flags (e.g.tappablevsnot-tappable) to flip and truncating boundary children from the semantics tree in customer feeds. - Semantic scroll actions (
SemanticsAction.scrollUp/scrollDown) should be clamped at the action dispatch level (_RenderSemanticsGestureHandler/_RenderScrollSemantics), rather than replacing the runtime physics of the entire scrollable widget.
Here are some tests to include:
Details
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PR 188788 Regression Tests', () {
testWidgets('PageView should snap to pages when accessibleNavigation is true', (
WidgetTester tester,
) async {
final controller = PageController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: PageView(
controller: controller,
children: const <Widget>[
SizedBox.expand(child: Text('Page 0')),
SizedBox.expand(child: Text('Page 1')),
SizedBox.expand(child: Text('Page 2')),
],
),
),
),
);
expect(controller.page, 0.0);
// Perform a drag/fling on the PageView.
await tester.fling(find.byType(PageView), const Offset(-200.0, 0.0), 1000.0);
await tester.pumpAndSettle();
// PageView should snap to page 1.0, but ClampingScrollSimulation stops at arbitrary offset.
expect(controller.page, 1.0);
controller.dispose();
});
testWidgets('ListWheelScrollView should snap to items when accessibleNavigation is true', (
WidgetTester tester,
) async {
final controller = FixedExtentScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: ListWheelScrollView(
controller: controller,
itemExtent: 50.0,
physics: const FixedExtentScrollPhysics(),
children: List<Widget>.generate(10, (int index) => Text('Item $index')),
),
),
),
);
expect(controller.selectedItem, 0);
// Fling ListWheelScrollView
await tester.fling(find.byType(ListWheelScrollView), const Offset(0.0, -80.0), 500.0);
await tester.pumpAndSettle();
// Offset must be a multiple of itemExtent (50.0)
expect(controller.offset % 50.0, moreOrLessEquals(0.0));
controller.dispose();
});
testWidgets('PageView with accessibleNavigation: false snaps, with true fails', (
WidgetTester tester,
) async {
final controller = PageController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: PageView(
controller: controller,
children: const <Widget>[
SizedBox.expand(child: Text('Page 0')),
SizedBox.expand(child: Text('Page 1')),
SizedBox.expand(child: Text('Page 2')),
],
),
),
),
);
await tester.drag(find.byType(PageView), const Offset(-50.0, 0.0));
await tester.pumpAndSettle();
// Should snap back to 0.0 or 1.0, not stay at 50.0
expect(controller.page == 0.0 || controller.page == 1.0, isTrue);
controller.dispose();
});
testWidgets(
'PageView drag and release without velocity should snap to nearest page when accessibleNavigation is true',
(WidgetTester tester) async {
final controller = PageController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: PageView(
controller: controller,
children: const <Widget>[
SizedBox.expand(child: Text('Page 0')),
SizedBox.expand(child: Text('Page 1')),
SizedBox.expand(child: Text('Page 2')),
],
),
),
),
);
// Drag 50 pixels without velocity (just drag and release)
await tester.drag(find.byType(PageView), const Offset(-50.0, 0.0));
await tester.pumpAndSettle();
// In PageView, dragging by 50px (out of 800px screen) and releasing without velocity
// must snap back to page 0.0.
expect(controller.page, 0.0);
controller.dispose();
},
);
testWidgets(
'BouncingScrollPhysics allows overscroll drag on short list when accessibleNavigation is true',
(WidgetTester tester) async {
final controller = ScrollController();
var minOffset = 0.0;
controller.addListener(() {
if (controller.offset < minOffset) {
minOffset = controller.offset;
}
});
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: ListView(
controller: controller,
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
children: const <Widget>[SizedBox(height: 50.0, child: Text('Short List Item'))],
),
),
),
);
// Drag down past the top edge
await tester.drag(find.text('Short List Item'), const Offset(0.0, 100.0));
await tester.pump();
// Bouncing physics with AlwaysScrollable should allow negative overscroll while dragged
expect(minOffset, lessThan(0.0));
controller.dispose();
},
);
testWidgets(
'Semantics tree child actions and boundary nodes in scroll feed with accessibleNavigation',
(WidgetTester tester) async {
final controller = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: ListView.builder(
controller: controller,
physics: const BouncingScrollPhysics(),
itemCount: 3,
itemBuilder: (BuildContext context, int index) {
final int itemNumber = index + 1;
return SizedBox(
height: 400.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Semantics(
label: 'Video content',
onTapHint: 'Toggle mute',
onLongPressHint: 'Pause video',
child: const SizedBox(height: 100.0, width: double.infinity),
),
const Text('Sponsored'),
Semantics(
button: true,
enabled: true,
label: 'More info',
child: const SizedBox(height: 30.0, width: 100.0),
),
Semantics(
button: true,
enabled: true,
label: 'Show menu',
child: const SizedBox(height: 30.0, width: 100.0),
),
Text(
'Test sponsor $itemNumber\nTest headline $itemNumber\nTest caption $itemNumber',
),
Semantics(
button: true,
enabled: true,
label: 'Test button',
child: const SizedBox(height: 30.0, width: 100.0),
),
],
),
);
},
),
),
),
);
// Verify all 3 sponsor items are present in semantics tree
expect(find.text('Test sponsor 1\nTest headline 1\nTest caption 1'), findsOneWidget);
expect(find.text('Test sponsor 2\nTest headline 2\nTest caption 2'), findsOneWidget);
expect(find.text('Test sponsor 3\nTest headline 3\nTest caption 3'), findsOneWidget);
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
expect(
tester.getSemantics(find.bySemanticsLabel('More info').first),
matchesSemantics(
isButton: true,
hasEnabledState: true,
isEnabled: true,
label: 'More info',
),
);
semanticsHandle.dispose();
controller.dispose();
},
);
});
}| if (_accessibleNavigation && physics.allowUserScrolling) { | ||
| physics = ClampingScrollPhysics(parent: physics); | ||
| } | ||
| _physics = physics; |
There was a problem hiding this comment.
Wrapping physics with ClampingScrollPhysics here causes some regressions:
ClampingScrollPhysics.createBallisticSimulationoverridescreateBallisticSimulationwithout callingparent?.createBallisticSimulation. This breaks snapping forPageView(PageScrollPhysics) andListWheelScrollView(FixedExtentScrollPhysics), causing them to stop at arbitrary non-snapped offsets on flings.ClampingScrollPhysics.applyBoundaryConditionsblocks overscroll, which breaks overscroll gestures and altersshouldAcceptUserOffset(position).- This mutates the runtime physics for all user gestures and layout computations during accessible navigation, rather than scoping the clamp specifically to synthetic semantic scroll actions (
SemanticsAction.scrollUp/scrollDown).
Instead of mutating _physics on ScrollableState, boundary clamping should be handled where semantic actions are processed (e.g. in _RenderSemanticsGestureHandler or _RenderScrollSemantics).
…ate instead of wrapping the scrollable's physics
Piinks
left a comment
There was a problem hiding this comment.
This LGTM, nice re-work. Let's see what Google Testing has to show again. :)
…lve page_view_test.dart conflict by keeping both new test blocks
|
I think autosubmit is quietly failing here due to conflicts. This will need a rebase. I'll file an issue. |
|
Oh actually the bot should have complained and removed autosubmit since my approval was dismissed |
|
HI @Piinks , for some reason the |
|
autosubmit label was removed for flutter/flutter/188788, because - The status or check suite Google testing has failed. Please fix the issues identified (or deflake) before re-applying this label. |
Piinks
left a comment
There was a problem hiding this comment.
Thanks for reworking this @mbcorona! I took a closer look into why Google testing is still failing on the new _ScrollableSemanticsGestureDelegate approach, and found a few regressions caused by pre-clamping DragUpdateDetails at the RawGestureDetector / SemanticsGestureDelegate layer before the drag reaches ScrollPosition
It would seem our test coverage in this area is lacking, so I created some test cases to add and illustrate the issues.
Test cases
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'widgets/semantics_tester.dart';
void main() {
group('PR 188788 Review & Regression Verification Tests', () {
testWidgets(
'REGRESSION 1: NestedScrollView SemanticsAction.scrollUp is clamped to outerPosition.maxScrollExtent instead of scrolling inner ListView',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final outerKey = GlobalKey<NestedScrollViewState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: NestedScrollView(
key: outerKey,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return const <Widget>[
SliverToBoxAdapter(
child: SizedBox(height: 200.0, child: Text('Collapsible Header')),
),
];
},
body: ListView.builder(
itemCount: 50,
itemExtent: 50.0,
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
),
),
),
),
);
final ScrollController outerController = outerKey.currentState!.outerController;
final ScrollController innerController = outerKey.currentState!.innerController;
expect(outerController.offset, 0.0);
expect(innerController.offset, 0.0);
// In NestedScrollView, only the outer _NestedScrollPosition calls context.setCanDrag(true),
// so all semantic scroll actions on NestedScrollView are handled by the outer Scrollable's
// _ScrollableSemanticsGestureDelegate.
final int nestedScrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollToOffset],
)
.single
.id;
// Dispatch SemanticsAction.scrollUp (480px) on NestedScrollView:
// On master (before PR 188788), _NestedScrollCoordinator distributes the 480px drag across
// outerPosition (0.0 -> 200.0, collapsing the header sliver) AND innerPosition (0.0 -> 280.0).
// With PR 188788, the outer _ScrollableSemanticsGestureDelegate clamps the 480px drag to
// outerPosition.maxScrollExtent (200.0), so innerController receives 0.0 and cannot scroll!
tester.binding.pipelineOwner.semanticsOwner!.performAction(
nestedScrollableId,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(outerController.offset, 200.0);
expect(
innerController.offset,
280.0,
reason:
'Regression in PR 188788: _ScrollableSemanticsGestureDelegate clamped the 480px scrollUp '
'to outerPosition.maxScrollExtent (200.0), preventing innerController from scrolling the remaining 280px.',
);
semantics.dispose();
},
);
testWidgets(
'REGRESSION 2: NestedScrollView SemanticsAction.scrollDown is clamped to outerPosition.pixels instead of scrolling inner ListView and expanding floating header',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final outerKey = GlobalKey<NestedScrollViewState>();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: NestedScrollView(
key: outerKey,
floatHeaderSlivers: true,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverPersistentHeader(
floating: true,
delegate: _TestFloatingHeaderDelegate(extent: 200.0),
),
];
},
body: ListView.builder(
itemCount: 50,
itemExtent: 50.0,
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
),
),
),
),
);
final ScrollController outerController = outerKey.currentState!.outerController;
final ScrollController innerController = outerKey.currentState!.innerController;
// Scroll up 500px by drag: 200px collapses the outer floating header, 300px scrolls the inner ListView.
await tester.drag(find.byType(NestedScrollView), const Offset(0.0, -500.0));
await tester.pumpAndSettle();
expect(outerController.offset, 200.0);
expect(innerController.offset, 300.0);
// Drag down 190px so the floating header is almost fully revealed (outerController.offset == 10.0),
// while innerController.offset is still at 300.0.
await tester.drag(find.byType(NestedScrollView), const Offset(0.0, 190.0));
await tester.pumpAndSettle();
expect(outerController.offset, 10.0);
expect(innerController.offset, 300.0);
final int nestedScrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
// Perform SemanticsAction.scrollDown (which should scroll 480px down: 10px finishing the floating header
// and 300px scrolling the inner ListView back to 0.0).
tester.binding.pipelineOwner.semanticsOwner!.performAction(
nestedScrollableId,
SemanticsAction.scrollDown,
);
await tester.pumpAndSettle();
expect(outerController.offset, 0.0);
expect(
innerController.offset,
0.0,
reason:
'Regression in PR 188788: _ScrollableSemanticsGestureDelegate clamped the 480px downward scroll '
'to outerPosition.pixels (10.0), leaving innerController permanently stuck at 300.0 instead of 0.0.',
);
semantics.dispose();
},
);
testWidgets(
'REGRESSION 3: ClampingScrollPhysics no longer dispatches OverscrollNotification on semantic scroll at boundary',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final controller = ScrollController(initialScrollOffset: 190.0);
addTearDown(controller.dispose);
final overscrolls = <OverscrollNotification>[];
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: NotificationListener<OverscrollNotification>(
onNotification: (OverscrollNotification notification) {
overscrolls.add(notification);
return false;
},
child: ListView(
controller: controller,
physics: const ClampingScrollPhysics(),
children: List<Widget>.generate(
20,
(int i) => SizedBox(height: 40.0, child: Text('$i')),
),
),
),
),
);
final int scrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(
scrollableId,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(controller.offset, 200.0);
expect(
overscrolls,
isNotEmpty,
reason:
'Regression in PR 188788: OverscrollNotification was suppressed on ClampingScrollPhysics '
'because the drag delta was pre-clamped before ScrollPosition.setPixels.',
);
semantics.dispose();
},
);
testWidgets(
'ISSUE 4: TwoDimensionalScrollable with DiagonalDragBehavior.free fails to clamp horizontal semantic scrolls',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final horizontalController = ScrollController(initialScrollOffset: 190.0);
final verticalController = ScrollController();
addTearDown(horizontalController.dispose);
addTearDown(verticalController.dispose);
var maxHorizontalObserved = 190.0;
horizontalController.addListener(() {
if (horizontalController.offset > maxHorizontalObserved) {
maxHorizontalObserved = horizontalController.offset;
}
});
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: TwoDimensionalScrollable(
diagonalDragBehavior: DiagonalDragBehavior.free,
horizontalDetails: ScrollableDetails.horizontal(
controller: horizontalController,
physics: const BouncingScrollPhysics(),
),
verticalDetails: ScrollableDetails.vertical(
controller: verticalController,
physics: const BouncingScrollPhysics(),
),
viewportBuilder:
(BuildContext context, ViewportOffset vertical, ViewportOffset horizontal) {
return _SimpleTwoDimensionalViewport(
verticalOffset: vertical,
verticalAxisDirection: AxisDirection.down,
horizontalOffset: horizontal,
horizontalAxisDirection: AxisDirection.right,
delegate: TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
return const SizedBox(width: 1000.0, height: 1000.0);
},
),
mainAxis: Axis.vertical,
);
},
),
),
),
);
expect(horizontalController.position.maxScrollExtent, 200.0);
expect(horizontalController.offset, 190.0);
final RawGestureDetectorState outerGestureDetector = tester.state<RawGestureDetectorState>(
find.byType(RawGestureDetector).first,
);
outerGestureDetector.replaceSemanticsActions(<SemanticsAction>{
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollLeft,
SemanticsAction.scrollRight,
});
await tester.pump();
final int panScrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollLeft,
SemanticsAction.scrollRight,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(
panScrollableId,
SemanticsAction.scrollLeft,
);
await tester.pump();
expect(
maxHorizontalObserved,
lessThanOrEqualTo(200.0),
reason:
'Bug in PR 188788: Horizontal semantic scroll on TwoDimensionalScrollable with DiagonalDragBehavior.free '
'overscrolled past 200.0 because dragAxis (Axis.horizontal) != state.widget.axis (Axis.vertical).',
);
await tester.pumpAndSettle();
semantics.dispose();
},
);
testWidgets(
'ISSUE 5: Variable-extent ListView.builder with BouncingScrollPhysics still overscrolls or truncates semantic scroll',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: ListView.builder(
controller: controller,
cacheExtent: 0.0,
physics: const BouncingScrollPhysics(),
itemCount: 9,
itemBuilder: (BuildContext context, int index) {
return SizedBox(height: index < 8 ? 80.0 : 800.0, child: Text('Item $index'));
},
),
),
),
);
expect(controller.position.maxScrollExtent, 120.0);
final int scrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollToOffset],
)
.single
.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(
scrollableId,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(
controller.offset,
480.0,
reason:
'Bug in PR 188788: Semantic scroll of 480px was prematurely truncated to 120px because '
'_ScrollableSemanticsGestureDelegate clamped against the pre-layout estimated maxScrollExtent.',
);
semantics.dispose();
},
);
testWidgets(
'ISSUE 6: Out-of-range ScrollPosition reverses semantic drag direction when clamped',
(WidgetTester tester) async {
final semantics = SemanticsTester(tester);
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: ListView(
controller: controller,
physics: const BouncingScrollPhysics(),
children: List<Widget>.generate(
20,
(int i) => SizedBox(height: 40.0, child: Text('$i')),
),
),
),
),
);
controller.jumpTo(100.0);
await tester.pump();
final int scrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
final TestGesture gesture = await tester.startGesture(const Offset(400.0, 300.0));
await gesture.moveBy(const Offset(0.0, 160.0));
await gesture.up();
final double overscrolledOffset = controller.offset;
expect(overscrolledOffset, lessThan(0.0));
var updatedDelta = 0.0;
controller.addListener(() {
updatedDelta = controller.offset - overscrolledOffset;
});
tester.binding.pipelineOwner.semanticsOwner!.performAction(
scrollableId,
SemanticsAction.scrollDown,
);
expect(
updatedDelta,
lessThanOrEqualTo(0.0),
reason:
'Bug in PR 188788: When position.pixels is < minScrollExtent, SemanticsAction.scrollDown '
'computed a positive clampedPixelDelta (+15.6px), scrolling UP instead of clamping/ignoring the downward scroll.',
);
await tester.pumpAndSettle();
semantics.dispose();
},
);
});
}
class _SimpleTwoDimensionalViewport extends TwoDimensionalViewport {
const _SimpleTwoDimensionalViewport({
required super.verticalOffset,
required super.verticalAxisDirection,
required super.horizontalOffset,
required super.horizontalAxisDirection,
required super.delegate,
required super.mainAxis,
});
@override
RenderTwoDimensionalViewport createRenderObject(BuildContext context) {
return _RenderSimpleTwoDimensionalViewport(
horizontalOffset: horizontalOffset,
horizontalAxisDirection: horizontalAxisDirection,
verticalOffset: verticalOffset,
verticalAxisDirection: verticalAxisDirection,
mainAxis: mainAxis,
delegate: delegate as TwoDimensionalChildBuilderDelegate,
childManager: context as TwoDimensionalChildManager,
);
}
}
class _RenderSimpleTwoDimensionalViewport extends RenderTwoDimensionalViewport {
_RenderSimpleTwoDimensionalViewport({
required super.horizontalOffset,
required super.horizontalAxisDirection,
required super.verticalOffset,
required super.verticalAxisDirection,
required super.mainAxis,
required TwoDimensionalChildBuilderDelegate super.delegate,
required super.childManager,
});
@override
void layoutChildSequence() {
final RenderBox child = buildOrObtainChildFor(const ChildVicinity(xIndex: 0, yIndex: 0))!;
child.layout(const BoxConstraints(), parentUsesSize: true);
parentDataOf(child).layoutOffset = Offset(-horizontalOffset.pixels, -verticalOffset.pixels);
verticalOffset.applyContentDimensions(
0.0,
(child.size.height - viewportDimension.height).clamp(0.0, double.infinity),
);
horizontalOffset.applyContentDimensions(
0.0,
(child.size.width - viewportDimension.width).clamp(0.0, double.infinity),
);
}
}
class _TestFloatingHeaderDelegate extends SliverPersistentHeaderDelegate {
_TestFloatingHeaderDelegate({required this.extent});
final double extent;
@override
double get minExtent => extent;
@override
double get maxExtent => extent;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox(height: extent, child: const Text('Floating Header'));
}
@override
bool shouldRebuild(covariant _TestFloatingHeaderDelegate oldDelegate) {
return oldDelegate.extent != extent;
}
}
…e-clamping DragUpdateDetails
|
Thanks @Piinks! I reworked the approach. The synthesized drag now goes through the normal drag pipeline unmodified, and the clamping happens in ScrollPosition.setPixels after applyBoundaryConditions, behind a new semanticsScrollInProgress flag that Scrollable sets while replaying the drag. So nested distribution, physics and overscroll notifications behave the same as real drags, but no position moves further out of range. Your tests 1, 2, 3, 4 and 6 now pass. Test 5 conflicts with test 4: at dispatch time only the estimated maxScrollExtent is known, so the scroll stops at the known boundary without bouncing, and the next scroll action continues after layout extends the estimate. Is that behavior acceptable? Also, where would you like your test cases added? I was thinking scrollable_semantics_test.dart, with the NestedScrollView ones in nested_scroll_view_test.dart. |
During accessible navigation (when a screen reader is active), semantic scroll actions synthesize a fixed-size drag derived from the viewport size. With
BouncingScrollPhysics, that synthesized drag bounces past the edge, so a screen reader user scrolling near a boundary overshoots into the overscroll region instead of landing on the edge.This clamps semantic scroll actions to the scrollable's bounds while accessible navigation is active by wrapping the resolved physics in
ClampingScrollPhysics. The wrap is skipped when the physics already block user scrolling (e.g.NeverScrollableScrollPhysics), so a non-scrollable view is never made scrollable.Fixes #11665
Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.