Flutter圈子

Flutter(Web) - 制作文件点击或拖拽上传的组件

2024-09-27  本文已影响0人  Cosecant

一. 背景
因最近有需求做这个功能,因此对这个功能做了一些研究,有需要的朋友可以了解如何实现。首先,从官方pub.dev网站我找过类似的库,但是有缺陷,web不兼容MAC系统,所有我决定自己使用原生的Div+Input实现这个功能,并集成到Flutter组件上来。

效果图1:


image.png

效果图2:


image.png

效果图3:


image.png

二、实现过程

  1. 在Flutter中,若想集成原生控件,PlatformViewRegistry就是必要的,我们需要把导入的原生组件使用PlatformViewRegistry进行注册。在web实现中,我们需要导入:import 'package:web/web.dart';
    在yaml中引入web依赖:
web: ^1.0.0

编写HTML的原生组件部分,如下:

  /// 注册的原生组件构造函数
  /// - viewId: 原生组件的ID
  static HTMLDivElement create({required String viewId}) {
    return HTMLDivElement()
      ..id = viewId
      ..style.width = '100%'
      ..style.height = '100%'
      ..append(HTMLInputElement()
        ..type = 'file'
        ..style.width = '100%'
        ..style.height = '100%'
        ..style.opacity = '0');
  }

使用PlatformViewRegistry注册原生组件,如下:

// 这里的viewTypeName是这个组件注入的类型,到时候会在HtmlElementView中使用到。
// viewId使用viewType+viewId,是为了后面方便使用id查找这个组件
  platformViewRegistry.registerViewFactory(
      FileUploadView.viewTypeName,
      (viewId) => FileUploadView.create(
          viewId: '${FileUploadView.viewTypeName}-$viewId'));
  1. 编写Dart的控件部分,处理组装上去的原生组件的相关事件,比如拖拽、点击的事件。下面的类实现了上面提到的效果的功能,包含文件上传的处理(包含上传进度监听,使用的是XMLHttpRequest)。如下:
import 'package:apk_manager_web/utils/view/view_utils.dart';
import 'package:dotted_decoration/dotted_decoration.dart';
import 'package:flutter/material.dart';
import 'package:web/web.dart' hide Text;
import 'dart:js_util' as js_util;

/// 文件上传状态
enum _FileUploadStatus {
  /// 文件上传中
  progressing,

  /// 文件上传完成
  uploaded,

  /// 文件上传失败
  failed
}

/// 文件上传组件
class FileUploadView extends StatefulWidget {
  /// 注册的原生组件类型名称:file-upload-view
  static const viewTypeName = 'file-upload-view';

  /// 注册的原生组件构造函数
  /// - viewId: 原生组件的ID
  static HTMLDivElement create({required String viewId}) {
    return HTMLDivElement()
      ..id = viewId
      ..style.width = '100%'
      ..style.height = '100%'
      ..append(HTMLInputElement()
        ..type = 'file'
        ..style.width = '100%'
        ..style.height = '100%'
        ..style.opacity = '0');
  }

  /// 文件上传的字段名称
  final String fileFieldName;

  /// 文件上传的URL地址
  final String fileUploadURL;

  /// 文件上传失败回调
  final void Function(String)? onUploadFailed;

  /// 文件上传成功回调(responseText)
  final void Function(String)? onUploadSuccess;

  const FileUploadView(
      {super.key,
      this.fileFieldName = "file",
      required this.fileUploadURL,
      this.onUploadFailed,
      this.onUploadSuccess});

  @override
  State<FileUploadView> createState() => _FileUploadViewState();
}

class _FileUploadViewState extends State<FileUploadView> {
  /// 上传文件名称
  String? _uploadFileName;

  /// 文件上传进度
  double _uploadProgress = 0;

  /// 文件上传状态
  _FileUploadStatus? _uploadStatus;

  /// 处理文件上传事件
  void _handleFileUploadEvent(String id) {
    var dropZone = window.document.getElementById(id);
    dropZone
      ?..onDragOver.listen((event) {
        event
          ..preventDefault()
          ..stopPropagation();
      })
      ..onDrop.listen((event) {
        event
          ..preventDefault()
          ..stopPropagation();
        if (event is DragEvent) {
          var files = event.dataTransfer?.files;
          if (files == null || files.length == 0) return;
          var targetFile = files.item(0);
          if (targetFile != null) _uploadFile(targetFile);
        }
      });
    dropZone?.firstElementChild?.onChange.listen((event) {
      var targetFile = (event.target as HTMLInputElement).files?.item(0);
      if (targetFile != null) _uploadFile(targetFile);
    });
  }

