fix(mobile): prevent late mouse-down after floating button release (#16541)

Signed-off-by: zhenghe <zhenghe2009@gmail.com>
This commit is contained in:
HeZheng
2026-10-14 11:26:13 +08:00
committed by GitHub
parent 49a9574f97
commit 29a160fc7e
2 changed files with 118 additions and 3 deletions
@@ -372,6 +372,8 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
Offset _position = Offset.zero;
bool _isInitialized = false;
bool _isDown = false;
Object? _activePress;
bool _remoteButtonDown = false;
Rect? _lastBlockedRect;
Orientation? _previousOrientation;
@@ -398,6 +400,11 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
@override
void dispose() {
_activePress = null;
if (_remoteButtonDown) {
_remoteButtonDown = false;
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
@@ -569,6 +576,8 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
child: Listener(
onPointerMove: _onBodyPointerMoveUpdate,
onPointerDown: (event) async {
final press = Object();
_activePress = press;
_isDragging = false;
setState(() {
_isDown = true;
@@ -579,6 +588,8 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
isSpecialHoldDragActive = true;
() async {
await _cursorModel.syncCursorPosition();
if (!mounted || !identical(_activePress, press)) return;
_remoteButtonDown = true;
await _inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
}();
@@ -586,6 +597,8 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
});
},
onPointerUp: (event) {
if (!mounted) return;
_activePress = null;
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
@@ -604,8 +617,8 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
}));
} else {
// If it's not a quick tap, it could be a hold or drag.
// If it was a hold, isSpecialHoldDragActive is true.
if (isSpecialHoldDragActive) {
if (_remoteButtonDown) {
_remoteButtonDown = false;
_inputModel
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
@@ -617,13 +630,16 @@ class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
isSpecialHoldDragActive = false;
},
onPointerCancel: (event) {
if (!mounted) return;
_activePress = null;
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
_tapDownTimer?.cancel();
_tapDownTimer = null;
if (isSpecialHoldDragActive) {
if (_remoteButtonDown) {
_remoteButtonDown = false;
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
isSpecialHoldDragActive = false;
@@ -0,0 +1,99 @@
@TestOn('browser')
import 'dart:async';
import 'dart:js' as js;
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_test/flutter_test.dart';
class _Input extends Fake implements InputModel {
final events = <(String, MouseButtons)>[];
@override
Future<void> tapDown(MouseButtons button) async =>
events.add(('down', button));
@override
Future<void> tapUp(MouseButtons button) async => events.add(('up', button));
}
class _FFI extends Fake implements FFI {}
class _Cursor extends CursorModel {
_Cursor(FFI ffi) : super(WeakReference(ffi));
final syncs = <Completer<void>>[];
@override
Future<void> syncCursorPosition() {
final sync = Completer<void>();
syncs.add(sync);
return sync.future;
}
}
void main() {
setUp(() {
isSpecialHoldDragActive = false;
js.context['isMobile'] = js.allowInterop(() => true);
js.context['getByName'] =
js.allowInterop((String name, [String? key]) => '');
js.context['setByName'] = js.allowInterop((String name, dynamic value) {});
});
tearDown(() {
isSpecialHoldDragActive = false;
for (final name in ['isMobile', 'getByName', 'setByName']) {
js.context.deleteProperty(name);
}
});
for (final end in ['release', 'cancel', 'dispose']) {
testWidgets('pending hold cannot press after $end', (tester) async {
final cancel = end == 'cancel';
final ffi = _FFI();
final input = _Input();
final cursor = _Cursor(ffi);
final button = cancel ? MouseButtons.right : MouseButtons.left;
await tester.pumpWidget(MaterialApp(
home: Stack(children: [
FloatingLeftRightButton(
isLeft: !cancel, inputModel: input, cursorModel: cursor),
]),
));
await tester.pump();
final target = find.byType(FloatingLeftRightButton);
final first = await tester.startGesture(tester.getCenter(target));
await tester.pump(const Duration(milliseconds: 201));
expect(cursor.syncs, hasLength(1));
if (end == 'dispose') {
await tester.pumpWidget(const SizedBox());
cursor.syncs.single.complete();
await tester.pump(const Duration(milliseconds: 1));
expect(input.events, isEmpty);
return;
}
if (cancel) {
await first.cancel();
} else {
await first.up();
}
// A new press must not make the old synchronization valid again.
final second = await tester.startGesture(tester.getCenter(target));
await tester.pump(const Duration(milliseconds: 201));
cursor.syncs.first.complete();
await tester.pump();
expect(input.events, isEmpty);
cursor.syncs.last.complete();
await tester.pump();
expect(input.events, [('down', button)]);
await second.up();
expect(input.events, [('down', button), ('up', button)]);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
expect(input.events, [('down', button), ('up', button)]);
});
}
}