likes
comments
collection
share

Flutter 封装:图片上传组件 NUploadBox/NUploadButton

作者站长头像
站长
· 阅读数 5

一、需求来源

最近遇到上传图片的需求,周末花时间封装成组件,方便复用。支持多选,显示上传百分比,支持失败重连,支持失败删除;每张图片上传成功之后都会进行 url 回调。效果如下:

选择图片

Flutter 封装:图片上传组件 NUploadBox/NUploadButton

失败重连

Flutter 封装:图片上传组件 NUploadBox/NUploadButton

二、使用示例

var selectedAssets = <XFile>[];

...

buildBody1() {
  return SingleChildScrollView(
    child: Column(
      children: [
        NUploadBox(
          items: selectedAssets
        ),
      ],
    ),
  );
}

三、源码

1、NUploadBox 源码,整个图片区域

class NUploadBox extends StatefulWidget {

  NUploadBox({
    Key? key,
    required this.items,
    this.maxCount = 9,
    this.rowCount = 4,
    this.spacing = 10,
    this.showFileSize = false,
  }) : super(key: key);

  List<XFile> items;
  /// 做大个数
  int maxCount;
  /// 每行个数
  int rowCount;

  double spacing;
  /// 显示文件大小
  bool showFileSize;

  @override
  _NUploadBoxState createState() => _NUploadBoxState();
}

class _NUploadBoxState extends State<NUploadBox> {

  final ImagePicker _picker = ImagePicker();

  late final selectedAssets = widget.items ?? <XFile>[];

  @override
  Widget build(BuildContext context) {
    return photoSection(
      items: widget.items,
      maxCount: widget.maxCount,
      rowCount: widget.rowCount,
      spacing: widget.spacing,
    );
  }

  photoSection({
    List<XFile> items = const [],
    int maxCount = 9,
    int rowCount = 4,
    double spacing = 10,
  }) {
    List<NUploadModel<XFile>> selectedModels = items.map((e){
      return NUploadModel(
        data: e,
      );
    }).toList();

    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints){
        var itemWidth = ((constraints.maxWidth - spacing * (rowCount - 1))/rowCount).truncateToDouble();
        // print("itemWidth: $itemWidth");
        return Wrap(
            spacing: spacing,
            runSpacing: spacing,
            children: [
              ...selectedModels.map((e) {
                // final size = await e.length()/(1024*1024);

                final index = selectedModels.indexOf(e);

                return Container(
                  child: Column(
                    children: [
                      ClipRRect(
                        borderRadius: BorderRadius.all(Radius.circular(8)),
                        child: SizedBox(
                          width: itemWidth,
                          height: itemWidth,
                          child: NUploadButton(
                            // id: "$index",
                            path: e.data.path ?? "",
                            urlBlock: (url){
                              e.url = url;
                              // debugPrint("e: ${e.data?.name}_${e.url}");
                              final isAllSuccess = selectedModels.where((e) =>
                              e.url == null).isEmpty;
                              debugPrint("isAllSuccess: ${isAllSuccess}");
                              if (isAllSuccess) {
                                final urls = selectedModels.map((e) => e.url).toList();
                                debugPrint("urls: ${urls}");
                              }
                            },
                            onDelete: (){
                              debugPrint("onDelete: $index");
                            },
                          ),
                        ),
                      ),
                      // buildFileSizeInfo(
                      //    length: e.data.length(),
                      //  ),
                    ],
                  ),
                );
              }).toList(),
              if (items.length < maxCount)
                InkWell(
                  onTap: () {
                    onPicker(maxCount: maxCount);
                  },
                  child: Container(
                    width: itemWidth,
                    height: itemWidth,
                    decoration: BoxDecoration(
                      color: Colors.black.withOpacity(0.1),
                      // border: Border.all(width: 1),
                      borderRadius: BorderRadius.all(Radius.circular(4)),
                    ),
                    child: Icon(Icons.add),
                  ),
                )
            ]
        );
      }
    );
  }

  Widget buildFileSizeInfo({required Future<int> length}) {
    return FutureBuilder<int>(
      future: length,
      builder: (BuildContext context, AsyncSnapshot snapshot) {
        // 请求已结束
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasError) {
            // 请求失败,显示错误
            return Text("Error: ${snapshot.error}");
          }
          // 请求成功,显示数据
          final response = snapshot.data/(1024 *1024);
          final desc = response.toStringAsFixed(2) + "MB";
          return Text(desc);
        } else {
          // 请求未结束,显示loading
          return CircularProgressIndicator();
        }
      },
    );
  }

  onPicker({
    int maxCount = 4,
    // required Function(int length, String result) cb,
  }) async {
    try {
      // 打开相册 - 支持多选
      final List<XFile> images = await _picker.pickMultiImage(
        imageQuality: 50,
      );
      if (images.isEmpty) return;
      if (images.length > maxCount) {
        BrunoUtil.showToast('最多上传$maxCount张图片');
        return;
      }

      for (var item in images) {
        if (selectedAssets.length < maxCount && !selectedAssets.contains(item)) {
          selectedAssets.add(item);
        }
      }
      debugPrint("selectedAssets:$selectedAssets");
      setState(() {});
    } catch (err) {
      debugPrint("err:$err");
      BrunoUtil.showToast('$err');
    }
  }
}

