一. 背景
因最近有需求做这个功能,因此对这个功能做了一些研究,有需要的朋友可以了解如何实现。首先,从官方pub.dev网站我找过类似的库,但是有缺陷,web不兼容MAC系统,所有我决定自己使用原生的Div+Input实现这个功能,并集成到Flutter组件上来。
效果图1:
效果图2:
效果图3:
二、实现过程
- 在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'));
- 编写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))
]);
}
}
三、总结
- 综上,我们已经实现了文件上传组件的功能。主要包含PlatformViewRegistry的使用和使用HtmlElementView组装原生组件。
- 注意事项,细心的你可能发现上面的实现中,在HtmlElementView的onPlatformViewCreated方法中有一个delay的操作,这是因为当HTML被组装上去时,使用document.getElementsByXXX是无法马上找到这个HMTL的,需要有一定延时才能查找到。
- 在使用XMLHttpRequest的类中,我们使用了js_util,这个包使用的就是把dart方法封装成一个对象,给js调用,即xhr.upload的onprogress的设置。所以需要记住方法:js_util.allowInterop, js_util.setProperty 等。
- 上面的组件中,我们把上传结果通过回调函数返回给组件的构造函数,需要用户自行处理接口的真实数据。
就这样,我们完成了一个HTML的原生组件的封装,如果有需要咱们评论区一起讨论。如需转载,请注明文档的作者和来源,感谢!