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 |
…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. |
|
To your questions:
Also, can you update the PR description/title so the commit message describes the current ScrollPosition.semanticsScrollInProgress / _ScrollableSemanticsGestureDelegate implementation rather than the initial ClampingScrollPhysics wrapper approach? I am finishing a review here, almost done. Thanks for sticking with this! It has been a really challenging issue! |
| // for diagonal scrolling) handles both axes. | ||
| final Map<Type, GestureRecognizerFactory> recognizers = state._gestureRecognizers; | ||
| final bool canPan = recognizers.containsKey(PanGestureRecognizer); | ||
| final bool canDragHorizontally = |
There was a problem hiding this comment.
What if the Scrollable is itself horizontal?
There was a problem hiding this comment.
It looks like this is just specific to TwoDimensionalScrollable? Maybe the class name should reflect this.
| // The positions that a drag synthesized from a semantics scroll action can | ||
| // move, which stop at their bounds instead of overscrolling while it is | ||
| // replayed. Overridden where drag deltas are forwarded to another | ||
| // scrollable. | ||
| List<ScrollPosition> get _positionsMovedBySemanticsScroll => <ScrollPosition>[position]; |
There was a problem hiding this comment.
Typically a scrollable only has one position, why are multiple being tracked here?
If this is for NestedScrollView, only _outerPosition calls context.setCanDrag, so semantic scroll actions run on the outer ScrollableState where _positionsMovedBySemanticsScroll only includes _outerPosition (leaving semanticsScrollInProgress false on _innerPositions). Additionally, _NestedScrollPosition.applyFullDragUpdate (packages/flutter/lib/src/widgets/nested_scroll_view.dart lines 1316–1337) calls physics.applyBoundaryConditions and forcePixels directly instead of ScrollPosition.setPixels, bypassing the clamp in setPixels.
As a result, NestedScrollView with BouncingScrollPhysics still overscrolls at the boundaries on SemanticsAction.scrollUp and SemanticsAction.scrollDown.
Here are tests demonstrating the NestedScrollView boundary overscroll (along with the two-step variable-extent ListView.builder test for Test 5):
Tests
testWidgets(
'NestedScrollView with BouncingScrollPhysics clamps inner position on SemanticsAction.scrollUp near maxScrollExtent',
(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,
physics: const BouncingScrollPhysics(),
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return const <Widget>[
SliverToBoxAdapter(
child: SizedBox(height: 200.0, child: Text('Collapsible Header')),
),
];
},
body: ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: 16,
itemExtent: 40.0,
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
),
),
),
),
);
final ScrollController outerController = outerKey.currentState!.outerController;
final ScrollController innerController = outerKey.currentState!.innerController;
outerController.jumpTo(190.0);
await tester.pump();
expect(innerController.position.maxScrollExtent, 50.0);
var innerWasOutOfRange = false;
innerController.addListener(() {
if (innerController.position.outOfRange) {
innerWasOutOfRange = true;
}
});
final int nestedScrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(
nestedScrollableId,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(outerController.offset, 200.0);
expect(innerController.offset, 40.0);
expect(innerWasOutOfRange, isFalse);
semantics.dispose();
},
);
testWidgets(
'NestedScrollView with BouncingScrollPhysics clamps on SemanticsAction.scrollDown near minScrollExtent',
(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,
physics: const BouncingScrollPhysics(),
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return const <Widget>[
SliverToBoxAdapter(
child: SizedBox(height: 200.0, child: Text('Collapsible Header')),
),
];
},
body: ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: 20,
itemExtent: 40.0,
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
),
),
),
),
);
final ScrollController outerController = outerKey.currentState!.outerController;
final ScrollController innerController = outerKey.currentState!.innerController;
outerController.jumpTo(10.0);
await tester.pump();
double minInnerObserved = innerController.offset;
innerController.addListener(() {
if (innerController.offset < minInnerObserved) {
minInnerObserved = innerController.offset;
}
});
final int nestedScrollableId = semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(
nestedScrollableId,
SemanticsAction.scrollDown,
);
await tester.pumpAndSettle();
expect(outerController.offset, 0.0);
expect(innerController.offset, 0.0);
expect(minInnerObserved, greaterThanOrEqualTo(0.0));
semantics.dispose();
},
);
testWidgets(
'Variable-extent ListView.builder stops at estimated maxScrollExtent then continues on next 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);
tester.binding.pipelineOwner.semanticsOwner!.performAction(
semantics
.nodesWith(
actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollToOffset],
)
.single
.id,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(controller.offset, 120.0);
expect(controller.position.maxScrollExtent, 840.0);
tester.binding.pipelineOwner.semanticsOwner!.performAction(
semantics
.nodesWith(
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
SemanticsAction.scrollToOffset,
],
)
.single
.id,
SemanticsAction.scrollUp,
);
await tester.pumpAndSettle();
expect(controller.offset, 600.0);
semantics.dispose();
},
);…estedScrollView inner positions
|
Thanks @Piinks! Merged master, addressed the three comments, added your test cases, and updated the title and description to describe the current approach. |
A semantic scroll action is replayed as a synthesized drag whose length is a fixed fraction of the viewport. Near the edge of the content, physics like
BouncingScrollPhysicsturn that drag into an overscroll and a bounce back, so a screen reader user overshoots instead of landing on the edge.Scrollablenow uses a_ScrollableSemanticsGestureDelegatethat replays the same drag with the newScrollPosition.semanticsScrollInProgressset. While it is set, the position does not move further out of range (inScrollPosition.setPixelsand in_NestedScrollPosition.applyFullDragUpdate), and it stays in range if the next layout changes the scroll extents. The drag still goes through the regular drag pipeline, so physics, overscroll notifications, and nested and two-dimensional scrollables behave as with pointer drags. Pointer drags are unaffected.Fixes #11665
Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.