2、NUploadButton 源码,单个图片组件

class NUploadButton extends StatefulWidget {

  NUploadButton({
    Key? key,
    required this.path,
    this.urlBlock,
    this.onDelete,
    this.radius = 8,
  }) : super(key: key);


  /// 文件本地路径
  final String path;
  /// 上传成功获取 url 回调
  final ValueChanged<String>? urlBlock;
  /// 返回删除元素的 id
  final VoidCallback? onDelete;
  /// 圆角 默认8
  final double radius;

  @override
  _NUploadButtonState createState() => _NUploadButtonState();
}

class _NUploadButtonState extends State<NUploadButton> {
  /// 防止触发多次上传动作
  var _isLoading = false;
  /// 请求成功或失败
  final _successVN = ValueNotifier(true);
  /// 上传进度
  final _percentVN = ValueNotifier(0.0);

  @override
  void initState() {
    // TODO: implement initState
    onRefresh();
    super.initState();
  }

  @override
  void didUpdateWidget(covariant NUploadButton oldWidget) {
    // TODO: implement didUpdateWidget
    super.didUpdateWidget(oldWidget);
    // debugPrint("didUpdateWidget:${widget.path == oldWidget.path}");
    if (widget.path == oldWidget.path) {
      // BrunoUtil.showInfoToast("path相同");
      return;
    }

    onRefresh();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: [
        ClipRRect(
          borderRadius: BorderRadius.all(Radius.circular(widget.radius)),
          child: Image.file(
            File(widget.path),
            fit: BoxFit.cover,
          ),
        ),
        Positioned(
          top: 0,
          right: 0,
          bottom: 0,
          left: 0,
          child: buildUploading(),
        ),
      ],
    );
  }

  Widget buildUploading() {
    return AnimatedBuilder(
        animation: Listenable.merge([
          _successVN,
          _percentVN,
        ]),
        builder: (context, child) {
          if (_successVN.value == false) {
            return buildUploadFail();
          }

          final value = _percentVN.value;
          if (value >= 1) {
            return SizedBox();
          }
          return Container(
            color: Colors.black45,
            alignment: Alignment.center,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                NText(
                  data: value.toStringAsPercent(2),
                  fontSize: 16,
                  fontColor: Colors.white,
                ),
                NText(
                  data: "上传中",
                  fontSize: 14,
                  fontColor: Colors.white,
                ),
              ],
            ),
          );
        }
    );
  }

  Widget buildUploadFail() {
    return Stack(
      children: [
        InkWell(
          onTap: (){
            debugPrint("onTap");
            onRefresh();
          },
          child: Container(
            color: Colors.black45,
            // margin: EdgeInsets.only(top: 12, right: 12),
            alignment: Alignment.center,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.refresh, color: Colors.red),
                NText(
                  data: "点击重试",
                  fontSize: 14,
                  fontColor: Colors.white,
                ),
              ],
            ),
          ),
        ),
        Positioned(
          top: 0,
          right: 0,
          child: IconButton(
            padding: EdgeInsets.zero,
            constraints: BoxConstraints(),
            onPressed: widget.onDelete,
            icon: Icon(Icons.cancel, color: Colors.red,),
          ),
        ),
      ],
    );
  }


  Future<String?> uploadImage({
    required String path,
  }) async {
    // 上传url
    String uploadUrl = '图片存储地址';
    var res = await RequestManager.upload(uploadUrl, path,
        onSendProgress: (int count, int total){
          _percentVN.value = (count/total);
          // debugPrint("${count}/${total}_${_percentVN.value}_${_percentVN.value.toStringAsPercent(2)}");
        }
    );
    if (res['code'] == 'OK') {
      debugPrint("res: $res");
    }
    return res['result'];
  }

  onRefresh() {
    debugPrint("onRefresh");
    if (_isLoading) {
      debugPrint("_isLoading: $_isLoading");
      return;
    }
    _isLoading = true;
    _successVN.value = true;
    uploadImage(
      path: widget.path,
    ).then((value) {
      if (value?.isNotEmpty == false) {
        _successVN.value = false;
        debugPrint("上传失败:${widget.path}");
        return;
      }
      _successVN.value = true;
      widget.urlBlock?.call(value!);
    }).catchError((err){
      debugPrint("err:${err}");
      _successVN.value = false;
    }).whenComplete(() {
      _isLoading = false;
    });
  }
}


class NUploadModel<T> {

  NUploadModel({
    required this.data,
    this.url,
  });

  /// 上传之后的文件 url
  String? url;
  /// 挂载数据,一般是模型
  T data;
}

总结

1、图片选择库用的是 image_picker,模拟器上有点卡顿;大家可以使用别的库,图片模型核心仅需要图片文件路径 path 和 可选参数 length(图片文件大小,辅助测试)。
2、此组件稍微改造即可支持其他类型文件,大家随意修改即可;
3、此组件实现极其简单,如果有图片遗漏的场景,大家可以留言即可;

github


转载自:https://juejin.cn/post/7253251884525010999
评论
请登录