震惊!!Flutter竟然可以完美复刻微信媒体操作

2,257 阅读12分钟

在移动应用开发中,文件上传和预览功能是几乎所有社交应用都需要具备的重要功能,尤其是在涉及图片和视频上传时。用户对于这些功能的期望,通常要求界面流畅、操作简便。而在众多社交软件中,微信无疑是最受欢迎的应用之一,它的文件上传与预览功能可谓是做得相当完善。

那么问题来了:有没有可能使用 Flutter 完美复刻微信中的文件上传和预览功能?

答案是肯定的!接下来,我们将一起探讨如何在 Flutter 中构建一个类似微信的文件上传与预览功能,并实现用户所期望的流畅体验。

为什么选择 Flutter?

Flutter 是一个由 Google 推出的开源跨平台开发框架,它可以帮助开发者通过一套代码同时构建 Android 和 iOS 应用。Flutter 不仅支持优秀的跨平台开发体验,还具备高性能和流畅的 UI 渲染能力。在构建复杂的用户界面和交互时,Flutter 提供了更多的灵活性和控制。

对于文件上传和媒体预览功能的实现,Flutter 提供了强大的组件和丰富的第三方库支持,这使得我们可以轻松复刻出微信的文件上传和预览功能。

如何复刻微信的文件上传与预览功能?

在微信中,文件上传和预览功能不仅支持图片和视频文件的选择,还具备清晰的上传进度、文件删除以及文件预览功能。通过合理的组件组合和第三方库,我们可以在 Flutter 中完美复刻这些功能。

1. 文件选择:支持多种来源

微信中的文件选择功能支持从相册、文件管理器中选择图片和视频,还可以直接使用相机拍照或录像。要实现这一点,我们需要使用以下两款第三方库:

  • wechat_assets_picker:用于从相册中选择图片、视频或其他媒体文件。
  • wechat_camera_picker:用于调用相机拍摄照片和录像。

这两款库的使用非常简单,我们可以通过它们来实现多种来源的文件选择功能。 同时我们了解到,微信的媒体功能是多样化的,但是首先,我们可以先实现一个弹窗,用于我们后续的媒体多样化选择

示例代码:媒体选择弹窗

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,
            ),
          ],
        ),
      ),
    );
  }
}


这段代码定义了一个自定义的Flutter控件,名为 MediaPickerMenu,用于实现选择媒体(如拍照、从图库选择图片或视频等)功能的菜单。让我们逐步分析这段代码,帮助你理解每一部分的含义和功能。

第一部分: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();
}

解释:

  1. MediaPickerMenu 是一个 StatefulWidget,意味着它有状态,可以在运行时进行变化。

  2. 该控件接受四个参数:

    • availableOptions:一个 List<uploadOptions>,表示用户可以选择的上传选项(例如拍照或从图库选择)。
    • maxFiles:一个整数,表示最大文件上传数量(这个参数在当前代码中并没有实际使用,但可以扩展功能时用到)。
    • takeAsset:一个回调函数,表示拍照或录像的操作。
    • selectAssets:另一个回调函数,表示选择图片或视频的操作。
  3. createState 方法会返回一个 _MediaPickerMenuState 的实例,用于管理 MediaPickerMenu 的状态。

第二部分:_MediaPickerMenuState

class _MediaPickerMenuState extends State<MediaPickerMenu> {
  final throttle = Throttle(milliseconds: 1000); // 1秒节流时间

解释:

  1. _MediaPickerMenuState 负责管理 MediaPickerMenu 的具体UI逻辑和状态。
  2. throttle 是一个节流工具,确保在1秒内不会重复触发某个操作。它的作用是防止用户快速点击菜单项时,触发过多的操作。比如,如果点击拍照或者选择图库时,它会限制1秒内只能触发一次操作。

第三部分:_buildPickerOption 方法

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(
              color: Colors.grey.shade200,
              height: 0.5,
            )
          : Container()
    ],
  );
}

