Files
plezy/test/utils/pointer_scroll_axis_test.dart
T
edde746 a6cca37174 fix(ui): keep pages scrollable when the wheel lands on a hub row
On Linux the Home page, a library's Recommended tab and movie/show detail
pages could not be scrolled at all, while Browse, Collections, Playlists,
search, downloads and settings scrolled normally. Those three screens are
exactly the ones whose viewport is covered by horizontal hub rows.

A wheel, trackball, trackpoint or button-scroll event expresses one-axis
intent, but the host reports both axes in the same event: on Linux only
touchpad-sourced scrolling becomes a pan/zoom sequence that resolves in the
gesture arena, and everything else arrives as one PointerScrollEvent
carrying whatever dx the device produced. A Scrollable claims a scroll
signal as soon as the delta along its own axis is non-zero, and the deepest
claimant wins the PointerSignalResolver, so a tenth of a pixel of dx handed
the whole event to the horizontal row and the page behind it never moved.

Collapse a scroll signal onto its dominant axis in the binding, before hit
testing - the last point where the delta can still be corrected, because a
Scrollable has already registered with the resolver by the time it sees the
event. Pan/zoom events are left alone, so trackpad panning keeps both axes,
and a horizontal wheel or tilt still scrolls the row it points at.

close #2081
2026-08-24 01:49:43 +02:00

117 lines
4.2 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/pointer_scroll_axis.dart';
/// The production binding's axis lock, over the standard test binding, so the
/// widget cases below exercise the same path a real wheel/trackball event takes.
class _AxisLockTestBinding extends AutomatedTestWidgetsFlutterBinding with PointerScrollAxisLock {}
void main() {
_AxisLockTestBinding();
PointerScrollEvent scrollEvent(Offset delta) => PointerScrollEvent(
viewId: 7,
timeStamp: const Duration(milliseconds: 1234),
kind: PointerDeviceKind.mouse,
device: 3,
position: const Offset(40, 60),
scrollDelta: delta,
embedderId: 9,
);
group('lockScrollSignalToDominantAxis', () {
test('keeps a single-axis signal identical', () {
final vertical = scrollEvent(const Offset(0, 53));
final horizontal = scrollEvent(const Offset(-53, 0));
final still = scrollEvent(Offset.zero);
expect(lockScrollSignalToDominantAxis(vertical), same(vertical));
expect(lockScrollSignalToDominantAxis(horizontal), same(horizontal));
expect(lockScrollSignalToDominantAxis(still), same(still));
});
test('drops the minor axis of a diagonal signal', () {
expect(lockScrollSignalToDominantAxis(scrollEvent(const Offset(1.5, 15))).scrollDelta, const Offset(0, 15));
expect(lockScrollSignalToDominantAxis(scrollEvent(const Offset(-0.1, -15))).scrollDelta, const Offset(0, -15));
expect(lockScrollSignalToDominantAxis(scrollEvent(const Offset(20, -3))).scrollDelta, const Offset(20, 0));
});
test('breaks an exact tie toward the vertical axis', () {
expect(lockScrollSignalToDominantAxis(scrollEvent(const Offset(-9, 9))).scrollDelta, const Offset(0, 9));
});
test('preserves every other field of the signal', () {
final locked = lockScrollSignalToDominantAxis(scrollEvent(const Offset(2, 30)));
expect(locked.viewId, 7);
expect(locked.timeStamp, const Duration(milliseconds: 1234));
expect(locked.kind, PointerDeviceKind.mouse);
expect(locked.device, 3);
expect(locked.position, const Offset(40, 60));
expect(locked.embedderId, 9);
});
});
group('a diagonal wheel over a nested row', () {
// The shape that froze in #2081: a vertical page whose viewport is covered
// by horizontal rows (hub sections, cast strips, season tabs).
late ScrollController pageController;
late List<ScrollController> rowControllers;
Widget buildPage() {
pageController = ScrollController();
rowControllers = List.generate(8, (_) => ScrollController());
return MaterialApp(
home: Scaffold(
body: ListView.builder(
controller: pageController,
itemCount: rowControllers.length,
itemBuilder: (context, row) => SizedBox(
height: 200,
child: ListView.builder(
controller: rowControllers[row],
scrollDirection: Axis.horizontal,
itemCount: 20,
itemBuilder: (context, index) => SizedBox(width: 120, child: Text('$row-$index')),
),
),
),
),
);
}
tearDown(() {
pageController.dispose();
for (final controller in rowControllers) {
controller.dispose();
}
});
Future<void> scrollOverRow(WidgetTester tester, Offset delta) async {
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(find.text('1-0'))));
await tester.sendEventToBinding(pointer.scroll(delta));
await tester.pump();
}
testWidgets('scrolls the page, not the row', (tester) async {
await tester.pumpWidget(buildPage());
await scrollOverRow(tester, const Offset(1.5, 120));
expect(pageController.offset, 120);
expect(rowControllers[1].offset, 0);
});
testWidgets('still scrolls the row when the wheel is horizontal', (tester) async {
await tester.pumpWidget(buildPage());
await scrollOverRow(tester, const Offset(120, 1.5));
expect(rowControllers[1].offset, 120);
expect(pageController.offset, 0);
});
});
}