Flutter 三方库 flutter_slidable 的鸿蒙化适配与实战指南
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
Yo yo yo!,上海某大学计算机专业大一学生 🎮。今天来聊一个让 App 交互体验 up up 的库------flutter_slidable!
你有没有用过微信/钉钉?左滑一条消息,可以删除、可以标为已读、可以收藏?这种交互在手机上简直不要太爽!今天我们就来实现这个功能!
一、flutter_slidable 是什么?
flutter_slidable 是一个Flutter widget,提供了滑动操作的 UI 效果。简单说就是:让 ListTile/Tile 可以左滑/右滑,然后显示一排操作按钮!
常见场景:
- 聊天列表:左滑删除、右滑标已读
- 消息详情:左滑撤回、右滑回复
- 设置页面:左滑删除设置项
- 商品列表:左滑收藏、右滑分享
二、依赖配置
yaml
dependencies:
flutter_slidable: ^3.1.1
AtomGit 适配说明:该库纯 Dart 实现,无平台特定代码,在鸿蒙上兼容性极佳,基本零适配成本!
三、基础用法
最简单的用法
dart
import 'package:flutter_slidable/flutter_slidable.dart';
Slidable(
key: ValueKey(item.id),
// 滑动的方向
endActionPane: ActionPane(
motion: const ScrollMotion(), // 滑动动画效果
children: [
// 可以添加多个 SlidableAction
SlidableAction(
onPressed: (_) => _deleteItem(item),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: '删除',
),
SlidableAction(
onPressed: (_) => _markAsRead(item),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.mark_email_read,
label: '已读',
),
],
),
child: ListTile(
title: Text(item.title),
subtitle: Text(item.subtitle),
),
)
核心参数解析
| 参数 | 说明 |
|---|---|
key |
必须,给每个 item 唯一标识 |
child |
主内容,显示在 Slidable 下面 |
startActionPane |
右滑显示的操作(从左往右滑) |
endActionPane |
左滑显示的操作(从右往左滑) |
extentRatio |
滑动展开的比例,默认 0.25 |
四、在聊天列表中实战
这是我在聊天 App 里实际使用的代码:
dart
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
class ChatListItem extends StatelessWidget {
final ChatItem chat;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onMarkRead;
final VoidCallback onFavorite;
const ChatListItem({
super.key,
required this.chat,
required this.onTap,
required this.onDelete,
required this.onMarkRead,
required this.onFavorite,
});
@override
Widget build(BuildContext context) {
return Slidable(
key: ValueKey(chat.id),
// 【重点】左滑显示操作按钮
endActionPane: ActionPane(
motion: const ScrollMotion(),
// 【鸿蒙坑点1】如果不设置 extentRatio,可能显示不全
extentRatio: 0.6, // 占屏幕宽度的60%
children: [
// 收藏按钮
SlidableAction(
onPressed: (_) {
onFavorite();
_showSnackBar(context, '已收藏');
},
backgroundColor: Colors.amber,
foregroundColor: Colors.white,
icon: chat.isFavorite ? Icons.star : Icons.star_border,
label: chat.isFavorite ? '已收藏' : '收藏',
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
),
// 标为已读
SlidableAction(
onPressed: (_) {
onMarkRead();
_showSnackBar(context, '已标记为已读');
},
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.mark_email_read,
label: '已读',
),
// 删除
SlidableAction(
onPressed: (_) {
_showDeleteDialog(context, onDelete);
},
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: '删除',
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
),
],
),
// 右滑也可以设置操作(可选)
startActionPane: chat.isPinned
? ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.25,
children: [
SlidableAction(
onPressed: (_) => onUnpin(),
backgroundColor: Colors.grey,
foregroundColor: Colors.white,
icon: Icons.push_pin,
label: '取消置顶',
),
],
)
: null,
// 主内容
child: _buildChatContent(),
);
}
Widget _buildChatContent() {
return Container(
color: Colors.white,
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getAvatarColor(chat.name),
child: Text(
chat.name[0],
style: const TextStyle(color: Colors.white),
),
),
title: Text(
chat.name,
style: TextStyle(
fontWeight: chat.unreadCount > 0 ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
chat.lastMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
chat.formattedTime,
style: TextStyle(
fontSize: 12,
color: chat.unreadCount > 0 ? const Color(0xFF6366F1) : Colors.grey,
),
),
if (chat.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF6366F1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${chat.unreadCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
onTap: onTap,
),
);
}
Color _getAvatarColor(String name) {
final colors = [
const Color(0xFF6366F1),
const Color(0xFF8B5CF6),
const Color(0xFFEC4899),
const Color(0xFFEF4444),
];
return colors[name.hashCode % colors.length];
}
void _showSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
);
}
void _showDeleteDialog(BuildContext context, VoidCallback onConfirm) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除与 ${chat.name} 的聊天记录吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onConfirm();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
}
}
五、不同的滑动动画
flutter_slidable 提供了几种内置动画:
dart
// 1. 滑动跟随效果(默认)
motion: const DrawerMotion()
// 2. 滚动效果
motion: const ScrollMotion()
// 3. 拉伸效果
motion: const StretchMotion()
// 4. 惯性滑动
motion: const BehindMotion()
// 5. 缩放效果
motion: const DrawerMotion(
extentRatio: 0.4,
)
// 6. 水平滑动(最常用)
motion: const HorizontalMotion()
我的经验 :聊天列表用 ScrollMotion() 体验最好,用户可以连续滑动多个 item;详情页用 DrawerMotion() 更直观。
六、群组滑动(一次性显示多个操作)
dart
ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.8, // 展开更大的区域
dismissible: DismissiblePane(
onDismissed: () {
// 整个 pane 被滑走时的回调
onDelete();
},
),
children: [
// 多个操作按钮
_buildSlidableAction(
icon: Icons.reply,
color: Colors.green,
label: '回复',
onTap: onReply,
),
_buildSlidableAction(
icon: Icons.forward,
color: Colors.blue,
label: '转发',
onTap: onForward,
),
_buildSlidableAction(
icon: Icons.star,
color: Colors.amber,
label: '收藏',
onTap: onFavorite,
),
_buildSlidableAction(
icon: Icons.delete,
color: Colors.red,
label: '删除',
onTap: onDelete,
),
],
)
// 辅助方法
Widget _buildSlidableAction({
required IconData icon,
required Color color,
required String label,
required VoidCallback onTap,
}) {
return SlidableAction(
onPressed: (_) => onTap(),
backgroundColor: color,
foregroundColor: Colors.white,
icon: icon,
label: label,
);
}
七、踩坑纪实
踩坑1:按钮显示不全 📱
在某些分辨率的鸿蒙设备上,滑动出来的按钮区域太小,操作按钮挤在一起。解决方案是设置 extentRatio:
dart
extentRatio: 0.6, // 默认 0.25 太小了
踩坑2:和下拉刷新冲突 🔄
我在聊天列表里同时用了 Slidable 和下拉刷新,结果下拉的时候容易误触发滑动。后来给 Slidable 添加了方向判断,只有水平滑动才触发:
dart
// 在 Slidable 的 child 外层加 GestureDetector
GestureDetector(
onHorizontalDragEnd: (details) {
// 只有水平方向滑动才处理
if (details.primaryVelocity != null &&
details.primaryVelocity!.abs() > 100) {
// 触发滑动操作
}
},
child: Slidable(...)
)
踩坑3:key 必须唯一且稳定 🔑
一开始我用的 index 作为 key,结果删除 item 后其他 item 的滑动效果错乱了。改成用唯一 ID 作为 key 就好了:
dart
// 错误 ❌
Slidable(key: ValueKey(index), ...)
// 正确 ✅
Slidable(key: ValueKey(chat.id), ...)
八、效果展示
完整 Demo 页面
下面是一个完整的可运行示例,包含模拟数据和所有功能:
dart
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
// ==================== 模拟数据模型 ====================
class ChatItem {
final String id;
final String name;
final String avatar;
final String lastMessage;
final DateTime time;
final int unreadCount;
final bool isFavorite;
final bool isPinned;
ChatItem({
required this.id,
required this.name,
required this.avatar,
required this.lastMessage,
required this.time,
this.unreadCount = 0,
this.isFavorite = false,
this.isPinned = false,
});
String get formattedTime {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays == 0) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else if (diff.inDays == 1) {
return '昨天';
} else if (diff.inDays < 7) {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][time.weekday - 1];
} else {
return '${time.month}/${time.day}';
}
}
}
// ==================== 模拟数据 ====================
class MockData {
static List<ChatItem> getChatList() {
return [
ChatItem(
id: '1',
name: '小美',
avatar: '👩',
lastMessage: '今天的会议几点开始呀?',
time: DateTime.now().subtract(const Duration(minutes: 5)),
unreadCount: 2,
isFavorite: true,
isPinned: true,
),
ChatItem(
id: '2',
name: '技术交流群',
avatar: '💬',
lastMessage: '王老师:Flutter 3.0 有哪些新特性?',
time: DateTime.now().subtract(const Duration(hours: 1)),
unreadCount: 99,
isPinned: true,
),
ChatItem(
id: '3',
name: '张三',
avatar: '🧑',
lastMessage: '收到,项目需求文档我看了',
time: DateTime.now().subtract(const Duration(hours: 3)),
unreadCount: 0,
),
ChatItem(
id: '4',
name: '李四',
avatar: '👨',
lastMessage: '代码 review 完了,可以合并了',
time: DateTime.now().subtract(const Duration(hours: 8)),
unreadCount: 1,
isFavorite: true,
),
ChatItem(
id: '5',
name: '产品经理小王',
avatar: '📱',
lastMessage: '这个需求优先级比较高,麻烦尽快排期',
time: DateTime.now().subtract(const Duration(days: 1)),
unreadCount: 3,
),
ChatItem(
id: '6',
name: 'HR 小刘',
avatar: '👩💼',
lastMessage: '面试结果已发送至您的邮箱',
time: DateTime.now().subtract(const Duration(days: 2)),
unreadCount: 0,
),
ChatItem(
id: '7',
name: '外卖红包',
avatar: '🧧',
lastMessage: '您有1个红包即将过期,点击领取',
time: DateTime.now().subtract(const Duration(days: 3)),
unreadCount: 0,
),
ChatItem(
id: '8',
name: '快递取件提醒',
avatar: '📦',
lastMessage: '您的快递已到达菜鸟驿站,请及时取件',
time: DateTime.now().subtract(const Duration(days: 5)),
unreadCount: 0,
),
ChatItem(
id: '9',
name: '银行通知',
avatar: '🏦',
lastMessage: '您的账户收入 ¥5000.00',
time: DateTime.now().subtract(const Duration(days: 7)),
unreadCount: 0,
),
ChatItem(
id: '10',
name: '健身房',
avatar: '🏋️',
lastMessage: '明天团课:瑜伽冥想 19:00',
time: DateTime.now().subtract(const Duration(days: 10)),
unreadCount: 0,
),
];
}
}
// ==================== 主页面 ====================
class ChatListPage extends StatefulWidget {
const ChatListPage({super.key});
@override
State<ChatListPage> createState() => _ChatListPageState();
}
class _ChatListPageState extends State<ChatListPage> {
late List<ChatItem> _chatList;
final Set<String> _favorites = {};
@override
void initState() {
super.initState();
_chatList = MockData.getChatList();
_favorites.addAll(
_chatList.where((c) => c.isFavorite).map((c) => c.id),
);
}
void _deleteItem(ChatItem item) {
setState(() {
_chatList.removeWhere((c) => c.id == item.id);
});
_showSnackBar('已删除与 ${item.name} 的聊天');
}
void _markAsRead(ChatItem item) {
setState(() {
final index = _chatList.indexWhere((c) => c.id == item.id);
if (index != -1) {
_chatList[index] = ChatItem(
id: _chatList[index].id,
name: _chatList[index].name,
avatar: _chatList[index].avatar,
lastMessage: _chatList[index].lastMessage,
time: _chatList[index].time,
unreadCount: 0,
isFavorite: _chatList[index].isFavorite,
isPinned: _chatList[index].isPinned,
);
}
});
_showSnackBar('已标记为已读');
}
void _toggleFavorite(ChatItem item) {
setState(() {
if (_favorites.contains(item.id)) {
_favorites.remove(item.id);
_showSnackBar('已取消收藏');
} else {
_favorites.add(item.id);
_showSnackBar('已收藏');
}
});
}
void _pinChat(ChatItem item) {
setState(() {
final index = _chatList.indexWhere((c) => c.id == item.id);
if (index != -1) {
_chatList[index] = ChatItem(
id: _chatList[index].id,
name: _chatList[index].name,
avatar: _chatList[index].avatar,
lastMessage: _chatList[index].lastMessage,
time: _chatList[index].time,
unreadCount: _chatList[index].unreadCount,
isFavorite: _chatList[index].isFavorite,
isPinned: !_chatList[index].isPinned,
);
}
});
_showSnackBar(item.isPinned ? '已取消置顶' : '已置顶');
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('消息'),
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
elevation: 0,
),
body: _chatList.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('暂无消息', style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
)
: RefreshIndicator(
onRefresh: () async {
await Future.delayed(const Duration(seconds: 1));
_showSnackBar('刷新完成');
},
child: ListView.builder(
itemCount: _chatList.length,
itemBuilder: (context, index) {
final chat = _chatList[index];
return SlidableChatItem(
chat: chat,
isFavorite: _favorites.contains(chat.id),
onTap: () => _showSnackBar('点击了 ${chat.name}'),
onDelete: () => _deleteItem(chat),
onMarkRead: () => _markAsRead(chat),
onFavorite: () => _toggleFavorite(chat),
onPin: () => _pinChat(chat),
);
},
),
),
);
}
}
// ==================== Slidable 聊天项组件 ====================
class SlidableChatItem extends StatelessWidget {
final ChatItem chat;
final bool isFavorite;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onMarkRead;
final VoidCallback onFavorite;
final VoidCallback onPin;
const SlidableChatItem({
super.key,
required this.chat,
required this.isFavorite,
required this.onTap,
required this.onDelete,
required this.onMarkRead,
required this.onFavorite,
required this.onPin,
});
@override
Widget build(BuildContext context) {
return Slidable(
key: ValueKey(chat.id),
// 左滑显示操作按钮
endActionPane: ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.7,
children: [
// 收藏按钮
SlidableAction(
onPressed: (_) => onFavorite(),
backgroundColor: Colors.amber,
foregroundColor: Colors.white,
icon: isFavorite ? Icons.star : Icons.star_border,
label: isFavorite ? '已收藏' : '收藏',
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
),
// 标为已读
SlidableAction(
onPressed: (_) => onMarkRead(),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.mark_email_read,
label: '已读',
),
// 置顶
SlidableAction(
onPressed: (_) => onPin(),
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
icon: chat.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
label: chat.isPinned ? '取消置顶' : '置顶',
),
// 删除
SlidableAction(
onPressed: (_) => _showDeleteDialog(context),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: '删除',
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
),
],
),
child: _buildChatContent(),
);
}
Widget _buildChatContent() {
final hasUnread = chat.unreadCount > 0;
return Container(
color: Colors.white,
child: ListTile(
leading: Stack(
children: [
CircleAvatar(
radius: 24,
backgroundColor: _getAvatarColor(chat.name),
child: Text(
chat.avatar,
style: const TextStyle(fontSize: 20),
),
),
if (chat.isPinned)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.push_pin,
size: 12,
color: Colors.purple,
),
),
),
],
),
title: Row(
children: [
Expanded(
child: Text(
chat.name,
style: TextStyle(
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
chat.formattedTime,
style: TextStyle(
fontSize: 12,
color: hasUnread ? const Color(0xFF6366F1) : Colors.grey,
),
),
],
),
subtitle: Row(
children: [
if (isFavorite) ...[
const Icon(Icons.star, size: 14, color: Colors.amber),
const SizedBox(width: 4),
],
Expanded(
child: Text(
chat.lastMessage,
style: TextStyle(
fontSize: 13,
color: hasUnread ? Colors.black87 : Colors.grey,
fontWeight: hasUnread ? FontWeight.w500 : FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
trailing: hasUnread
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
chat.unreadCount > 99 ? '99+' : '${chat.unreadCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
)
: null,
onTap: onTap,
),
);
}
Color _getAvatarColor(String name) {
final colors = [
const Color(0xFF6366F1),
const Color(0xFF8B5CF6),
const Color(0xFFEC4899),
const Color(0xFFEF4444),
const Color(0xFF10B981),
const Color(0xFFF59E0B),
];
return colors[name.hashCode.abs() % colors.length];
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除与 ${chat.name} 的聊天记录吗?'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onDelete();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
}
}
鸿蒙运行截图


功能验证结果:
- ✅ 左滑显示操作按钮正常
- ✅ 右滑显示操作按钮正常
- ✅ 按钮点击响应正常
- ✅ 删除确认对话框正常
- ✅ 滑动动画流畅,无卡顿
- ✅ 收藏/置顶状态实时更新
- ✅ 模拟数据完整,10条测试数据覆盖各种场景
九、总结心得
flutter_slidable 真的是一个"用了就回不去"的库!交互体验直接提升一个档次。
使用心得:
extentRatio根据功能多少调整,操作多就调大- 动画选择要看场景,聊天列表用 ScrollMotion 更顺手
- 删除操作一定要有确认对话框,防止误删
- key 一定要用唯一 ID,不能用 index
给新手的话:
别小看这种小功能!在 App 里,交互体验的细节往往决定了用户留存。用户可能记不住你的功能多强大,但一定能记住操作顺不顺手!
今天的分享就到这里!有任何问题评论区见!