Skip to content

Stop semantic scroll actions at the scroll extents instead of overscrolling - #188788

Open
mbcorona wants to merge 9 commits into
flutter:masterfrom
mbcorona:fix-11665-semantic-scroll-overscroll
Open

mbcorona wants to merge 9 commits into
flutter:masterfrom
mbcorona:fix-11665-semantic-scroll-overscroll

Conversation

@mbcorona

@mbcorona mbcorona commented Jun 30, 2026 •

Copy link
Copy Markdown
Member

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 BouncingScrollPhysics turn that drag into an overscroll and a bounce back, so a screen reader user overshoots instead of landing on the edge.

Scrollable now uses a _ScrollableSemanticsGestureDelegate that replays the same drag with the new ScrollPosition.semanticsScrollInProgress set. While it is set, the position does not move further out of range (in ScrollPosition.setPixels and 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.

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Jun 30, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +622 to +633
_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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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)

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) f: scrolling Viewports, list views, slivers, etc. labels Jun 30, 2026
@mbcorona
mbcorona requested a review from Piinks June 30, 2026 14:26
@mbcorona
mbcorona force-pushed the fix-11665-semantic-scroll-overscroll branch from 3da1464 to 38d7f3b Compare July 1, 2026 16:30
@Piinks
Piinks force-pushed the fix-11665-semantic-scroll-overscroll branch from 38d7f3b to e0a8017 Compare July 7, 2026 19:52
@Piinks Piinks added CICD Run CI/CD and removed CICD Run CI/CD labels Jul 7, 2026
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@mbcorona

Copy link
Copy Markdown
Member Author

Hi @Piinks
Thanks for the review! Addressed all three:

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.

@Piinks

Piinks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Filed #189799 to track the top-level SemanticsTester refactor separately.

Thank you so much!

Piinks
Piinks previously approved these changes Jul 28, 2026

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Piinks
Piinks force-pushed the fix-11665-semantic-scroll-overscroll branch from 88c1643 to 2b8194b Compare July 28, 2026 19:57
@Piinks Piinks added the autosubmit Merge PR when tree becomes green via auto submit App label Jul 28, 2026
@auto-submit

auto-submit Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/188788, because Pull request flutter/flutter/188788 is not in a mergeable state.

@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Jul 29, 2026
@Piinks

Piinks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Can you resolve the merge conflicts here?

@mbcorona
mbcorona force-pushed the fix-11665-semantic-scroll-overscroll branch from 2b8194b to e4f430b Compare July 29, 2026 19:31
@mbcorona
mbcorona enabled auto-merge July 29, 2026 19:41
@mbcorona

Copy link
Copy Markdown
Member Author

@Piinks conflicts resolved.

Piinks
Piinks previously approved these changes Jul 29, 2026

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Re-LGTM!

@Piinks Piinks added the autosubmit Merge PR when tree becomes green via auto submit App label Jul 29, 2026
@auto-submit

auto-submit Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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.

@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Jul 29, 2026
@Piinks

Piinks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

I am reviewing the Google testing failures

@Piinks Piinks removed their assignment Aug 17, 2026
Piinks
Piinks previously approved these changes Aug 26, 2026

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@justinmc
justinmc requested a review from Piinks September 1, 2026 22:22
@mbcorona mbcorona added the autosubmit Merge PR when tree becomes green via auto submit App label Sep 2, 2026
@Piinks

Piinks commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

I think autosubmit is quietly failing here due to conflicts. This will need a rebase. I'll file an issue.

@Piinks

Piinks commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Oh actually the bot should have complained and removed autosubmit since my approval was dismissed

Piinks
Piinks previously approved these changes Sep 10, 2026

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mbcorona

Copy link
Copy Markdown
Member Author

HI @Piinks , for some reason the Google testing keeps failing here.

@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Sep 22, 2026
@auto-submit

auto-submit Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

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 Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
  }
}

@mbcorona

Copy link
Copy Markdown
Member Author

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.

@Piinks

Piinks commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

To your questions:

  • Test 5 (Variable-extent ListView.builder): Stopping at the pre-layout estimated maxScrollExtent (120.0) on the first semantic action and continuing on the next semantic action after layout extends maxScrollExtent (840.0) is expected and matches ClampingScrollPhysics.
  • Test Locations: Placing the NestedScrollView tests in nested_scroll_view_test.dart and the remaining tests in scrollable_semantics_test.dart is appropriate.

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 =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the Scrollable is itself horizontal?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is just specific to TwoDimensionalScrollable? Maybe the class name should reflect this.

Comment on lines +756 to +760
// 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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
      },
    );

@hannah-hyj hannah-hyj added the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Oct 28, 2026
@github-actions github-actions Bot removed the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Nov 11, 2026
@mbcorona mbcorona changed the title Clamp semantic scroll actions to bounds during accessible navigation Stop semantic scroll actions at the scroll extents instead of overscrolling Nov 13, 2026
@mbcorona

Copy link
Copy Markdown
Member Author

Thanks @Piinks! Merged master, addressed the three comments, added your test cases, and updated the title and description to describe the current approach.

@mbcorona
mbcorona requested a review from Piinks November 13, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) CICD Run CI/CD f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

showOnScreen should only scroll if new offset is within bounds

3 participants