141 lines
5.1 KiB
Markdown
141 lines
5.1 KiB
Markdown
# 【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<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):
|
||
```dart
|
||
// 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](smooth_sheet_demo.md) 的"模态 vs 非模态"对比;
|
||
- 上传动作的通用包装(loading 防重入、finally 复位)见 [share_demo.md](share_demo.md) 的 `_sharing` 模式;
|
||
- 真实项目:把 TODO 处换成 `UploadApi.uploadImages`,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。
|