185 lines
6.1 KiB
Dart
185 lines
6.1 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
/// 远端触摸控制层(对应 Android 端 RemoteTouchView)。
|
||
///
|
||
/// 捕获指针(触摸/鼠标)事件,将其转换为相对于控件区域的百分比坐标
|
||
/// (0.0 ~ 1.0),以便在不同分辨率的设备间换算。
|
||
///
|
||
/// - 短时间内位移很小的抬起 -> 单击(TOUCH)
|
||
/// - 否则 -> 滑动(SWIPE)
|
||
/// - 按住不动超过阈值 -> 长按(LONG_PRESS)
|
||
/// 同时捕获物理按键事件(KEY),用于外接键盘 / 遥控器场景。
|
||
///
|
||
/// 该控件完全基于 Flutter 框架实现,无需任何原生代码,可运行于
|
||
/// Android、iOS 等平台。
|
||
class RemoteTouchView extends StatefulWidget {
|
||
final void Function(double x, double y) onTouch;
|
||
final void Function(
|
||
double x1,
|
||
double y1,
|
||
double x2,
|
||
double y2,
|
||
int durationMs,
|
||
) onSwipe;
|
||
final void Function(int keyCode, int action) onKey;
|
||
final void Function(double x, double y) onLongPress;
|
||
final void Function(int action, double x, double y) onMotionEvent;
|
||
|
||
const RemoteTouchView({
|
||
super.key,
|
||
required this.onTouch,
|
||
required this.onSwipe,
|
||
required this.onKey,
|
||
required this.onLongPress,
|
||
required this.onMotionEvent,
|
||
});
|
||
|
||
@override
|
||
State<RemoteTouchView> createState() => _RemoteTouchViewState();
|
||
}
|
||
|
||
class _RemoteTouchViewState extends State<RemoteTouchView> {
|
||
Offset? _startPosition;
|
||
Offset? _currentPosition;
|
||
DateTime? _startTime;
|
||
bool _isLongPressed = false;
|
||
Timer? _longPressTimer;
|
||
|
||
// 缓存 RenderBox 信息以优化坐标换算开销
|
||
RenderBox? _cachedRenderBox;
|
||
Size? _cachedSize;
|
||
|
||
// 采样率限制:控制 ACTION_MOVE 的发送频率(例如每 16ms 发送一次,约 60fps)
|
||
int _lastMoveTimestamp = 0;
|
||
static const int _sampleIntervalMs = 16;
|
||
|
||
/// 长按触发阈值,对应 Android GestureDetector 默认的 LONG_PRESS_TIMEOUT(400ms)。
|
||
static const _longPressTimeout = Duration(milliseconds: 400);
|
||
|
||
/// 手指移动超过该像素阈值则视为滚动,取消长按(对应 Android touch slop)。
|
||
static const _touchSlop = 10.0;
|
||
|
||
double _toRelativeX(Offset local, Size size) =>
|
||
(local.dx / size.width).clamp(0.0, 1.0);
|
||
|
||
double _toRelativeY(Offset local, Size size) =>
|
||
(local.dy / size.height).clamp(0.0, 1.0);
|
||
|
||
void _cancelLongPress() {
|
||
_longPressTimer?.cancel();
|
||
_longPressTimer = null;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_cancelLongPress();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Focus(
|
||
autofocus: true,
|
||
onKeyEvent: (node, event) {
|
||
if (event is KeyDownEvent) {
|
||
widget.onKey(event.logicalKey.keyId, 0);
|
||
return KeyEventResult.handled;
|
||
} else if (event is KeyUpEvent) {
|
||
widget.onKey(event.logicalKey.keyId, 1);
|
||
return KeyEventResult.handled;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
},
|
||
child: Listener(
|
||
behavior: HitTestBehavior.opaque,
|
||
onPointerDown: (event) {
|
||
_cancelLongPress();
|
||
_isLongPressed = false;
|
||
_startPosition = event.localPosition;
|
||
_currentPosition = event.localPosition;
|
||
_startTime = DateTime.now();
|
||
_lastMoveTimestamp = 0; // 重置采样计时
|
||
|
||
// 开始触摸时缓存 RenderBox 和 Size
|
||
_cachedRenderBox = context.findRenderObject() as RenderBox?;
|
||
_cachedSize = _cachedRenderBox?.size;
|
||
|
||
if (_cachedSize != null) {
|
||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||
widget.onMotionEvent(0, rx, ry); // ACTION_DOWN
|
||
}
|
||
|
||
_longPressTimer = Timer(_longPressTimeout, () {
|
||
if (_currentPosition == null || _cachedSize == null) return;
|
||
_isLongPressed = true;
|
||
final x = _toRelativeX(_currentPosition!, _cachedSize!);
|
||
final y = _toRelativeY(_currentPosition!, _cachedSize!);
|
||
widget.onLongPress(x, y);
|
||
});
|
||
},
|
||
onPointerMove: (event) {
|
||
_currentPosition = event.localPosition;
|
||
|
||
if (_cachedSize != null) {
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
// 采样率限制逻辑
|
||
if (now - _lastMoveTimestamp >= _sampleIntervalMs) {
|
||
_lastMoveTimestamp = now;
|
||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||
widget.onMotionEvent(2, rx, ry); // ACTION_MOVE
|
||
}
|
||
}
|
||
|
||
if (_longPressTimer != null && _startPosition != null) {
|
||
if ((event.localPosition - _startPosition!).distance > _touchSlop) {
|
||
_cancelLongPress();
|
||
}
|
||
}
|
||
},
|
||
onPointerUp: (event) {
|
||
_cancelLongPress();
|
||
|
||
if (_cachedSize != null) {
|
||
final rx = _toRelativeX(event.localPosition, _cachedSize!);
|
||
final ry = _toRelativeY(event.localPosition, _cachedSize!);
|
||
widget.onMotionEvent(1, rx, ry); // ACTION_UP
|
||
|
||
if (!_isLongPressed && _startPosition != null && _startTime != null) {
|
||
final startX = _toRelativeX(_startPosition!, _cachedSize!);
|
||
final startY = _toRelativeY(_startPosition!, _cachedSize!);
|
||
final duration = DateTime.now().difference(_startTime!).inMilliseconds;
|
||
final dx = (rx - startX).abs();
|
||
final dy = (ry - startY).abs();
|
||
|
||
if (duration < 200 && dx < 0.02 && dy < 0.02) {
|
||
widget.onTouch(startX, startY);
|
||
} else {
|
||
widget.onSwipe(startX, startY, rx, ry, max(duration, 1));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清理状态
|
||
_startPosition = null;
|
||
_currentPosition = null;
|
||
_startTime = null;
|
||
_cachedRenderBox = null;
|
||
_cachedSize = null;
|
||
},
|
||
child: const ColoredBox(
|
||
color: Colors.transparent,
|
||
child: SizedBox.expand(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|