Files
2026-09-04 15:22:20 +08:00

5.1 KiB
Raw Permalink Blame History

【Demo】笔记图片选择页(多图选择 + 缩略图网格 + 删除角标)

源文件:lib/pages/demo/note_picker_demo.dart | 分类:组件封装 一句话:学生上传笔记前的"选图 + 预览 + 确认"页 —— 网格缩略图、右下角加号、底部提交按钮。


一、这个 Demo 解决什么问题

笔记上传前要先选图、预览、确认。本页是一个真实的多图选择页:

选图(PickerService 弹层)→ 网格缩略图预览 → 删除不满意的 → 底部提交(预留上传接口)

它演示了三个可复用的套路:

  1. 加号格子:GridView 比图片多一格,点它弹选图层;
  2. 删除角标:每张图右上角一个小圆叉(Stack + Positioned);
  3. 双端缩略图:同一份代码在原生 / Web 都能显示缩略图(PickerService.buildThumb)。

对照 Vue:相当于"el-upload 多图模式 + 预览删除"的前端部分,上传接口那步留了 TODO。


二、怎么运行 / 演示内容

runApp(const MaterialApp(home: NotePickerPage()));
  • 点加号 → 弹「拍照/相册」底部弹层(原生是微信风格相册,可多选;Web 降级 image_picker);
  • 选完图回到网格,右上角小叉可删;
  • 选满 9 张后加号自动消失;
  • 提交按钮显示 上传笔记(n/9),未选图时点击提示"请先选择笔记图片"。

三、核心知识点(新手向)

1. 网格多一格 = 加号位

GridView.builder(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,          // 3 列
    mainAxisSpacing: 10.h,
    crossAxisSpacing: 10.w,
  ),
  itemCount: _images.length + 1, // 关键:多一格给"加号"
  itemBuilder: (context, index) {
    if (index == _images.length) {          // 最后一格
      if (_images.length >= _maxCount) {
        return const SizedBox.shrink();     // 满了:占位空格
      }
      return _buildAddCell();               // 没满:加号
    }
    return _buildImageCell(index);          // 前面是已选图片
  },
)

2. 删除角标 = Stack + Positioned

Stack(
  fit: StackFit.expand,
  children: [
    ClipRRect(borderRadius: BorderRadius.circular(10.r), child: thumb),
    Positioned(
      top: 4.h, right: 4.w,                 // 钉在右上角
      child: GestureDetector(
        onTap: () => _onRemoveTap(index),
        child: Container(                    // 20x20 黑色半透明圆
          width: 20.w, height: 20.h,
          decoration: const BoxDecoration(
            color: Colors.black54, shape: BoxShape.circle),
          child: const Icon(Icons.close, size: 12, color: Colors.white),
        ),
      ),
    ),
  ],
)

3. 双端缩略图:永远走服务层,别自己判断平台

Widget _buildThumb(PickedImage image) {
  return PickerService.buildThumb(image);
  // 原生 → AssetEntityImage(实体缩略图,秒开不卡顿)
  // Web  → Image.memory(字节)
}

4. 提交按钮的"禁用态"套路

FilledButton(
  onPressed: _uploading ? null : _onSubmit,  // null = 禁用(置灰)
  child: Text(_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)'),
)

onPressed 传 null 就是禁用,这是 Flutter 的通用约定(比 Vue 的 :disabled 更内建)。


四、关键代码剖析

Future<void> _onAddTap() async {
  final picked = await PickerService.instance.showPickSheet(
    context,
    maxCount: _maxCount - _images.length, // 剩余可选张数(限总数 9)
  );
  if (picked == null || picked.isEmpty) return; // 用户取消
  setState(() => _images.addAll(picked));       // 不可变对象直接合并
}

提交时预留了上传接口位(等接口文档到了替换 TODO):

// TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置)
//   final urls = await UploadApi.instance.uploadImages(_images);
//   成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。
await Future<void>.delayed(const Duration(milliseconds: 500)); // 模拟请求

五、踩坑记录

  1. 千万别在 GridView 里用 Image.file 判断平台——dart:io 在 Web 编译不过,必须用 AssetEntityImage(原生)/ Image.memory(Web);
  2. PickedImage 是不可变对象:直接 addAll 进列表再 setState 即可,不需要深拷贝;
  3. 上传前校验:没选图时点击要提示,别发空请求;
  4. setState 里 _images.removeAt(index) 即可删图,网格随 itemCount 变化自动收缩;
  5. 满 9 张时加号格用 SizedBox.shrink() 占位而不是直接不渲染,保持网格布局稳定。

六、扩展思考

  • 选图弹层本身是另一个封装(showPickSheet 内部是 showModalBottomSheet),见 smooth_sheet_demo.md 的"模态 vs 非模态"对比;
  • 上传动作的通用包装(loading 防重入、finally 复位)见 share_demo.md 的 _sharing 模式;
  • 真实项目:把 TODO 处换成 UploadApi.uploadImages,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。