Flutter 三方库 flutter_slidable 的鸿蒙化适配与实战指南


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Yo yo yo!,上海某大学计算机专业大一学生 🎮。今天来聊一个让 App 交互体验 up up 的库——flutter_slidable

你有没有用过微信/钉钉?左滑一条消息,可以删除、可以标为已读、可以收藏?这种交互在手机上简直不要太爽!今天我们就来实现这个功能!

一、flutter_slidable 是什么?

flutter_slidable 是一个Flutter widget,提供了滑动操作的 UI 效果。简单说就是:让 ListTile/Tile 可以左滑/右滑,然后显示一排操作按钮!

常见场景:

  • 聊天列表:左滑删除、右滑标已读
  • 消息详情:左滑撤回、右滑回复
  • 设置页面:左滑删除设置项
  • 商品列表:左滑收藏、右滑分享

二、依赖配置

dependencies:
  flutter_slidable: ^3.1.1

AtomGit 适配说明:该库纯 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 里实际使用的代码:

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,
  });

  
  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 提供了几种内置动画:

// 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() 更直观。

六、群组滑动(一次性显示多个操作)

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

extentRatio: 0.6,  // 默认 0.25 太小了

踩坑2:和下拉刷新冲突 🔄

我在聊天列表里同时用了 Slidable 和下拉刷新,结果下拉的时候容易误触发滑动。后来给 Slidable 添加了方向判断,只有水平滑动才触发:

// 在 Slidable 的 child 外层加 GestureDetector
GestureDetector(
  onHorizontalDragEnd: (details) {
    // 只有水平方向滑动才处理
    if (details.primaryVelocity != null && 
        details.primaryVelocity!.abs() > 100) {
      // 触发滑动操作
    }
  },
  child: Slidable(...)
)

踩坑3:key 必须唯一且稳定 🔑

一开始我用的 index 作为 key,结果删除 item 后其他 item 的滑动效果错乱了。改成用唯一 ID 作为 key 就好了:

// 错误 ❌
Slidable(key: ValueKey(index), ...)

// 正确 ✅
Slidable(key: ValueKey(chat.id), ...)

八、效果展示

完整 Demo 页面

下面是一个完整的可运行示例,包含模拟数据和所有功能:

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});

  
  State<ChatListPage> createState() => _ChatListPageState();
}

class _ChatListPageState extends State<ChatListPage> {
  late List<ChatItem> _chatList;
  final Set<String> _favorites = {};

  
  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,
      ),
    );
  }

  
  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,
  });

  
  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 真的是一个"用了就回不去"的库!交互体验直接提升一个档次。

使用心得:

  1. extentRatio 根据功能多少调整,操作多就调大
  2. 动画选择要看场景,聊天列表用 ScrollMotion 更顺手
  3. 删除操作一定要有确认对话框,防止误删
  4. key 一定要用唯一 ID,不能用 index

给新手的话:
别小看这种小功能!在 App 里,交互体验的细节往往决定了用户留存。用户可能记不住你的功能多强大,但一定能记住操作顺不顺手!


今天的分享就到这里!有任何问题评论区见!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