  /// 上传文件处理
  Future<void> _uploadFile(File file) async {
    try {
      var xhr = XMLHttpRequest();
      js_util.setProperty(xhr.upload, 'onprogress',
          js_util.allowInterop((event) {
        if (event is ProgressEvent) {
          var percent = event.loaded.toDouble() / event.total.toDouble();
          setState(() {
            _uploadProgress = percent;
            _uploadStatus = _FileUploadStatus.progressing;
          });
          debugPrint(
              '${file.name} 上传进度: ${(percent * 100).toStringAsFixed(1)}%');
        }
      }));
      xhr
        ..onLoad.listen((_) {
          setState(() {
            _uploadFileName = file.name;
            _uploadProgress = 0;
            _uploadStatus = _FileUploadStatus.progressing;
          });
        })
        ..onReadyStateChange.listen((_) {
          if (xhr.readyState != XMLHttpRequest.DONE) return;
          if (xhr.status == HttpStatus.ok) {
            debugPrint('${file.name} 文件上传成功');
            setState(() => _uploadStatus = _FileUploadStatus.uploaded);
            widget.onUploadSuccess?.call(xhr.responseText);
          } else {
            debugPrint('${file.name} 文件上传失败???');
            Future.delayed(const Duration(milliseconds: 200),
                () => setState(() => _uploadStatus = _FileUploadStatus.failed));
            widget.onUploadFailed?.call(xhr.responseText);
          }
        })
        ..onError.listen((_) {
          debugPrint('${file.name} 文件上传失败...');
          Future.delayed(const Duration(milliseconds: 200),
              () => setState(() => _uploadStatus = _FileUploadStatus.failed));
          widget.onUploadFailed?.call('文件上传失败...');
        })
        ..open('POST', widget.fileUploadURL)
        ..send(file);
    } catch (err) {
      debugPrint('文件上传失败: $err');
      widget.onUploadFailed?.call('文件上传失败...');
      setState(() => _uploadStatus = _FileUploadStatus.failed);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(fit: StackFit.expand, children: [
      HtmlElementView(
          viewType: FileUploadView.viewTypeName,
          onPlatformViewCreated: (viewId) {
            debugPrint('viewId = $viewId');
            Future.delayed(
                const Duration(seconds: 1),
                () => _handleFileUploadEvent(
                    '${FileUploadView.viewTypeName}-$viewId'));
          }),
      if (_uploadFileName?.isNotEmpty != true)
        const Center(
            child: Text('点击或拖拽上传文件',
                style: TextStyle(fontSize: 14, color: Colors.grey))),
      if (_uploadFileName?.isNotEmpty == true)
        Row(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              const Padding(
                  padding: EdgeInsets.only(right: 8),
                  child: Icon(Icons.file_copy_outlined, size: 35)),
              Column(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                        (_uploadFileName ?? '').length > 12
                            ? '${(_uploadFileName ?? '').substring(0, 12)}...'
                            : (_uploadFileName ?? ''),
                        style: const TextStyle(fontSize: 14)),
                    if (_uploadStatus == _FileUploadStatus.progressing)
                      Text('正在上传${(_uploadProgress * 100).toStringAsFixed(1)}%',
                          style: const TextStyle(
                              fontSize: 11, color: Colors.grey)),
                    if (_uploadStatus == _FileUploadStatus.uploaded)
                      const _FileUploadStateView(
                          state: _FileUploadStatus.uploaded),
                    if (_uploadStatus == _FileUploadStatus.failed)
                      const _FileUploadStateView(
                          state: _FileUploadStatus.failed)
                  ])
            ])
    ]).applyToContainer(
        height: 100,
        width: double.infinity,
        padding: const EdgeInsets.all(16),
        decoration: DottedDecoration(
            shape: Shape.box,
            color: Colors.grey,
            borderRadius: BorderRadius.circular(12)));
  }
}

class _FileUploadStateView extends StatelessWidget {
  final _FileUploadStatus state;

  const _FileUploadStateView({required this.state});

  @override
  Widget build(BuildContext context) {
    return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
      Text(state == _FileUploadStatus.uploaded ? '上传成功' : '上传失败',
          style: const TextStyle(fontSize: 11, color: Colors.grey)),
      Padding(
          padding: const EdgeInsets.only(left: 5),
          child: Icon(
              state == _FileUploadStatus.uploaded
                  ? Icons.check_circle
                  : Icons.error,
              color: state == _FileUploadStatus.uploaded
                  ? Colors.green
                  : Colors.red,
              size: 15))
    ]);
  }
}

三、总结

  1. 综上,我们已经实现了文件上传组件的功能。主要包含PlatformViewRegistry的使用和使用HtmlElementView组装原生组件。
  2. 注意事项,细心的你可能发现上面的实现中,在HtmlElementView的onPlatformViewCreated方法中有一个delay的操作,这是因为当HTML被组装上去时,使用document.getElementsByXXX是无法马上找到这个HMTL的,需要有一定延时才能查找到。
  3. 在使用XMLHttpRequest的类中,我们使用了js_util,这个包使用的就是把dart方法封装成一个对象,给js调用,即xhr.upload的onprogress的设置。所以需要记住方法:js_util.allowInterop, js_util.setProperty 等。
  4. 上面的组件中,我们把上传结果通过回调函数返回给组件的构造函数,需要用户自行处理接口的真实数据。

就这样,我们完成了一个HTML的原生组件的封装,如果有需要咱们评论区一起讨论。如需转载,请注明文档的作者和来源,感谢!

上一篇 下一篇

猜你喜欢

热点阅读