fix(library): prevent pinch gesture blocking scroll

This commit is contained in:
zarzet
2026-08-28 19:57:40 +07:00
parent 357190757c
commit 686cc25395
4 changed files with 207 additions and 15 deletions
+10 -10
View File
@@ -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<QueueTab> {
String _sortMode = 'latest';
String _libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate;
double _libraryGridExtent = _libraryGridDefaultExtent;
double? _libraryGridScaleStartExtent;
double? _libraryGridPinchStartExtent;
final Map<String, int> _libraryPageOffsetByFilter = {};
bool _libraryPageLoadScheduled = false;
final Map<_QueueLibraryCountsRequest, QueueLibraryCounts>
@@ -315,16 +316,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
/// 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<QueueTab> {
setState(() => _libraryGridExtent = nextExtent);
}
void _handleLibraryGridScaleEnd(ScaleEndDetails details) {
_libraryGridScaleStartExtent = null;
void _handleLibraryGridPinchEnd() {
_libraryGridPinchStartExtent = null;
}
@override
+4 -5
View File
@@ -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,
);
}
+101
View File
@@ -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<double> onUpdate;
final VoidCallback onEnd;
@override
State<TwoFingerPinchListener> createState() => _TwoFingerPinchListenerState();
}
class _TwoFingerPinchListenerState extends State<TwoFingerPinchListener> {
final Map<int, Offset> _touchPositions = <int, Offset>{};
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,
);
}
}
+92
View File
@@ -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<double> 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 = <double>[];
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 = <double>[];
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();
});
}