桌面端表格视图多选功能增强

January 3, 2026
5 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

桌面端文件列表使用表格视图(FileTableView),我想参考 123云盘 的界面设计,优化多选交互体验。

需求分析

原始需求

  1. 列宽可拖拽调整 - 可以拖拽调整名称/大小/时间/类型等列的宽度
  2. 表头全选复选框 - 参考123云盘,在表头前面添加全选复选框
  3. 始终显示勾选框 - 每个列表项前面默认显示勾选框,而非悬浮才显示

参考设计

123云盘界面特点:

  • 表头左侧有全选复选框
  • 每行左侧始终显示勾选框(不是hover才出现)
  • 勾选框使用圆形样式

技术方案

一、列宽拖拽调整

1.1 方案选择

方案A:使用 DataTable + columnSpacing

  • 优点:Flutter 原生支持
  • 缺点:DataTable 的列宽调整支持有限,自定义困难

方案B:自定义 Row + 固定宽度 SizedBox

  • 优点:完全可控,易于实现拖拽
  • 缺点:需要手动管理列宽状态

选择:方案B - 更灵活,适合已有的自定义表格实现

1.2 实现细节

状态变量

class _FileTableViewState extends State<FileTableView> {
  static const double _minColWidth = 60.0;
  
  // 默认列宽
  double _nameColWidth = 250.0;
  double _sizeColWidth = 80.0;
  double _timeColWidth = 140.0;
  double _typeColWidth = 70.0;
}

持久化存储

Future<void> _loadColumnWidths() async {
  final prefs = await SharedPreferences.getInstance();
  setState(() {
    _nameColWidth = prefs.getDouble('table_col_name') ?? 250.0;
    _sizeColWidth = prefs.getDouble('table_col_size') ?? 80.0;
    _timeColWidth = prefs.getDouble('table_col_time') ?? 140.0;
    _typeColWidth = prefs.getDouble('table_col_type') ?? 70.0;
  });
}
 
Future<void> _saveColumnWidths() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setDouble('table_col_name', _nameColWidth);
  await prefs.setDouble('table_col_size', _sizeColWidth);
  await prefs.setDouble('table_col_time', _timeColWidth);
  await prefs.setDouble('table_col_type', _typeColWidth);
}

拖拽分隔符组件

Widget _buildResizeHandle(void Function(double delta) onDrag) {
  return MouseRegion(
    cursor: SystemMouseCursors.resizeColumn,  // 调整光标样式
    child: GestureDetector(
      onHorizontalDragUpdate: (details) {
        onDrag(details.delta.dx);
      },
      onHorizontalDragEnd: (_) {
        _saveColumnWidths();  // 拖拽结束时保存
      },
      child: Container(
        width: 8,
        height: _headerHeight,
        color: Colors.transparent,
        child: Center(
          child: Container(
            width: 1,
            height: 16,
            color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
          ),
        ),
      ),
    ),
  );
}

表头布局变化

  • 原来:使用 Expanded + flex 比例分配
  • 现在:使用 SizedBox 固定宽度 + 拖拽分隔符

二、全选复选框

2.1 方案选择

位置选项:

  • A. AppBar 中的全选按钮(原有)
  • B. 表头左侧的全选复选框(新增)
  • C. 两者都有

选择分析:

  • 移动端:没有表头,只能用 AppBar 按钮
  • 桌面端表格:表头全选更直观,符合使用习惯
  • 桌面端网格:没有表头,只能用 AppBar 按钮

最终方案: 区分平台和视图类型

  • 桌面端表格视图:表头全选(移除 AppBar 全选,保留反选)
  • 其他场景:AppBar 全选/反选

2.2 全选状态逻辑

三种状态图标显示:

Widget _buildSelectAllCheckbox(ThemeData theme) {
  final selectableFiles = widget.files.where((f) => !f.isSystemFolder).toList();
  final selectableCount = selectableFiles.length;
  final selectedCount = widget.selectedIds.length;
  
  final isAllSelected = selectableCount > 0 && selectedCount == selectableCount;
  final isPartialSelected = selectedCount > 0 && selectedCount < selectableCount;
  
  return GestureDetector(
    behavior: HitTestBehavior.opaque,
    onTap: widget.onSelectAll,
    child: Icon(
      isAllSelected
          ? TablerIcons.circle_check_filled  // 全选:实心勾选
          : isPartialSelected
          ? TablerIcons.circle_minus         // 部分选择:减号
          : TablerIcons.circle,              // 未选择:空圆
      ...
    ),
  );
}

2.3 切换逻辑

