mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 00:30:54 +02:00
fix(library): prevent pinch gesture blocking scroll
This commit is contained in:
+10
-10
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user