From 686cc2539513cd396a89a70b6e8e7db4c3ffb029 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 28 Aug 2026 19:57:40 +0700 Subject: [PATCH] fix(library): prevent pinch gesture blocking scroll --- lib/screens/queue_tab.dart | 20 ++-- lib/screens/queue_tab_filter_widgets.dart | 9 +- lib/widgets/two_finger_pinch_listener.dart | 101 +++++++++++++++++++++ test/two_finger_pinch_listener_test.dart | 92 +++++++++++++++++++ 4 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 lib/widgets/two_finger_pinch_listener.dart create mode 100644 test/two_finger_pinch_listener_test.dart diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index cfdffd21..023480a5 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -58,6 +58,7 @@ import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/widgets/smoothed_progress.dart'; import 'package:spotiflac_android/widgets/scroll_edge_fade.dart'; +import 'package:spotiflac_android/widgets/two_finger_pinch_listener.dart'; part 'queue_tab_helpers.dart'; part 'queue_tab_widgets.dart'; @@ -285,7 +286,7 @@ class _QueueTabState extends ConsumerState { String _sortMode = 'latest'; String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate; double _libraryGridExtent = _libraryGridDefaultExtent; - double? _libraryGridScaleStartExtent; + double? _libraryGridPinchStartExtent; final Map _libraryPageOffsetByFilter = {}; bool _libraryPageLoadScheduled = false; final Map<_QueueLibraryCountsRequest, QueueLibraryCounts> @@ -315,16 +316,15 @@ class _QueueTabState extends ConsumerState { /// through this forwarder. void _setState(VoidCallback fn) => setState(fn); - void _handleLibraryGridScaleStart(ScaleStartDetails details) { - if (details.pointerCount < 2) return; - _libraryGridScaleStartExtent = _libraryGridExtent; + void _handleLibraryGridPinchStart() { + _libraryGridPinchStartExtent = _libraryGridExtent; } - void _handleLibraryGridScaleUpdate(ScaleUpdateDetails details) { - final startExtent = _libraryGridScaleStartExtent; - if (startExtent == null || details.pointerCount < 2) return; + void _handleLibraryGridPinchUpdate(double scale) { + final startExtent = _libraryGridPinchStartExtent; + if (startExtent == null) return; - final nextExtent = (startExtent * details.scale).clamp( + final nextExtent = (startExtent * scale).clamp( _libraryGridMinExtent, _libraryGridMaxExtent, ); @@ -332,8 +332,8 @@ class _QueueTabState extends ConsumerState { setState(() => _libraryGridExtent = nextExtent); } - void _handleLibraryGridScaleEnd(ScaleEndDetails details) { - _libraryGridScaleStartExtent = null; + void _handleLibraryGridPinchEnd() { + _libraryGridPinchStartExtent = null; } @override diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 32d566e2..d2e5a003 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -761,11 +761,10 @@ extension _QueueTabFilterWidgets on _QueueTabState { ); if (historyViewMode != 'grid') return scrollAwareContent; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onScaleStart: _handleLibraryGridScaleStart, - onScaleUpdate: _handleLibraryGridScaleUpdate, - onScaleEnd: _handleLibraryGridScaleEnd, + return TwoFingerPinchListener( + onStart: _handleLibraryGridPinchStart, + onUpdate: _handleLibraryGridPinchUpdate, + onEnd: _handleLibraryGridPinchEnd, child: scrollAwareContent, ); } diff --git a/lib/widgets/two_finger_pinch_listener.dart b/lib/widgets/two_finger_pinch_listener.dart new file mode 100644 index 00000000..a5c61d85 --- /dev/null +++ b/lib/widgets/two_finger_pinch_listener.dart @@ -0,0 +1,101 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +/// Observes a two-finger pinch without competing in Flutter's gesture arena. +/// +/// A regular [GestureDetector.onScaleUpdate] also recognizes one-finger pans. +/// When wrapped around a scrollable, it can therefore win against the +/// scrollable's drag recognizer and make a vertical scroll stop unexpectedly. +/// Raw pointer observation keeps normal one-finger scrolling untouched while +/// still exposing a scale factor once exactly two touch pointers are present. +class TwoFingerPinchListener extends StatefulWidget { + const TwoFingerPinchListener({ + super.key, + required this.child, + required this.onStart, + required this.onUpdate, + required this.onEnd, + }); + + final Widget child; + final VoidCallback onStart; + final ValueChanged onUpdate; + final VoidCallback onEnd; + + @override + State createState() => _TwoFingerPinchListenerState(); +} + +class _TwoFingerPinchListenerState extends State { + final Map _touchPositions = {}; + double? _initialSpan; + bool _pinching = false; + + void _handlePointerDown(PointerDownEvent event) { + if (event.kind != PointerDeviceKind.touch) return; + _touchPositions[event.pointer] = event.localPosition; + + if (_touchPositions.length == 2) { + _beginPinch(); + } else if (_touchPositions.length > 2) { + _endPinch(); + } + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_touchPositions.containsKey(event.pointer)) return; + _touchPositions[event.pointer] = event.localPosition; + + final initialSpan = _initialSpan; + if (!_pinching || initialSpan == null || _touchPositions.length != 2) { + return; + } + + final span = _currentSpan; + if (span <= 0) return; + widget.onUpdate(span / initialSpan); + } + + void _handlePointerEnd(PointerEvent event) { + if (_touchPositions.remove(event.pointer) == null) return; + _endPinch(); + + // If a third finger was present, resume with the two remaining pointers + // using their current distance as a fresh scale baseline. + if (_touchPositions.length == 2) { + _beginPinch(); + } + } + + double get _currentSpan { + final positions = _touchPositions.values.take(2).toList(growable: false); + if (positions.length != 2) return 0; + return (positions[0] - positions[1]).distance; + } + + void _beginPinch() { + final span = _currentSpan; + if (span <= 0) return; + _initialSpan = span; + _pinching = true; + widget.onStart(); + } + + void _endPinch() { + if (_pinching) widget.onEnd(); + _pinching = false; + _initialSpan = null; + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerEnd, + onPointerCancel: _handlePointerEnd, + child: widget.child, + ); + } +} diff --git a/test/two_finger_pinch_listener_test.dart b/test/two_finger_pinch_listener_test.dart new file mode 100644 index 00000000..61005cbd --- /dev/null +++ b/test/two_finger_pinch_listener_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/two_finger_pinch_listener.dart'; + +void main() { + Widget buildHarness({ + required ScrollController controller, + required VoidCallback onStart, + required ValueChanged onUpdate, + required VoidCallback onEnd, + }) { + return MaterialApp( + home: Scaffold( + body: TwoFingerPinchListener( + onStart: onStart, + onUpdate: onUpdate, + onEnd: onEnd, + child: ListView.builder( + controller: controller, + itemExtent: 80, + itemCount: 30, + itemBuilder: (context, index) => Text('Track $index'), + ), + ), + ), + ); + } + + testWidgets('does not compete with one-finger vertical scrolling', ( + tester, + ) async { + final controller = ScrollController(); + var pinchStarts = 0; + final scales = []; + + await tester.pumpWidget( + buildHarness( + controller: controller, + onStart: () => pinchStarts++, + onUpdate: scales.add, + onEnd: () {}, + ), + ); + + await tester.drag(find.text('Track 2'), const Offset(0, -240)); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0)); + expect(pinchStarts, 0); + expect(scales, isEmpty); + controller.dispose(); + }); + + testWidgets('reports scale only after two touch pointers are down', ( + tester, + ) async { + final controller = ScrollController(); + var pinchStarts = 0; + var pinchEnds = 0; + final scales = []; + + await tester.pumpWidget( + buildHarness( + controller: controller, + onStart: () => pinchStarts++, + onUpdate: scales.add, + onEnd: () => pinchEnds++, + ), + ); + + final first = await tester.startGesture(const Offset(150, 300), pointer: 1); + expect(pinchStarts, 0); + + final second = await tester.startGesture( + const Offset(250, 300), + pointer: 2, + ); + expect(pinchStarts, 1); + + await first.moveTo(const Offset(125, 300)); + await second.moveTo(const Offset(275, 300)); + await tester.pump(); + + expect(scales, isNotEmpty); + expect(scales.last, greaterThan(1)); + + await first.up(); + await second.up(); + expect(pinchEnds, 1); + controller.dispose(); + }); +}