2026-08-24 01:49:43 +02:00
|
|
|
import 'package:flutter/gestures.dart';
|
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
|
|
2026-09-02 22:02:41 +02:00
|
|
|
import 'semantics_tree_gate.dart';
|
|
|
|
|
|
2026-08-24 01:49:43 +02:00
|
|
|
/// Collapses a diagonal scroll signal onto the axis the viewer actually moved.
|
|
|
|
|
///
|
|
|
|
|
/// A wheel, trackpoint, trackball or button-scroll event expresses one-axis
|
|
|
|
|
/// intent, but the host can report both axes in the same event: on Linux only
|
|
|
|
|
/// touchpad-sourced scrolling becomes a pan/zoom sequence (which resolves in the
|
|
|
|
|
/// gesture arena), while wheel/continuous/tilt sources arrive as a single
|
|
|
|
|
/// [PointerScrollEvent] carrying whatever `dx` the device produced. A trackball
|
|
|
|
|
/// or libinput button-scrolling maps pointer motion straight onto both axes, so
|
|
|
|
|
/// `dx` is essentially never zero there.
|
|
|
|
|
///
|
|
|
|
|
/// That matters because [Scrollable] claims a scroll signal as soon as the delta
|
|
|
|
|
/// along *its own* axis is non-zero, and the deepest claimant wins the
|
|
|
|
|
/// [PointerSignalResolver]. One pixel of `dx` therefore hands the whole event to
|
|
|
|
|
/// a horizontal hub row and the page behind it never scrolls — the Home,
|
|
|
|
|
/// library Recommended and media-detail freeze reported in #2081, on exactly the
|
|
|
|
|
/// screens whose viewport is covered by horizontal rows.
|
|
|
|
|
///
|
|
|
|
|
/// Returns [event] unchanged when it is already single-axis, so the common wheel
|
|
|
|
|
/// path allocates nothing.
|
|
|
|
|
PointerScrollEvent lockScrollSignalToDominantAxis(PointerScrollEvent event) {
|
|
|
|
|
final Offset delta = event.scrollDelta;
|
|
|
|
|
if (delta.dx == 0.0 || delta.dy == 0.0) return event;
|
|
|
|
|
// Ties go to the vertical axis: pages scroll vertically, rows are the nested
|
|
|
|
|
// exception, so an ambiguous event should reach the page.
|
|
|
|
|
final Offset dominant = delta.dx.abs() > delta.dy.abs() ? Offset(delta.dx, 0.0) : Offset(0.0, delta.dy);
|
|
|
|
|
return PointerScrollEvent(
|
|
|
|
|
viewId: event.viewId,
|
|
|
|
|
timeStamp: event.timeStamp,
|
|
|
|
|
kind: event.kind,
|
|
|
|
|
device: event.device,
|
|
|
|
|
position: event.position,
|
|
|
|
|
scrollDelta: dominant,
|
|
|
|
|
embedderId: event.embedderId,
|
|
|
|
|
onRespond: event.respond,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Applies [lockScrollSignalToDominantAxis] to every scroll signal before it is
|
|
|
|
|
/// hit-tested, which is the only place the delta can still be corrected: by the
|
|
|
|
|
/// time a [Scrollable] sees the event it has already registered with the
|
|
|
|
|
/// [PointerSignalResolver], and only the first (deepest) registration runs.
|
|
|
|
|
///
|
|
|
|
|
/// Pan/zoom events are left alone — trackpad panning is resolved by the gesture
|
|
|
|
|
/// arena, which already prefers the axis the viewer moved along.
|
|
|
|
|
mixin PointerScrollAxisLock on GestureBinding {
|
|
|
|
|
@override
|
|
|
|
|
void handlePointerEvent(PointerEvent event) {
|
|
|
|
|
super.handlePointerEvent(event is PointerScrollEvent ? lockScrollSignalToDominantAxis(event) : event);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 22:02:41 +02:00
|
|
|
/// The app's binding: installs [PointerScrollAxisLock] and [SemanticsTreeGate].
|
|
|
|
|
class PlezyWidgetsBinding extends WidgetsFlutterBinding with PointerScrollAxisLock, SemanticsTreeGate {
|
2026-08-24 01:49:43 +02:00
|
|
|
static bool _initialized = false;
|
|
|
|
|
|
|
|
|
|
/// Creates the binding on first call and returns it afterwards, mirroring
|
|
|
|
|
/// [WidgetsFlutterBinding.ensureInitialized].
|
|
|
|
|
static WidgetsBinding ensureInitialized() {
|
|
|
|
|
if (!_initialized) {
|
|
|
|
|
PlezyWidgetsBinding();
|
|
|
|
|
_initialized = true;
|
|
|
|
|
}
|
|
|
|
|
return WidgetsBinding.instance;
|
|
|
|
|
}
|
|
|
|
|
}
|