Skip to content

Clamp semantic scroll actions to bounds during accessible navigation - #188788

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

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

Conversation

@mbcorona

Copy link
Copy Markdown
Member

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.

@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

@mbcorona mbcorona self-assigned this Jul 31, 2026
@Piinks Piinks assigned Piinks and unassigned mbcorona Aug 10, 2026
@Piinks

Piinks commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Rebasing to get a fresh Google testing run. I have been working with customers to unblock this.

@Piinks
Piinks force-pushed the fix-11665-semantic-scroll-overscroll branch from e4f430b to 4d48192 Compare August 12, 2026 23:21

@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 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.createBallisticSimulation returns a ClampingScrollSimulation on flings without delegating to parent.createBallisticSimulation. As a result, PageView fails 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.applyBoundaryConditions strictly rejects negative overscroll, completely disabling pull-to-refresh gestures in iOS-styled scroll views when accessible navigation is active.
  • Changing _physics alters shouldAcceptUserOffset and setCanDrag, which unregisters RawGestureDetector drag recognizers, causing child semantics action flags (e.g. tappable vs not-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;

@Piinks Piinks Aug 13, 2026 •

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.

Wrapping physics with ClampingScrollPhysics here causes some regressions:

  • ClampingScrollPhysics.createBallisticSimulation overrides createBallisticSimulation without calling parent?.createBallisticSimulation. This breaks snapping for PageView (PageScrollPhysics) and ListWheelScrollView (FixedExtentScrollPhysics), causing them to stop at arbitrary non-snapped offsets on flings.
  • ClampingScrollPhysics.applyBoundaryConditions blocks overscroll, which breaks overscroll gestures and alters shouldAcceptUserOffset(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).

@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.

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