Files
VibeCoding/webrtc_controller_flutter/lib/widgets/remote_touch_view.dart

185 lines
6.1 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_TIMEOUT400ms
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(),
),
),
);
}
}