Skip to content

[framework] Allow nested perpendicular scrollables to both scroll from a diagonal pointer signal#186638

Closed
xlash5 wants to merge 2 commits into
flutter:masterfrom
xlash5:fix/diagonal-scroll-nested
Closed

[framework] Allow nested perpendicular scrollables to both scroll from a diagonal pointer signal#186638
xlash5 wants to merge 2 commits into
flutter:masterfrom
xlash5:fix/diagonal-scroll-nested

Conversation

@xlash5

@xlash5 xlash5 commented May 17, 2026

Copy link
Copy Markdown

Closes #186634

Problem

When a horizontal scrollable (e.g. \CarouselView, \PageView, horizontal \ListView) is placed inside a vertical scrollable, trackpad two-finger scroll events at a diagonal angle cause the horizontal scrollable to capture the entire event — the vertical component intended for the outer scrollable is lost.

Root Cause

\PointerSignalResolver\ used a first-registered-wins model with a single callback slot. The deepest scrollable registered first and called
espond(false), consuming the entire \PointerScrollEvent\ delta — including the perpendicular component meant for the outer scrollable.

Fix

Three changes, all backwards-compatible:

1. \PointerSignalResolver\ — support multiple key-deduplicated registrations

  • Changed from a single callback (_firstRegisteredCallback) to a list
  • Added optional \Object? key\ parameter to
    egister(): only one registration per key is kept

  • esolve()\ now calls all registered callbacks in registration order, then calls
    espond(false)\ once

2. \Scrollable._receivedPointerSignal\ — pass axis as registration key

  • Passes \widget.axis\ to
    esolver.register()\
  • _handlePointerScroll\ no longer calls \event.respond()\ — the resolver handles it

3. \Scrollbar._receivedPointerSignal\ — pass axis as registration key

  • Passes \position.axis\ to
    esolver.register(), preventing double-scroll with the backing \Scrollable\

Testing

  • All existing tests pass unchanged
  • Added 5 new widget tests covering:
    • Diagonal scroll where both axes can move
    • Diagonal scroll with mostly-vertical component
    • Horizontal-at-edge still defers to vertical
    • Single-axis delta still handled by innermost
  • Added 5 new resolver unit tests for multi-key registration behavior

Migration

No migration needed. The change is purely additive:

  • Existing callers of
    egister(event, callback)\ without a key continue to work identically (first-registration-wins for keyless callbacks)
  • New \key\ parameter is optional

… a diagonal pointer signal

When a horizontal scrollable (e.g. CarouselView, PageView) is nested inside
a vertical scrollable (e.g. ListView), a two-finger trackpad diagonal scroll
event was consumed entirely by the deepest scrollable, preventing the outer
scrollable from receiving its perpendicular axis component.

Root cause: PointerSignalResolver used a first-registered-wins model with a
single callback slot. The deepest scrollable would register first and call
respond(false), consuming the entire event delta including the perpendicular
component intended for the outer scrollable.

Fix:
- PointerSignalResolver now supports multiple registrations, deduplicated by
  an optional Object? key. Only one registration per key is kept, allowing
  perpendicular-axis scrollables to each register independently while
  same-axis widgets (e.g. scrollable + attached scrollbar) are deduplicated.
- Scrollable._receivedPointerSignal passes widget.axis as the registration key.
- Scrollable._handlePointerScroll no longer calls event.respond() — the
  resolver handles it after all callbacks have run.
- Scrollbar._receivedPointerSignal also passes position.axis as key to
  prevent double-scrolling its backing ScrollPosition.

Fixes flutter#186634
@google-cla

google-cla Bot commented May 17, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. f: scrolling Viewports, list views, slivers, etc. f: gestures flutter/packages/flutter/gestures repository. labels May 17, 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 modifies PointerSignalResolver to allow multiple callbacks per event, deduplicated by an optional key. This enables simultaneous scrolling on different axes for nested scrollables. The Scrollable and RawScrollbar widgets now provide their axis as a registration key. A review comment suggests deferring the clearing of internal state in the resolve method until after all callbacks have been executed to avoid side effects from re-registrations.

