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

一. 背景
因最近有需求做这个功能,因此对这个功能做了一些研究,有需要的朋友可以了解如何实现。首先,从官方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的原生组件的封装,如果有需要咱们评论区一起讨论。如需转载,请注明文档的作者和来源,感谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容