void _toggleSelectAll(List<FileMetadata> files) {
  final selectableFiles = files.where((f) => !f.isSystemFolder).toList();
  final selectableIds = selectableFiles.map((f) => f.id).toSet();
  final isAllSelected = selectableIds.isNotEmpty && 
      selectableIds.every((id) => _selectedIds.contains(id));
  
  setState(() {
    if (isAllSelected) {
      // 已全选 → 取消全选
      _selectedIds.clear();
      _selectionMode = false;
    } else {
      // 未全选 → 全选
      _selectionMode = true;
      _selectedIds.clear();
      _selectedIds.addAll(selectableIds);
    }
  });
}

三、始终显示勾选框

3.1 原有逻辑

勾选框仅在多选模式(_selectionMode = true)下显示

3.2 新逻辑

桌面端表格视图中,勾选框始终显示:

  • 系统文件夹不显示勾选框
  • 其他文件始终显示(选中/未选中两种状态)
// 勾选框(始终显示)
SizedBox(
  width: 48,
  child: Center(
    child: widget.file.isSystemFolder
        ? const SizedBox()  // 系统文件夹不显示
        : GestureDetector(
            behavior: HitTestBehavior.opaque,
            onTap: widget.onSelectToggle,
            child: Icon(
              widget.selected
                  ? TablerIcons.circle_check_filled
                  : TablerIcons.circle,
              ...
            ),
          ),
  ),
),

问题与修复

问题1:点击延迟

现象: 我这边用的时候发现”感觉单项选中有一点延迟呢,点一下过一会儿才被选中”

原因分析:

  • 使用 InkWell 包裹勾选框
  • InkWell 的涟漪动画导致视觉上的感知延迟
  • 动画完成后才触发回调的视觉错觉

解决方案:

  • InkWell 改为 GestureDetector
  • 添加 behavior: HitTestBehavior.opaque 确保点击区域可靠
// Before
InkWell(
  onTap: widget.onSelectToggle,
  borderRadius: BorderRadius.circular(12),
  child: ...
)
 
// After
GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTap: widget.onSelectToggle,
  child: ...
)

问题2:AppBar 全选冗余

现象: 桌面端表格视图同时有两个全选入口

  • 表头全选复选框(新增)
  • AppBar 全选按钮(原有)

分析矩阵:

平台视图表头全选AppBar全选冲突
桌面端表格冗余
桌面端网格正常
移动端列表正常
移动端网格正常

解决方案: 条件隐藏 AppBar 全选按钮

if (_selectionMode) ...[
  // 桌面端表格视图时,表头已有全选复选框,不需要 AppBar 的全选按钮
  if (!(_isDesktop && _viewType == ViewType.list))
    IconButton(
      icon: const Icon(TablerIcons.circle_check),
      tooltip: '全选',
      ...
    ),
  // 反选按钮保留(表头没有反选功能)
  IconButton(
    icon: const Icon(TablerIcons.circle),
    tooltip: '反选',
    ...
  ),
]

修改文件清单

file_tiles.dart

  1. 添加列宽状态变量和持久化方法
  2. 添加 _buildResizeHandle() 拖拽分隔符
  3. 添加 _buildSelectAllCheckbox() 全选复选框
  4. 修改 _FileTableRow 始终显示勾选框
  5. InkWell → GestureDetector 修复点击延迟
  6. 添加 onSelectAll 回调参数

files_page.dart

  1. 添加 _toggleSelectAll() 方法
  2. FileTableView 调用添加 onSelectAll 参数
  3. 条件隐藏 AppBar 全选按钮(桌面端表格视图时)

最终交互矩阵

平台视图全选方式勾选框显示
🖥️ 桌面端表格表头复选框 + AppBar反选始终显示
🖥️ 桌面端网格AppBar 全选/反选多选模式下
📱 移动端列表AppBar 全选/反选多选模式下
📱 移动端网格AppBar 全选/反选多选模式下

设计决策记录

为什么表头全选使用切换逻辑?

AppBar 的全选按钮是”单向全选”(点击只能全选,不能取消),而表头全选复选框采用”切换逻辑”:

  • 已全选 → 点击取消全选
  • 未全选 → 点击全选

原因:

  1. 符合复选框的交互预期
  2. 123云盘等主流产品都是这种行为
  3. 减少点击次数,提升效率

为什么保留 AppBar 的反选?

表头没有反选功能,而反选在批量操作中有用(如:选中大部分,反选几个排除),所以保留。

为什么 GestureDetector 比 InkWell 响应更快?

  • InkWell 有涟漪动画,虽然 onTap 立即触发,但动画让人觉得”正在处理”
  • GestureDetector 没有视觉反馈,状态变化直接反映在图标上
  • 对于小型交互元素(如复选框),无涟漪更干净直接

后续优化建议

  1. 记忆视图模式 - 切换列表/网格后应持久化保存
  2. 表格排序指示器 - 当前只有点击排序,没有视觉指示排序方向
  3. 列宽双击重置 - 双击分隔符恢复默认宽度