手势是移动端交互的核心。Flutter 提供了从低层级的 Listener 到高层级的 GestureDetector 一整套手势识别体系,并通过手势竞技场(Gesture Arena)解决手势冲突。
一、手势识别层级
Layer 3(高层):GestureDetector / InkWell / Dismissible
↓ 封装了各种 Recognizer
Layer 2(中层):GestureRecognizer 系列
↓ 基于 PointerEvent 识别手势语义
Layer 1(低层):Listener / RawGestureDetector
↓ 直接监听原始指针事件
硬件层:PointerDownEvent / PointerMoveEvent / PointerUpEvent
二、GestureDetector
2.1 基本手势
dart
GestureDetector(
// ========= 点击类 =========
onTap: () => print('单击'),
onDoubleTap: () => print('双击'),
onLongPress: () => print('长按'),
onLongPressStart: (details) => print('长按开始: ${details.globalPosition}'),
onLongPressEnd: (details) => print('长按结束'),
// ========= 拖拽类 =========
onPanStart: (details) => print('拖拽开始'),
onPanUpdate: (details) {
setState(() {
_offset += details.delta; // 跟随手指移动
});
},
onPanEnd: (details) => print('拖拽结束,速度: ${details.velocity}'),
// ========= 缩放类(双指缩放 / 旋转)=========
onScaleStart: (details) {
_baseScale = _currentScale;
_baseAngle = _currentAngle;
},
onScaleUpdate: (details) {
setState(() {
_currentScale = _baseScale * details.scale;
_currentAngle = _baseAngle + details.rotation;
});
},
// 子 Widget
child: Transform(
transform: Matrix4.identity()
..translate(_offset.dx, _offset.dy)
..scale(_currentScale)
..rotateZ(_currentAngle),
alignment: Alignment.center,
child: Image.asset('assets/photo.jpg'),
),
)
2.2 垂直 / 水平拖拽
dart
// 仅监听垂直方向拖拽
GestureDetector(
onVerticalDragUpdate: (details) {
setState(() => _offsetY += details.delta.dy);
},
child: child,
)
// 仅监听水平方向拖拽
GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() => _offsetX += details.delta.dx);
},
child: child,
)
// 注意:onPanUpdate 与 onVerticalDrag / onHorizontalDrag 不能同时使用!
// Pan = 垂直 + 水平 的联合手势
三、手势竞技场(Gesture Arena)
当多个 Widget 同时识别到手势时,Flutter 使用手势竞技场来决定谁赢得手势:
手指按下 → 多个 GestureRecognizer 注册到 Arena
竞争规则:
① 任何 Recognizer 可以直接"声明胜利"(如 TapRecognizer 在手指抬起时)
② 如果只剩一个 Recognizer,自动胜出
③ 超时后,第一个注册的 Recognizer 胜出
常见冲突场景:
ListView(ScrollGesture)↔ GestureDetector(Tap)
→ 短按不动 = Tap 胜出
→ 滑动 = ScrollGesture 胜出
3.1 解决手势冲突
dart
// 问题:外部 GestureDetector 吞掉了内部的 onTap
GestureDetector(
onTap: () => print('外部'),
child: GestureDetector(
onTap: () => print('内部'), // 可以正常接收
child: child,
),
)
// → onTap 不冲突,因为子节点的 HitTest 先命中
// 问题:onPanUpdate 与 ListView 滚动冲突
// 解决:使用 RawGestureDetector + 自定义竞争策略
class AlwaysWinPanRecognizer extends PanGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer); // 强制赢得竞争
}
}
RawGestureDetector(
gestures: {
AlwaysWinPanRecognizer:
GestureRecognizerFactoryWithHandlers<AlwaysWinPanRecognizer>(
() => AlwaysWinPanRecognizer(),
(instance) {
instance.onUpdate = (details) { /* 拖拽逻辑 */ };
},
),
},
child: child,
)
四、Listener(底层指针事件)
dart
Listener(
onPointerDown: (event) => print('手指按下: ${event.position}'),
onPointerMove: (event) => print('手指移动: ${event.delta}'),
onPointerUp: (event) => print('手指抬起'),
onPointerCancel: (event) => print('取消'),
onPointerHover: (event) => print('悬停(鼠标/触控笔)'),
onPointerSignal: (event) {
// 鼠标滚轮事件(桌面/Web)
if (event is PointerScrollEvent) {
print('滚轮: ${event.scrollDelta}');
}
},
child: child,
)
五、HitTest(命中测试)
手指按下 → HitTest 从 RenderObject 树根节点开始
↓ 递归调用每个 RenderBox.hitTest()
↓ 收集所有命中的 Widget(HitTestResult 列表)
↓ 从内到外分发 PointerEvent
自定义 HitTest 区域:
dart
// 忽略 Widget(事件穿透)
IgnorePointer(
ignoring: true,
child: OverlayWidget(), // 该 Widget 不响应触摸
)
// 吸收事件(不穿透到下方)
AbsorbPointer(
absorbing: true,
child: child, // 下方 Widget 不接收事件
)
// 扩大触摸区域
Padding(
padding: const EdgeInsets.all(12), // 视觉不变,但可触摸区域增大
child: GestureDetector(
behavior: HitTestBehavior.opaque, // 让透明区域也响应
onTap: () {},
child: Icon(Icons.close, size: 24),
),
)
HitTestBehavior
dart
GestureDetector(
behavior: HitTestBehavior.deferToChild, // 默认:仅子 Widget 区域响应
// behavior: HitTestBehavior.opaque, // 自身整个区域都响应(不透传)
// behavior: HitTestBehavior.translucent,// 自身整个区域响应(但透传给下方)
child: child,
)
六、拖拽排序(Drag & Drop)
6.1 内置排序组件
dart
ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
},
children: _items.map((item) => ListTile(
key: ValueKey(item.id), // 必须有 Key
title: Text(item.name),
trailing: ReorderableDragStartListener(
index: _items.indexOf(item),
child: const Icon(Icons.drag_handle),
),
)).toList(),
)
6.2 Draggable + DragTarget
dart
// 可拖拽的 Widget
Draggable<Product>(
data: product, // 携带数据
feedback: Material( // 拖拽时跟随手指的 Widget
elevation: 8,
child: ProductCard(product: product),
),
childWhenDragging: Opacity( // 原位置的占位 Widget
opacity: 0.3,
child: ProductCard(product: product),
),
child: ProductCard(product: product),
)
// 拖拽目标区域
DragTarget<Product>(
onWillAcceptWithDetails: (details) => true, // 是否接受此拖拽
onAcceptWithDetails: (details) {
setState(() => _cart.add(details.data));
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isHovering ? Colors.blue.withOpacity(0.2) : Colors.grey[100],
border: Border.all(
color: isHovering ? Colors.blue : Colors.grey,
width: 2,
),
),
child: const Center(child: Text('拖拽商品到此处')),
);
},
)
6.3 LongPressDraggable
dart
// 长按后才能拖拽(更自然的移动端体验)
LongPressDraggable<String>(
data: 'hello',
delay: const Duration(milliseconds: 300), // 长按触发时间
hapticFeedbackOnStart: true, // 触觉反馈
feedback: child,
child: child,
)
七、桌面 / Web 交互适配
dart
// 鼠标光标
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: Matrix4.identity()..scale(_isHovering ? 1.05 : 1.0),
child: Card(child: content),
),
)
// 键盘快捷键
Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
const SaveIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ):
const UndoIntent(),
},
child: Actions(
actions: {
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (_) => _save(),
),
},
child: Focus(
autofocus: true,
child: child,
),
),
)
// 右键菜单
Listener(
onPointerDown: (event) {
if (event.kind == PointerDeviceKind.mouse &&
event.buttons == kSecondaryMouseButton) {
_showContextMenu(event.position);
}
},
child: child,
)
小结
| 概念 | 要点 |
|---|---|
| GestureDetector | 高层手势封装:tap / drag / scale |
| 手势竞技场 | 解决多手势冲突,子节点优先 |
| HitTestBehavior | deferToChild / opaque / translucent |
| Draggable | 拖拽交互,配合 DragTarget |
| MouseRegion | 桌面 hover、光标适配 |
| Listener | 低层指针事件,全平台兼容 |
👉 下一节:2.6 表单与输入处理