fix(android): add two-finger scrolling to avoid OEM gesture conflicts

This commit is contained in:
THARUN 2026-05-03 23:20:55 +05:30
commit d15937730c
3 changed files with 159 additions and 93 deletions

View file

@ -7,6 +7,7 @@ enum GestureState {
none,
oneFingerPan,
twoFingerScale,
twoFingerVerticalDrag,
threeFingerVerticalDrag
}
@ -32,7 +33,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
GestureScaleUpdateCallback? onTwoFingerScaleUpdate;
GestureScaleEndCallback? onTwoFingerScaleEnd;
// threeFingerVerticalDrag
// twoFingerVerticalDrag (scroll) primary scroll gesture
GestureDragStartCallback? onTwoFingerVerticalDragStart;
GestureDragUpdateCallback? onTwoFingerVerticalDragUpdate;
GestureDragEndCallback? onTwoFingerVerticalDragEnd;
// threeFingerVerticalDrag kept for devices where it isn't intercepted by OS
GestureDragStartCallback? onThreeFingerVerticalDragStart;
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate;
GestureDragEndCallback? onThreeFingerVerticalDragEnd;
@ -42,79 +48,102 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
void _init() {
debugPrint("CustomTouchGestureRecognizer init");
// onStart = (d) {};
onUpdate = (d) {
_debounceTimer?.cancel();
if (d.pointerCount == 1 && _currentState != GestureState.oneFingerPan) {
onOneFingerStartDebounce(d);
} else if (d.pointerCount == 2 &&
_currentState != GestureState.twoFingerScale) {
onTwoFingerStartDebounce(d);
} else if (d.pointerCount == 2) {
final dx = d.focalPointDelta.dx;
final dy = d.focalPointDelta.dy;
if (dy.abs() > dx.abs() * 1.5 && dy.abs() > 2) {
// Two-finger vertical drag treat as scroll
if (_currentState != GestureState.twoFingerVerticalDrag) {
_currentState = GestureState.twoFingerVerticalDrag;
final startDetails = DragStartDetails(globalPosition: d.localFocalPoint);
onTwoFingerVerticalDragStart?.call(startDetails);
// Also fire the three-finger callback for backward compatibility
onThreeFingerVerticalDragStart?.call(startDetails);
debugPrint("start twoFingerVerticalDrag (scroll)");
}
} else {
if (_currentState != GestureState.twoFingerScale) {
onTwoFingerStartDebounce(d);
}
}
} else if (d.pointerCount == 3 &&
_currentState != GestureState.threeFingerVerticalDrag) {
_currentState = GestureState.threeFingerVerticalDrag;
if (onThreeFingerVerticalDragStart != null) {
onThreeFingerVerticalDragStart!(
DragStartDetails(globalPosition: d.localFocalPoint));
}
debugPrint("start threeFingerScale");
final startDetails = DragStartDetails(globalPosition: d.localFocalPoint);
onThreeFingerVerticalDragStart?.call(startDetails);
debugPrint("start threeFingerVerticalDrag");
}
if (_currentState != GestureState.none) {
switch (_currentState) {
case GestureState.oneFingerPan:
if (onOneFingerPanUpdate != null) {
onOneFingerPanUpdate!(_getDragUpdateDetails(d));
}
onOneFingerPanUpdate?.call(_getDragUpdateDetails(d));
break;
case GestureState.twoFingerScale:
if (onTwoFingerScaleUpdate != null) {
onTwoFingerScaleUpdate!(d);
}
onTwoFingerScaleUpdate?.call(d);
break;
case GestureState.twoFingerVerticalDrag:
final update = _getDragUpdateDetails(d);
onTwoFingerVerticalDragUpdate?.call(update);
// Fire three-finger callback too for backward compatibility
onThreeFingerVerticalDragUpdate?.call(update);
break;
case GestureState.threeFingerVerticalDrag:
if (onThreeFingerVerticalDragUpdate != null) {
onThreeFingerVerticalDragUpdate!(_getDragUpdateDetails(d));
}
onThreeFingerVerticalDragUpdate?.call(_getDragUpdateDetails(d));
break;
default:
break;
}
return;
}
};
onEnd = (d) {
debugPrint("ScaleGestureRecognizer onEnd");
_debounceTimer?.cancel();
// end
switch (_currentState) {
case GestureState.oneFingerPan:
debugPrint("OneFingerState.pan onEnd");
if (onOneFingerPanEnd != null) {
onOneFingerPanEnd!(_getDragEndDetails(d));
}
onOneFingerPanEnd?.call(_getDragEndDetails(d));
break;
case GestureState.twoFingerScale:
debugPrint("TwoFingerState.scale onEnd");
if (onTwoFingerScaleEnd != null) {
onTwoFingerScaleEnd!(d);
}
onTwoFingerScaleEnd?.call(d);
if (isSpecialHoldDragActive) {
// If we are in special drag mode, we need to reset the state.
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
_currentState = GestureState.none;
return;
}
break;
case GestureState.threeFingerVerticalDrag:
debugPrint("ThreeFingerState.vertical onEnd");
if (onThreeFingerVerticalDragEnd != null) {
onThreeFingerVerticalDragEnd!(_getDragEndDetails(d));
}
case GestureState.twoFingerVerticalDrag:
debugPrint("twoFingerVerticalDrag onEnd");
final endDetails = _getDragEndDetails(d);
onTwoFingerVerticalDragEnd?.call(endDetails);
// Fire three-finger callback too for backward compatibility
onThreeFingerVerticalDragEnd?.call(endDetails);
break;
case GestureState.threeFingerVerticalDrag:
debugPrint("threeFingerVerticalDrag onEnd");
onThreeFingerVerticalDragEnd?.call(_getDragEndDetails(d));
break;
default:
break;
}
_debounceTimer = Timer(Duration(milliseconds: 200), () {
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
_currentState = GestureState.none;
});
};
@ -125,14 +154,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
void onOneFingerStartDebounce(ScaleUpdateDetails d) {
start(ScaleUpdateDetails d) {
_currentState = GestureState.oneFingerPan;
if (onOneFingerPanStart != null) {
onOneFingerPanStart!(DragStartDetails(
localPosition: d.localFocalPoint, globalPosition: d.focalPoint));
}
onOneFingerPanStart?.call(DragStartDetails(
localPosition: d.localFocalPoint, globalPosition: d.focalPoint));
}
if (_currentState != GestureState.none) {
_debounceTimer = Timer(Duration(milliseconds: 200), () {
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
start(d);
debugPrint("debounce start oneFingerPan");
});
@ -145,14 +172,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
void onTwoFingerStartDebounce(ScaleUpdateDetails d) {
start(ScaleUpdateDetails d) {
_currentState = GestureState.twoFingerScale;
if (onTwoFingerScaleStart != null) {
onTwoFingerScaleStart!(ScaleStartDetails(
localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint));
}
onTwoFingerScaleStart?.call(ScaleStartDetails(
localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint));
}
if (_currentState == GestureState.threeFingerVerticalDrag) {
_debounceTimer = Timer(Duration(milliseconds: 200), () {
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
start(d);
debugPrint("debounce start twoFingerScale");
});
@ -176,15 +201,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
super.rejectGesture(pointer);
switch (_currentState) {
case GestureState.oneFingerPan:
if (onOneFingerPanCancel != null) {
onOneFingerPanCancel!();
}
onOneFingerPanCancel?.call();
break;
case GestureState.twoFingerScale:
// Reset scale state if needed, currently self-contained
break;
case GestureState.twoFingerVerticalDrag:
case GestureState.threeFingerVerticalDrag:
// Reset drag state if needed, currently self-contained
break;
default:
break;
@ -192,7 +214,6 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
_currentState = GestureState.none;
}
}
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
HoldTapMoveGestureRecognizer({
Object? debugOwner,
@ -742,6 +763,9 @@ RawGestureDetector getMixinGestureDetector({
GestureDragCancelCallback? onOneFingerPanCancel,
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
GestureScaleEndCallback? onTwoFingerScaleEnd,
GestureDragStartCallback? onTwoFingerVerticalDragStart,
GestureDragUpdateCallback? onTwoFingerVerticalDragUpdate,
GestureDragEndCallback? onTwoFingerVerticalDragEnd,
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
}) {
return RawGestureDetector(
@ -790,6 +814,9 @@ RawGestureDetector getMixinGestureDetector({
..onOneFingerPanEnd = onOneFingerPanEnd
..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerVerticalDragStart = onTwoFingerVerticalDragStart
..onTwoFingerVerticalDragUpdate = onTwoFingerVerticalDragUpdate
..onTwoFingerVerticalDragEnd = onTwoFingerVerticalDragEnd
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
}),

View file

@ -514,7 +514,7 @@ class _RawTouchGestureDetectorRegionState
}
get onHoldDragCancel => null;
get onThreeFingerVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
get onScrollVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
? null
: (d) {
_mouseScrollIntegral += d.delta.dy / 4;
@ -527,6 +527,8 @@ class _RawTouchGestureDetectorRegionState
}
};
get onThreeFingerVerticalDragUpdate => onScrollVerticalDragUpdate;
makeGestures(BuildContext context) {
return <Type, GestureRecognizerFactory>{
// Official
@ -594,7 +596,12 @@ class _RawTouchGestureDetectorRegionState
..onTwoFingerScaleStart = onTwoFingerScaleStart
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
// Two-finger vertical drag: primary scroll for Android devices where
// the OS (e.g. MIUI on Xiaomi) intercepts three-finger gestures.
..onTwoFingerVerticalDragUpdate = onScrollVerticalDragUpdate
// Three-finger vertical drag: kept for devices where the OS does not
// intercept three-finger gestures.
..onThreeFingerVerticalDragUpdate = onScrollVerticalDragUpdate;
}),
};
}

View file

@ -5,15 +5,10 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "72.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
version: "67.0.0"
after_layout:
dependency: transitive
description:
@ -26,10 +21,10 @@ packages:
dependency: transitive
description:
name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
version: "6.4.1"
animations:
dependency: transitive
description:
@ -202,10 +197,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
charcode:
dependency: transitive
description:
@ -234,10 +229,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@ -250,10 +245,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.1"
contextmenu:
dependency: "direct main"
description:
@ -298,10 +293,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
url: "https://pub.dev"
source: hosted
version: "2.3.7"
version: "2.3.6"
dash_chat_2:
dependency: "direct main"
description:
@ -417,6 +412,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: "direct main"
description:
@ -644,6 +647,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@ -653,10 +661,10 @@ packages:
dependency: "direct dev"
description:
name: freezed
sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e"
sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1
url: "https://pub.dev"
source: hosted
version: "2.5.7"
version: "2.5.2"
freezed_annotation:
dependency: "direct main"
description:
@ -849,6 +857,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
@ -865,38 +897,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.18.2"
mime:
dependency: transitive
description:
@ -957,10 +981,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_parsing:
dependency: transitive
description:
@ -1205,7 +1229,7 @@ packages:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
version: "0.0.0"
source_gen:
dependency: transitive
description:
@ -1290,10 +1314,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.11"
texture_rgba_renderer:
dependency: "direct main"
description:
@ -1473,13 +1497,13 @@ packages:
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
dependency: "direct main"
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: "47a1b32ee755c3fcffa33db52a7258c137f97bdb2209a1075be847809fac4ccf"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.3.0"
video_player:
dependency: transitive
description:
@ -1528,6 +1552,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.0+2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
wakelock_plus:
dependency: "direct main"
description:
@ -1659,5 +1691,5 @@ packages:
source: hosted
version: "0.2.4"
sdks:
dart: ">=3.5.0 <4.0.0"
dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.24.0"