1. 结构与接口概览
类头与关键字段(5--17 行)
scala
5: class DragMap extends PositionComponent
6: with DragCallbacks, GestureHitboxes {
7: final void Function(Vector2 delta) onDragged;
8: final void Function(Vector2 position)? onTap;
9: final ValueNotifier<bool>? isTapLocked;
10:
11: final VoidCallback? onDragStartCallback;
12: final VoidCallback? onDragEndCallback;
13:
14: final bool showGrid;
15:
16: double scaleFactor = 1.0;
17:
要点:
- 通过
with DragCallbacks, GestureHitboxes
接入 Flame 事件系统; - 对外暴露
onDragged(Vector2 delta)
、onTap(Vector2 position)
两条主回调; - 提供
onDragStartCallback
与onDragEndCallback
两个生命周期钩子; - 内部维护
scaleFactor
与轻点判定 所需的_startPosition/_startTime/_totalDistance
(见下文)。
2. 事件路径(Drag → Map)
2.1 拖拽开始:记录起点与计时
源码(64--70 行)
ini
64: void onDragStart(DragStartEvent event) {
65: _startPosition = event.localPosition;
66: _totalDistance = 0;
67: _startTime = DateTime.now();
68:
69: onDragStartCallback?.call(); // ✅ 通知外部:开始拖动
70: }
解析:
- 记录
event.localPosition
作为_startPosition
; - 清零
_totalDistance
并记录_startTime=DateTime.now()
; - 通过
onDragStartCallback?.call()
将生命周期事件抛给外部。
2.2 拖拽过程:增量位移与累计距离
源码(73--76 行)
css
73: void onDragUpdate(DragUpdateEvent event) {
74: onDragged(event.localDelta);
75: _totalDistance += event.localDelta.length;
76: }
解析:
- 直接把
event.localDelta
透传给外部onDragged(delta)
,用于相机/地图平移; - 用
localDelta.length
累加_totalDistance
,为"是否视作 Tap"提供依据。
2.3 拖拽结束:轻点判定与生命周期钩子
源码(79--87 行)
css
79: void onDragEnd(DragEndEvent event) {
80: final duration = DateTime.now().difference(_startTime);
81: if (_totalDistance < 10 && duration.inMilliseconds < 250) {
82: if (isTapLocked?.value == true) return;
83: onTap?.call(_startPosition ?? Vector2.zero());
84: }
85:
86: onDragEndCallback?.call(); // ✅ 通知外部:结束拖动
87: }
逻辑细读:
- 计算拖拽时长
duration = now - _startTime
; - 轻点判定 :若
_totalDistance < 10
且duration < 250ms
,则触发onTap(_startPosition)
; - 受
isTapLocked
保护:当外部上锁时忽略 Tap; - 结束时触发
onDragEndCallback?.call()
。
这是一种距离 + 时间的鲁棒 Tap 判定:能有效区分"短促点击"和"慢速拖拽"。阈值建议随 DPI/缩放自适应(见 §4 优化建议)。
3. 设计评价
- 解耦 :拖拽与点击分流,外部根据
onDragged
算相机偏移,自由度高; - 轻量:无多余物理/动画依赖;
- 可测性 :
_startTime/_totalDistance
使 Tap 判定可单元测试(构造时间与位移)。
4. 工程改进(可选增强)
- DPI/缩放自适应 :将
_totalDistance < 10
改为乘以devicePixelRatio
或基于世界坐标的阈值:
threshold = max(6.0, 8.0 * devicePixelRatio / max(1.0, scaleFactor))
。 - 惯性/减速(可开关) :在
onDragEnd
中,若最后 50--100ms 的速度超过阈值,按指数衰减积分位移(或用 Flame 的EffectController
/MoveEffect
实现一段 fling)。 - 边界钳制 :在外部
onDragged
里结合地图尺寸与相机视窗做 clamp,避免拖出黑边; - 长按与双击 :可在本组件内加
onLongPress
/onDoubleTap
,与 Tap 判定共享isTapLocked
; - 可视化网格 :
showGrid=true
时渲染辅助网格,便于调试移动尺度与像素对齐。
5. 测试方案
- 点击 vs 拖拽:构造 5/20/50px 位移、100/200/400ms 的组合,期望仅 (≤10px, <250ms) 触发 Tap;
- 边缘条件 :
isTapLocked=true
时,任何情况都不触发 Tap; - 大缩放 :
scaleFactor=0.5/2.0
下验证 Tap 阈值自适应(若按 §4 实现)。
6. 结论
drag_map.dart
用最小的状态机实现了"拖拽移动 + 轻点判定"的通用地图交互骨架,简单、直接、可扩展。在需要更丰富动效或物理体验时,可无缝叠加惯性/回弹与边界钳制等模块,而不会破坏既有接口。
dart
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flutter/cupertino.dart';
class DragMap extends PositionComponent
with DragCallbacks, GestureHitboxes {
final void Function(Vector2 delta) onDragged;
final void Function(Vector2 position)? onTap;
final ValueNotifier<bool>? isTapLocked;
final VoidCallback? onDragStartCallback;
final VoidCallback? onDragEndCallback;
final bool showGrid;
double scaleFactor = 1.0;
DragMap({
required this.onDragged,
this.onTap,
this.isTapLocked,
this.showGrid = false,
this.onDragStartCallback,
this.onDragEndCallback,
}) {
size = Vector2(5000, 5000); // 可交互区域范围
priority = 9999; // 保证在最上层处理拖动
debugPrint('🔥 DragMap created: hashCode=$hashCode');
}
Vector2? _startPosition;
double _totalDistance = 0;
late DateTime _startTime;
@override
Future<void> onLoad() async {
anchor = Anchor.topLeft;
}
@override
bool containsLocalPoint(Vector2 point) => true;
@override
void render(Canvas canvas) {
canvas.save();
canvas.translate(position.x, position.y);
canvas.scale(scaleFactor);
if (showGrid) {
final paint = Paint()..color = const Color(0xFF99CCFF);
const gridSize = 64.0;
for (double x = -size.x; x < size.x * 2; x += gridSize) {
for (double y = -size.y; y < size.y * 2; y += gridSize) {
canvas.drawRect(Rect.fromLTWH(x, y, gridSize - 2, gridSize - 2), paint);
}
}
}
super.render(canvas);
canvas.restore();
}
@override
void onDragStart(DragStartEvent event) {
_startPosition = event.localPosition;
_totalDistance = 0;
_startTime = DateTime.now();
onDragStartCallback?.call(); // ✅ 通知外部:开始拖动
}
@override
void onDragUpdate(DragUpdateEvent event) {
onDragged(event.localDelta);
_totalDistance += event.localDelta.length;
}
@override
void onDragEnd(DragEndEvent event) {
final duration = DateTime.now().difference(_startTime);
if (_totalDistance < 10 && duration.inMilliseconds < 250) {
if (isTapLocked?.value == true) return;
onTap?.call(_startPosition ?? Vector2.zero());
}
onDragEndCallback?.call(); // ✅ 通知外部:结束拖动
}
}