解释:

  1. 该方法构建了一个菜单项。每个菜单项包含一个图标、标题以及点击时的响应。
  2. GestureDetector 是一个手势识别控件,用于识别用户的点击事件(onTap)。
  3. throttle.run 确保点击操作不会频繁执行。
  4. 点击后,Navigator.of(context).pop() 会关闭当前的菜单弹窗。
  5. onTapAction() 是传入的回调函数,它会在点击后执行对应的操作,比如拍照或选择文件。
  6. 如果 isShowBordertrue,则显示一个分割线,用于视觉分隔菜单项。

第四部分:build 方法

@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,
          ),
        ],
      ),
    ),
  );
}

解释:

  1. build 方法是 Flutter 控件的核心构建方法,用于构建控件的UI。

  2. 使用 Wrap 控件可以将多个子控件包装成可自动换行的布局。

  3. 菜单项通过 _buildPickerOption 方法动态构建:

    • 如果 availableOptions 中包含 uploadOptions.camera,则显示“拍摄”项。
    • 如果 availableOptions 中包含 uploadOptions.image,则显示“图库”项。
    • 最后,添加一个“取消”项,它没有边框且点击后不会执行任何操作(仅关闭菜单)。

弹窗效果:

ee71db8a64b841854d2cc674ff640ebd.gif

既然我们已经实现了弹窗的样式和功能,接下来就是我们媒体选择和拍照的功能了

示例代码:拍摄/图片功能实现

拍摄和选择功能的实现 我们需要实现拍照和从相册选择文件的功能。我们分别定义两个方法 _takeAsset_selectAssets 来处理拍照和选择文件的逻辑。

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,
                    ),
                  ),
                ),
            ],
          );
  }
}

第一部分: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();
}

解释:

  1. ImageUploadWidget 是一个 StatefulWidget,表示该控件有状态,UI 可以根据数据变化进行更新。

  2. 该控件接收以下参数:

    • onUploadComplete:上传完成后回调,返回上传文件的 URL 列表。
    • size:每个文件展示框的大小,默认为 80。
    • maxFiles:最大上传数量,默认为 9。
    • availableOptions:一个列表,表示用户可选择的上传选项(包括拍照、从图库选择和选择视频)。
    • onChangeStatus:状态变化的回调,用于通知父组件上传状态的变化。
    • child:一个可选的子组件,允许外部传入自定义内容。

第二部分:_ImageUploadWidgetState

class _ImageUploadWidgetState extends State<ImageUploadWidget> {
  List<File> _selectedFiles = []; // 当前选择的文件
  Map<File, uploadStatus> _uploadStatus = {}; // 文件的上传状态
  List<Map> _fileUrls = []; // 上传成功的网络地址列表

解释:

  1. _ImageUploadWidgetStateImageUploadWidget 的状态类,负责处理文件选择、上传和状态更新等操作。

  2. 定义了以下状态变量:

    • _selectedFiles:当前已选择的文件列表。
    • _uploadStatus:一个映射,记录每个文件的上传状态。
    • _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,
      );
    },
  );
}

解释:

  1. _showPickerMenu 方法打开底部选择菜单,显示用户可以选择的上传选项(例如拍照、从图库选择文件或视频)。
  2. MediaPickerMenu 控件接受 availableOptions(可选项)、maxFiles(剩余上传次数)以及处理拍照和选择文件的回调 takeAssetselectAssets

第四部分:上传文件

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;
            }
          });
        });
        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');
  }
}

解释:

  1. _uploadFiles 方法负责处理文件的上传逻辑。
  2. 使用 FileUploader 上传文件,上传过程中,文件的状态会更新为 "上传中"。
  3. 上传成功后,会返回文件的网络 URL 和其他信息,并将这些信息添加到 _fileUrls 列表中,同时更新文件状态为 "上传成功"。
  4. 上传失败时,文件的状态更新为 "上传失败"。

第五部分:删除文件

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 数组变化
  });
}

解释:

  1. _deleteFile 方法允许用户删除已选择的文件。
  2. 删除文件后,更新 _selectedFiles_uploadStatus_fileUrls,并通知父组件上传完成的 URL 列表发生变化。

效果演示:

ee71db8a64b841854d2cc674ff640ebd.gif

第六部分:选择图片或视频

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);
    }
  }
}