Comment on lines +134 to 161
_registrations.clear();
_currentEvent = null;
for (final _Registration reg in registrations) {
try {
reg.callback(registeredEvent);
} catch (exception, stack) {
InformationCollector? collector;
assert(() {
collector = () => <DiagnosticsNode>[
DiagnosticsProperty<PointerSignalEvent>(
'Event',
event,
style: DiagnosticsTreeStyle.errorProperty,
),
];
return true;
}());
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while resolving a PointerSignalEvent'),
informationCollector: collector,
),
];
return true;
}());
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while resolving a PointerSignalEvent'),
informationCollector: collector,
),
);
);
}
}

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

Clearing the registration state (_registrations and _currentEvent) before executing the callbacks allows any calls to register made within those callbacks to persist into the next event's resolution cycle. This could lead to unexpected behavior or assertion failures when the next event is processed. It is safer to clear the state after the loop, ensuring that any re-registrations during the current resolution are correctly discarded.

    for (final _Registration reg in registrations) {
      try {
        reg.callback(registeredEvent);
      } catch (exception, stack) {
        InformationCollector? collector;
        assert(() {
          collector = () => <DiagnosticsNode>[
            DiagnosticsProperty<PointerSignalEvent>(
              'Event',
              event,
              style: DiagnosticsTreeStyle.errorProperty,
            ),
          ];
          return true;
        }());
        FlutterError.reportError(
          FlutterErrorDetails(
            exception: exception,
            stack: stack,
            library: 'gesture library',
            context: ErrorDescription('while resolving a PointerSignalEvent'),
            informationCollector: collector,
          ),
        );
      }
    }
    _registrations.clear();
    _currentEvent = null;

@cedvdb

cedvdb commented May 17, 2026

Copy link
Copy Markdown
Contributor

@xlash5 While allowing both to scroll is better than what's currently in place, the issue specifically mention that they should NOT both scroll, but that the vertical scrollable should take priority.

Picking vertical is not arbitrary as it is the primary scroll direction.

Yet this issue title says allow BOTH.

@Piinks Piinks self-requested a review May 26, 2026 23:15

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

Hi @xlash5 thanks for contributing.
While the goal of enabling diagonal scrolling is clear, this implementation introduces some inconsistencies and conflicts with Flutter's 1D scrolling architecture.

Specifically:

  1. This uses two conflicting models—'innermost-wins' for identical keys and 'outermost-wins' for different keys. Additionally, keyless registrations bypass the filtering entirely, resulting in unpredictable behavior.
  2. Framework Alignment: 1D scrollables in Flutter are intentionally designed to be exclusive (innermost-wins) to ensure a stable, locked feel. Changing the PointerSignalResolver to allow diagonal components to leak into parent axes breaks this contract. True diagonal support is better handled by 2D scrolling APIs (like TwoDimensionalScrollable) rather than modifying the fundamental behavior of 1D lists.
  3. Docs vs. Code: The code (which uses reversed iteration and winningKey filtering) directly contradicts the PR description and doc comments, which claim that all callbacks are invoked in registration order.

Given that this fundamentally changes the expected 1D scrolling behavior and introduces a complex priority state, I don't believe we should land this as-is.

@Piinks Piinks added the waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds label Jun 2, 2026
@github-actions

Copy link
Copy Markdown

This pull request is being closed because it has not been updated in the last 21 days after a request for more information.

If you are still working on this, please feel free to reopen it or file a new pull request.

Thanks for your contribution.

@github-actions github-actions Bot closed this Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

f: gestures flutter/packages/flutter/gestures repository. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels. waiting for response The Flutter team cannot make further progress on this issue until the original reporter responds

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Horizontal Scrollable interfering with vertical scrolls

4 participants