
前言
在移动端备忘录应用中,列表项的交互体验至关重要。常见的需求是:左滑删除、右滑置顶、滑动切换分类------这些都是高频操作。Flutter 内置的 Dismissible 组件虽然能实现滑动删除,但它的设计哲学是"滑动即执行",一旦滑动超过阈值,操作自动触发,无法撤销。
但实际产品中,用户往往期望一个更安全的交互模式:先滑动展露操作按钮,用户确认后再点击执行 。这种"滑出-确认"模式在 iOS 原生的 UITableViewRowAction 中很常见,却需要我们在 Flutter 中从零构建。
本文将以鸿蒙 Flutter 备忘录应用为例,拆解如何用 GestureDetector + AnimationController 从零实现一个高可定制的滑动操作组件。
项目仓库:todo_flutter_harmony
为什么不直接用 Dismissible
Dismissible 有几个固有问题使其不适合备忘录场景:
1. 不可逆的操作触发
dart
Dismissible(
key: Key(item.id.toString()),
onDismissed: (direction) {
// 滑动完成立即删除,无法撤销
deleteItem(item);
},
child: ListTile(title: Text(item.title)),
)
滑动超过阈值后,onDismissed 立即触发,没有任何"确认"环节。如果用户手滑误触,数据就丢了。
2. 背景动作数量受限
Dismissible 只支持左右各一个方向的单一操作(background 和 secondaryBackground)。但在备忘录场景中,左侧可能需要"置顶"和"删���"两个按钮,右侧需要快速切换分类。
3. 缺少展开态交互
Dismissible 的 confirmDismiss 回调虽然可以在弹出确认对话框后决定是否执行,但这打断了流畅的交互体验。我们更希望用户滑动后,按钮直接呈现在眼前,点击即可执行。
基于以上原因,我决定从零实现一个 SlideActionTile 组件。
核心设计思路
整个组件的交互分三个阶段:
- 拖拽阶段:用户手指水平滑动,前景卡片跟随移动,背后露出操作按钮
- 判定阶段:手指抬起时,根据滑动距离和速度判断是"展开"还是"回弹"
- 确认阶段:卡片保持展开态,用户点击背后的按钮执行操作
布局结构上,采用"按钮层在下、卡片层在上"的 Stack 叠放方式:
Stack
├── Row (操作按钮,背后层)
│ ├── 删除按钮
│ └── 置顶按钮
└── GestureDetector (前景卡片层,Transform.translate 控制偏移)
└── Card
└── ListTile
完整代码实现
1. 组件接口定义
dart
class SlideActionTile extends StatefulWidget {
final Widget child; // 前景卡片内容
final List<SlideAction> leftActions; // 左侧操作按钮列表
final List<SlideAction> rightActions; // 右侧操作按钮列表
final VoidCallback? onOpen; // 展开时回调
final VoidCallback? onClose; // 关闭时回调
const SlideActionTile({
super.key,
required this.child,
this.leftActions = const [],
this.rightActions = const [],
this.onOpen,
this.onClose,
});
}
class SlideAction {
final String label;
final IconData icon;
final Color color;
final VoidCallback onTap;
const SlideAction({
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
}
2. 动画控制器与手势处理
dart
class _SlideActionTileState extends State<SlideActionTile>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _slideAnimation;
double _dragStartX = 0;
double _currentOffset = 0;
bool _isOpen = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_controller.addListener(() {
setState(() => _currentOffset = _slideAnimation.value);
});
}
void _onHorizontalDragStart(DragStartDetails details) {
_dragStartX = details.globalPosition.dx;
// 切换动画前保存当前偏移作为起点
_controller.value = 0;
}
手势拖动的核心逻辑:
dart
void _onHorizontalDragUpdate(DragUpdateDetails details) {
final delta = details.globalPosition.dx - _dragStartX;
final totalWidth = _calculateTotalWidth();
// 限制滑动范围不超过按钮总宽度
_currentOffset = delta.clamp(-totalWidth, totalWidth);
// 已展开状态下,偏移需要加上当前展开量
if (_isOpen) {
_currentOffset += _openedOffset;
}
setState(() {});
}
double _calculateTotalWidth() {
double width = 0;
if (_currentOffset > 0) {
width = widget.rightActions.length * 70.0;
} else {
width = widget.leftActions.length * 70.0;
}
return width;
}
3. 吸附判定算法
这是组件的核心------手指抬起后,决定卡片是"展开露出按钮"还是"回弹恢复原位":
dart
void _onHorizontalDragEnd(DragEndDetails details) {
final totalWidth = _calculateTotalWidth();
final threshold = totalWidth * 0.35; // 35% 距离阈值
final velocity = details.primaryVelocity ?? 0;
final velocityThreshold = 500.0; // 500px/s 速度阈值
bool shouldOpen;
if (_isOpen) {
// 已展开状态:滑动超过按钮宽度的 50% 才关闭
shouldOpen = _currentOffset.abs() > totalWidth * 0.5;
} else {
// 未展开状态:超过 35% 阈值或高速滑动则展开
shouldOpen = _currentOffset.abs() > threshold ||
velocity.abs() > velocityThreshold;
}
if (shouldOpen && !_isOpen) {
_open();
} else if (shouldOpen && _isOpen) {
_close();
} else if (!shouldOpen && _isOpen) {
_close();
} else {
_close();
}
}
这里用了双阈值机制:距离阈值 35% 保证短距离滑动不会误触发,速度阈值 500px/s 则让快速轻扫也能触发,符合直觉。
4. 展开与关闭动画
dart
void _open() {
final targetOffset = _currentOffset > 0
? widget.rightActions.length * 70.0
: -widget.leftActions.length * 70.0;
_controller.forward(from: 0);
_slideAnimation = Tween<double>(
begin: _currentOffset,
end: targetOffset,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_isOpen = true;
_openedOffset = targetOffset;
widget.onOpen?.call();
}
void _close() {
_slideAnimation = Tween<double>(
begin: _currentOffset,
end: 0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward(from: 0);
_isOpen = false;
_openedOffset = 0;
widget.onClose?.call();
}
5. 构建视图层
dart
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
// 背景层:操作按钮
Positioned.fill(
child: Row(
mainAxisAlignment: _currentOffset > 0
? MainAxisAlignment.start
: MainAxisAlignment.end,
children: _buildActionButtons(),
),
),
// 前景层:可滑动的卡片
Transform.translate(
offset: Offset(_currentOffset, 0),
child: GestureDetector(
onHorizontalDragStart: _onHorizontalDragStart,
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: widget.child,
),
),
],
);
}
6. 构建操作按钮
dart
List<Widget> _buildActionButtons() {
final actions = _currentOffset > 0
? widget.rightActions
: widget.leftActions;
return actions.map((action) {
return GestureDetector(
onTap: () {
action.onTap();
_close(); // 点击按钮后自动关闭
},
child: Container(
width: 70,
color: action.color,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(action.icon, color: Colors.white, size: 20),
SizedBox(height: 4),
Text(
action.label,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
);
}).toList();
}
在备忘录页面中使用
在备忘录列表项中,这样使用 SlideActionTile:
dart
ListView.builder(
itemCount: memos.length,
itemBuilder: (context, index) {
final memo = memos[index];
return SlideActionTile(
leftActions: [
SlideAction(
label: '删除',
icon: Icons.delete_outline,
color: Colors.red,
onTap: () => _deleteMemo(memo),
),
SlideAction(
label: '置顶',
icon: memo.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Colors.orange,
onTap: () => _togglePin(memo),
),
],
rightActions: [
SlideAction(
label: '分类',
icon: Icons.folder_outlined,
color: Colors.blue,
onTap: () => _changeCategory(memo),
),
],
child: MemoCard(memo: memo),
);
},
)
鸿蒙兼容性说明
这个组件完全基于 Flutter 框架层实现,不依赖任何平台原生API:
GestureDetector--- Flutter 手势识别,纯 Dart/Dart VM 层AnimationController--- Flutter 动画引擎,纯 Dart/Dart VM 层Transform.translate--- Flutter 渲染管线,纯 Dart/Dart VM 层
因此在鸿蒙 OHOS 平台上无需任何额外适配,直接可用。这也是本系列所有自定义组件的一个共同特征------零原生依赖,跨平台无阻。
总结
从零实现一个滑动操作组件并不复杂,核心点在于:
- 手势追踪 :
onHorizontalDragStart/Update/End三阶段处理 - 吸附判定:距离阈值 + 速度阈值的双重判定机制
- 动画衔接 :
Tween+CurvedAnimation.easeOut保证流畅过渡 - 布局叠放:Stack + Transform.translate 实现前后景分离
相比 Dismissible,这个方案提供了"操作可预览、可确认、可多选"的柔性交互,更贴合移动端备忘录这类数据敏感场景的需求。
完整项目代码见:todo_flutter_harmony