解释:

  1. _selectAssets 方法允许用户选择图片或视频。选择时,调用 AssetPicker 来选择文件,并根据 availableOptions 设置选择类型(图片或视频)。
  2. 选择的文件会添加到 _selectedFiles 列表中,并设置文件的上传状态为 "待上传"。
  3. 立即开始上传选中的文件。

效果演示:

ee71db8a64b841854d2cc674ff640ebd4.gif

第七部分:拍照或录像

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]);
   }
 }
}

解释:

  1. _takeAsset 方法允许用户使用摄像头拍照或录像。
  2. 如果用户拍照或录像成功,会将文件添加到 _selectedFiles 列表,并立即上传。

效果演示:

1733823236734.jpg

示例代码:缩略图
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,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

FilePreview 组件,用于展示上传的文件预览。它支持显示图片和视频文件的预览,对于视频文件还生成了缩略图。组件还提供了删除功能,允许用户删除已选择的文件。### 1. 类定义与构造函数

FilePreview 是一个 StatefulWidget,用来展示单个文件的预览。它有以下属性:

  • file: 要展示的文件,类型为 File
  • status: 文件的上传状态,类型为 uploadStatus(一个枚举值)。
  • onDelete: 点击删除按钮时调用的回调函数,类型为 VoidCallback

构造函数需要这三个参数,并传递给 State_FilePreviewState

2. 状态管理 (_FilePreviewState)

_FilePreviewState 负责文件预览的实现。主要包含:

  • _thumbnailFuture: 一个 Future<Uint8List?> 类型的变量,用于存储视频文件的缩略图数据。当文件是视频时,_thumbnailFuture 会被赋值为生成缩略图的异步操作。
  • initState 方法中,检查文件是否为视频(通过文件扩展名 .mp4),如果是视频,则通过 VideoThumbnail.thumbnailData 方法生成缩略图。thumbnailData 返回一个包含缩略图数据的 Uint8List

3. 界面构建 (build)

build 方法返回一个 Stack 小部件,堆叠显示文件的预览和删除按钮。

  • 文件预览

    • 使用 FutureBuilder 异步加载视频文件的缩略图。

    • FutureBuilder 根据 _thumbnailFuture 的状态渲染不同内容:

      • 正在加载时:显示一个圆形加载指示器(CircularProgressIndicator)。
      • 有缩略图时:如果文件是视频且成功生成了缩略图,使用 MemoryImage 显示缩略图。
      • 视频文件无缩略图时:如果没有生成缩略图,则显示一个默认的图标(Icons.broken_image)表示损坏的图片。
      • 图片文件:如果文件是图片,直接显示图片的 FileImage
  • 删除按钮

    • 使用 Positioned 小部件在预览的右上角展示一个删除按钮,按钮是一个圆形的 CircleAvatar,点击后调用 onDelete 回调函数。

4. 关键方法与功能

  • 生成缩略图 (_thumbnailFuture) :对于视频文件,使用 VideoThumbnail.thumbnailData 方法生成缩略图数据。FutureBuilder 会监听这个 Future 并在缩略图加载完成后进行渲染。
  • 删除文件 (onDelete) :删除按钮的 onTap 事件会触发 widget.onDelete,这个回调通常会在父组件中处理文件的删除逻辑。

5. 布局与样式

  • 文件预览布局

    • Container 包含了 DecorationImage,用于展示文件的图像或视频缩略图。
    • 使用 BoxFit.cover 来确保图像或者缩略图填充容器并且保持宽高比。
    • 使用 BorderRadius.circular(8) 为容器添加圆角。
  • 删除按钮布局

    • Positioned 小部件将删除按钮固定在预览框的右上角。
    • 使用 CircleAvatar 来创建圆形按钮,Icons.close 作为删除图标。

还有我开发过程中所自定义的枚举类型:

enum uploadOptions {
  camera, // 拍照
  gallery, // 选择图片
  video, // 选择视频
  file, // 选择文件
}

//上传状态
enum uploadStatus {
  pending,
  uploading,
  success,
  failure,
}

虽然FLUTTER很迪奥,但是VUE总有一天会统治世界!!

完结撒花❀