# 【Demo】笔记图片选择页(多图选择 + 缩略图网格 + 删除角标) > 源文件:`lib/pages/demo/note_picker_demo.dart` | 分类:组件封装 > 一句话:学生上传笔记前的"选图 + 预览 + 确认"页 —— 网格缩略图、右下角加号、底部提交按钮。 --- ## 一、这个 Demo 解决什么问题 笔记上传前要先选图、预览、确认。本页是一个真实的**多图选择页**: ``` 选图(PickerService 弹层)→ 网格缩略图预览 → 删除不满意的 → 底部提交(预留上传接口) ``` 它演示了三个可复用的套路: 1. **加号格子**:GridView 比图片多一格,点它弹选图层; 2. **删除角标**:每张图右上角一个小圆叉(Stack + Positioned); 3. **双端缩略图**:同一份代码在原生 / Web 都能显示缩略图(`PickerService.buildThumb`)。 > 对照 Vue:相当于"el-upload 多图模式 + 预览删除"的前端部分,上传接口那步留了 TODO。 --- ## 二、怎么运行 / 演示内容 ```dart runApp(const MaterialApp(home: NotePickerPage())); ``` - 点加号 → 弹「拍照/相册」底部弹层(原生是微信风格相册,可多选;Web 降级 image_picker); - 选完图回到网格,右上角小叉可删; - 选满 9 张后加号自动消失; - 提交按钮显示 `上传笔记(n/9)`,未选图时点击提示"请先选择笔记图片"。 --- ## 三、核心知识点(新手向) ### 1. 网格多一格 = 加号位 ```dart 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 ```dart 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. 双端缩略图:永远走服务层,别自己判断平台 ```dart Widget _buildThumb(PickedImage image) { return PickerService.buildThumb(image); // 原生 → AssetEntityImage(实体缩略图,秒开不卡顿) // Web → Image.memory(字节) } ``` ### 4. 提交按钮的"禁用态"套路 ```dart FilledButton( onPressed: _uploading ? null : _onSubmit, // null = 禁用(置灰) child: Text(_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)'), ) ``` `onPressed` 传 `null` 就是禁用,这是 Flutter 的通用约定(比 Vue 的 `:disabled` 更内建)。 --- ## 四、关键代码剖析 ```dart Future _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): ```dart // TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置) // final urls = await UploadApi.instance.uploadImages(_images); // 成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。 await Future.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](smooth_sheet_demo.md) 的"模态 vs 非模态"对比; - 上传动作的通用包装(loading 防重入、finally 复位)见 [share_demo.md](share_demo.md) 的 `_sharing` 模式; - 真实项目:把 TODO 处换成 `UploadApi.uploadImages`,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。