在开发移动应用时,文件上传和预览是一个常见且非常重要的功能,尤其在涉及图片和视频上传时。在本文中,我将分享一个根据微信文件上传以及预览和操作完整的文件上传与预览组件的实现过程,涵盖了上传文件、生成文件缩略图、管理上传状态以及处理删除等功能。通过这个组件,我们不仅能方便地管理文件选择和上传,还能提供极好的用户体验。
需求分析
首先,我们需要明确组件的基本需求:
- 文件选择:用户可以从相册或相机中选择图片或视频。
- 上传管理:每个文件选择后,需展示上传状态(待上传、上传中、上传成功、上传失败)。
- 文件预览:上传文件后,需要能够查看文件的缩略图或预览。
- 删除功能:用户可以删除已选的文件。
- 最大上传限制:每次上传的文件数量有限制,通常为9个文件。
组件设计
为了满足以上需求,我们设计了几个组件:
ImageUploadWidget
:主要负责文件的选择和上传。FilePreview
:负责显示每个文件的预览图,支持图片和视频。MediaPickerMenu
:用于提供选择文件或拍摄照片/视频的功能。
技术栈与插件
在实现这些功能时,我们使用了以下技术和插件:
flutter_slimming
:这是我们应用的主要包,包含上传功能和文件选择菜单等。wechat_assets_picker
:用于实现从相册中选择文件的功能。wechat_camera_picker
:实现拍照或录像功能。video_thumbnail
:生成视频文件的缩略图。flutter_material
:用于实现 UI 组件(如按钮、图标、网格布局等)。
组件实现
1. ImageUploadWidget
组件
ImageUploadWidget
组件是上传文件的核心,它支持文件选择和文件上传。用户可以从相机或相册选择文件,并展示选择的文件。
关键逻辑
- 文件选择:通过
MediaPickerMenu
弹出文件选择菜单,用户可以选择从相册或相机上传文件。 - 上传文件:选择文件后,使用
FileUploader().uploadFiles
进行文件上传,并在上传过程中更新每个文件的上传状态(待上传、上传中、上传成功、上传失败)。 - 文件删除:点击每个文件的删除按钮时,通过
onDelete
回调从已选文件中移除。
class ImageUploadWidget extends StatefulWidget {
final Function(List) onUploadComplete;
final double size;
final int maxFiles;
final List<uploadOptions> availableOptions;
final Function(bool) onChangeStatus;
final Widget? child;
const ImageUploadWidget({
Key? key,
required this.onUploadComplete,
this.size = 80,
this.maxFiles = 9,
this.availableOptions = const [
uploadOptions.camera,
uploadOptions.gallery,
uploadOptions.video,
],
required this.onChangeStatus,
this.child,
}) : super(key: key);
@override
_ImageUploadWidgetState createState() => _ImageUploadWidgetState();
}
文件选择与上传
在 ImageUploadWidget
中,使用了 MediaPickerMenu
来选择文件,并且通过 FileUploader
类进行文件上传。
Future<void> _selectAssets() async {
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
maxAssets: widget.maxFiles - _selectedFiles.length,
requestType: widget.availableOptions.contains(uploadOptions.video)
? RequestType.all
: RequestType.image,
),
);
// 处理文件上传逻辑
}
2. FilePreview
组件
FilePreview
组件负责展示文件的预览。不同类型的文件(图片、视频)会有不同的展示方式。对于视频文件,我们通过 video_thumbnail
插件生成视频的缩略图。
视频缩略图生成
在 FilePreview
中,如果文件是视频,我们会调用 VideoThumbnail.thumbnailData
来生成视频的缩略图。若缩略图生成失败,则显示一个默认的“损坏图标”。
if (widget.file.path.endsWith('.mp4')) {
_thumbnailFuture = VideoThumbnail.thumbnailData(
video: widget.file.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 128,
quality: 75,
);
}
图片文件展示
对于图片文件,直接通过 FileImage
显示文件的内容。
else {
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(widget.file),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(8),
),
);
}
3. MediaPickerMenu
组件
MediaPickerMenu
是一个底部弹出菜单,允许用户选择文件来源。用户可以选择拍照、从图库选择文件,或者取消操作。
菜单项构建
每个菜单项通过 _buildPickerOption
方法构建,点击菜单项时会触发相应的回调(例如拍照或选择文件)。
Widget _buildPickerOption({
required String title,
required IconData icon,
bool isShowBorder = true,
required Function onTapAction
}) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: onTapAction,
child: Row(
children: [
Icon(icon, size: 22),
SizedBox(width: 10),
Text(title, style: TextStyle(fontSize: 14)),
],
),
),
if (isShowBorder) Divider(color: Colors.grey.shade200),
],
);
}
关键挑战与解决方案
- 视频缩略图生成:视频缩略图的生成需要一定时间,可能会导致界面卡顿或不流畅。为了解决这个问题,我们使用了
FutureBuilder
来异步加载缩略图,确保界面流畅。 - 上传状态管理:上传状态的管理需要实时更新,保证用户看到准确的上传状态(待上传、上传中、上传成功、上传失败)。我们通过
Map
来维护每个文件的状态,并在文件上传成功或失败后及时更新 UI。 - 文件删除:删除文件时,不仅要更新文件列表,还要更新上传状态和已上传的文件 URL。通过
onDelete
回调,确保 UI 能实时更新。
完整代码
ImageUploadWidget组件
class ImageUploadWidget extends StatefulWidget {
final Function(List) onUploadComplete; // 上传完成的回调
final double size; // 每个文件展示框的大小
final int maxFiles; // 最大上传数量
final List<uploadOptions> availableOptions; // 可选择的上传选项
final Function(bool) onChangeStatus;
final Widget? child;
const ImageUploadWidget({
Key? key,
required this.onUploadComplete,
this.size = 80,
this.maxFiles = 9,
this.availableOptions = const [
uploadOptions.camera,
uploadOptions.gallery,
uploadOptions.video,
],
required this.onChangeStatus,
this.child,
}) : super(key: key);
@override
_ImageUploadWidgetState createState() => _ImageUploadWidgetState();
}
class _ImageUploadWidgetState extends State<ImageUploadWidget> {
List<File> _selectedFiles = []; // 当前选择的文件
Map<File, uploadStatus> _uploadStatus = {}; // 文件的上传状态
List<Map> _fileUrls = []; // 上传成功的网络地址列表
// 打开选择菜单
Future<void> _showPickerMenu() async {
showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return MediaPickerMenu(
availableOptions: widget.availableOptions,
maxFiles: widget.maxFiles - _selectedFiles .length,
takeAsset: _takeAsset,
selectAssets: _selectAssets,
);
},
);
}
// 上传文件
Future<void> _uploadFiles(List<File> files) async {
widget.onChangeStatus(true);
setState(() {
// 设置所有文件的上传状态为上传中
for (var file in files) {
_uploadStatus[file] = uploadStatus.uploading;
}
});
try {
// 调用 FileUploader 的上传逻辑
FileUploader().uploadFiles(
files: files,
onSuccess: (response) {
print('上传成功files: ${files[0]}');
response.data['data'].forEach((element) {
print('element:$element');
Map item = {
"type": "${element['fileType']}",
"url": "${element['url']}",
"fileName": "${element['originalFilename']}"
};
setState(() {
_fileUrls.add(item);
// 更新上传状态
for (var file in files) {
_uploadStatus[file] = uploadStatus.success;
}
});
});
// 通知父组件上传完成,返回 URL 数组
widget.onUploadComplete(_fileUrls);
widget.onChangeStatus(false);
},
onError: (error) {
setState(() {
// 更新状态为上传失败
for (var file in files) {
_uploadStatus[file] = uploadStatus.failure;
}
});
widget.onChangeStatus(false);
print('上传失败: $error');
},
);
} catch (e) {
setState(() {
// 更新状态为上传失败
for (var file in files) {
_uploadStatus[file] = uploadStatus.failure;
}
});
widget.onChangeStatus(false);
print('上传出错: $e');
}
}
// 删除文件
void _deleteFile(File file) {
setState(() {
_selectedFiles.remove(file);
_uploadStatus.remove(file);
_fileUrls.removeWhere((urlMap) {
final fileName = file.path.split('/').last;
final urlFileName = urlMap['fileName'] ?? '';
return fileName == urlFileName; // 比较文件名是否相同
});
widget.onUploadComplete(_fileUrls); // 通知父组件 URL 数组变化
});
}
// 选择图片或视频
Future<void> _selectAssets() async {
final List<AssetEntity>? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
maxAssets: widget.maxFiles - _selectedFiles.length,
requestType: widget.availableOptions.contains(uploadOptions.video)
? RequestType.all
: RequestType.image,
),
);
if (assets != null && assets.isNotEmpty) {
List<File> newFiles = [];
for (var asset in assets) {
final File? file = await asset.file;
if (file != null && !_selectedFiles.contains(file)) {
newFiles.add(file);
}
}
if (newFiles.isNotEmpty) {
setState(() {
_selectedFiles.addAll(newFiles);
// 为每个新文件设置初始状态为待上传
newFiles.forEach((file) {
_uploadStatus[file] = uploadStatus.pending;
});
});
// 立即上传所有选择的文件
_uploadFiles(newFiles);
}
}
}
// 拍照或录像
Future<void> _takeAsset() async {
final AssetEntity? asset = await CameraPicker.pickFromCamera(
context,
pickerConfig: CameraPickerConfig(
enableRecording: widget.availableOptions.contains(uploadOptions.video),
),
);
if (asset != null) {
final File? file = await asset.file;
if (file != null && !_selectedFiles.contains(file)) {
setState(() {
_selectedFiles.add(file);
_uploadStatus[file] = uploadStatus.pending;
});
// 立即上传
_uploadFiles([file]);
}
}
}
@override
Widget build(BuildContext context) {
return widget.child != null
? GestureDetector(
onTap: _showPickerMenu, // 点击显示选择菜单
child: widget.child ?? Container(),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文件网格视图
_selectedFiles.isNotEmpty
? GridView.builder(
shrinkWrap: true,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _selectedFiles.length,
itemBuilder: (context, index) {
final file = _selectedFiles[index];
return FilePreview(
file: file,
status: _uploadStatus[file] ?? uploadStatus.pending,
onDelete: () => _deleteFile(file),
);
},
)
: Container(),
// 添加文件按钮
if (_selectedFiles.length < widget.maxFiles)
GestureDetector(
onTap: _showPickerMenu,
child: Container(
width: widget.size,
height: widget.size,
margin: EdgeInsets.only(top: 10,),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.add,
color: Colors.grey,
size: 40,
),
),
),
],
);
}
}
FilePreview组件
class FilePreview extends StatefulWidget {
final File file;
final uploadStatus status;
final VoidCallback onDelete;
const FilePreview({
Key? key,
required this.file,
required this.status,
required this.onDelete,
}) : super(key: key);
@override
_FilePreviewState createState() => _FilePreviewState();
}
class _FilePreviewState extends State<FilePreview> {
Future<Uint8List?>? _thumbnailFuture;
@override
void initState() {
super.initState();
// 如果是视频文件,生成视频缩略图
if (widget.file.path.endsWith('.mp4')) {
_thumbnailFuture = VideoThumbnail.thumbnailData(
video: widget.file.path,
imageFormat: ImageFormat.JPEG,
maxWidth: 128, // 缩略图最大宽度
quality: 75, // 缩略图质量
);
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 文件预览
FutureBuilder<Uint8List?>(
future: _thumbnailFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(),
);
}
// 判断文件类型
if (snapshot.hasData && snapshot.data != null) {
// 如果是视频文件,显示视频缩略图
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: MemoryImage(snapshot.data!),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Icon(
Icons.play_circle_fill,
color: Colors.white,
size: 40,
),
),
);
} else if (widget.file.path.endsWith('.mp4')) {
// 如果没有视频缩略图,则显示默认的“损坏图标”
return Container(
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Icon(
Icons.broken_image,
color: Colors.black54,
size: 40,
),
),
);
} else {
// 如果是图片文件,直接展示
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(widget.file),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(8),
),
);
}
},
),
// 删除按钮
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: widget.onDelete,
child: const CircleAvatar(
radius: 12,
backgroundColor: Colors.black54,
child: Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
}
}
MediaPickerMenu组件
class MediaPickerMenu extends StatefulWidget {
final List<uploadOptions> availableOptions; // 可选择的上传选项
final int maxFiles; // 最大上传数量
final Function takeAsset; // 拍照或录像
final Function selectAssets; // 选择图片或视频
const MediaPickerMenu(
{Key? key,
required this.availableOptions,
required this.maxFiles,
required this.takeAsset,
required this.selectAssets})
: super(key: key);
@override
_MediaPickerMenuState createState() => _MediaPickerMenuState();
}
class _MediaPickerMenuState extends State<MediaPickerMenu> {
final throttle = Throttle(milliseconds: 1000); // 1秒节流时间
// 构建选择菜单项
Widget _buildPickerOption(
{required String title,
required IconData icon,
bool isShowBorder = true,
required Function onTapAction}) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 20, horizontal: 10),
child: GestureDetector(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 22,
),
SizedBox(
width: 10,
),
Text(
title,
style: TextStyle(
fontSize: 14,
),
),
],
),
onTap: () {
throttle.run(
() async {
Navigator.of(context).pop();
await onTapAction();
},
);
},
),
),
isShowBorder
? Divider(
// Divider 用于在 ListTile 下面添加一条线
color: Colors.grey.shade200,
height: 0.5, // 设置高度,控制分割线的粗细
)
: Container()
],
);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: SafeArea(
child: Wrap(
children: <Widget>[
if (widget.availableOptions.contains(uploadOptions.camera))
_buildPickerOption(
title: '拍摄',
icon: Icons.camera_alt,
onTapAction: widget.takeAsset),
if (widget.availableOptions.contains(uploadOptions.camera))
_buildPickerOption(
title: '图库',
icon: Icons.image,
onTapAction: widget.selectAssets),
Container(
height: 10,
color: Colors.grey.shade100,
),
_buildPickerOption(
title: '取消',
icon: Icons.close,
onTapAction: () {
// Navigator.of(context).pop();
},
isShowBorder: false,
),
],
),
),
);
}
}
使用这些组件后,用户可以实现:
- 从相册或相机中选择图片、视频文件。
- 对每个文件显示上传状态和预览图。
- 上传过程中,文件会显示为“上传中”,上传成功或失败后状态更新。
- 文件上传成功后,返回文件的网络地址,供后续使用。
- 支持删除文件功能,用户可以移除不需要上传的文件。
弹窗演示
拍摄演示
选择演示
上传演示
由此仿照微信实现媒体的选择和发送功能就完成了,感谢各位阅读!