デスクトップ向けのFlutter Webでファイルのドロップを処理する

2021/05/07に公開

ニッチすぎる環境での機能だと思うけど、自分がやった備忘として残しておく。

今回作るもの

  • Flutter Webを実行中のWebブラウザのウィンドウへ、ファイルをドラッグアンドドロップする。
  • ドロップされたファイルの種類に応じて、以下の動作を行う。
    • テキストファイルだったら、中身を表示する。
    • 画像ファイルだったら、画像を表示する。
    • その他のファイルだったら、ファイルの情報を表示する。

環境

  • Flutter 2.0.5
  • Dart 2.12.3

プロジェクトの準備

適当なフォルダでコマンドを実行し、Flutterの新しいプロジェクトを作る。ついでにnull safetyにも対応させておく。

flutter create drop_file
cd drop_file
dart migrate --apply-changes

pubspec.yamlを開き、dependenciesflutter_dropzoneを追加する。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_dropzone: ^2.0.1

flutter_dropzonepub.devのページを見れば分かるけど、Flutter Webしか対応していない

準備が済んだら必要に応じてpub getする。VSCodeを使用しているなら勝手にやってくれるので不要。

コード

lib/main.dartを、以下のように記述する。

lib/main.dart
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_dropzone/flutter_dropzone.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Drop Image',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: DropImage(title: 'Drop Image'),
    );
  }
}

class DropImage extends StatefulWidget {
  DropImage({Key? key, this.title}) : super(key: key);

  final String? title;

  
  _DropImageState createState() => _DropImageState();
}

class _DropImageState extends State<DropImage> {
  late DropzoneViewController _controller;
  String? _filename;
  int? _fileSize;
  String? _fileMIME;
  Uint8List? _fileData;
  bool _hoverFlag = false;

  // ファイルがドロップされたら情報を読み込んでから、画面を更新する。
  void _handleFileDrop(dynamic ev) async {
    // ファイル情報を読み込む。
    _filename = await _controller.getFilename(ev);
    _fileSize = await _controller.getFileSize(ev);
    _fileMIME = await _controller.getFileMIME(ev);
    _fileData = await _controller.getFileData(ev);

    // 一時的なリンクを生成して表示する。
    final url = await _controller.createFileUrl(ev);
    print(url);
    _controller.releaseFileUrl(url);

    // ホバー状態の表示を解除する。
    _hoverFlag = false;

    // ファイル情報が揃ったら描画を更新する。
    setState(() {});
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
        body: Stack(children: [
      DropzoneView(
        operation: DragOperation.move,
        cursor: CursorType.auto,
        onCreated: (ctrl) => _controller = ctrl,
        onDrop: (ev) => _handleFileDrop(ev),
        onError: (ev) => print('Error: $ev'),
        onHover: () => setState(() {
          _hoverFlag = true;
        }),
        onLeave: () => setState(() {
          _hoverFlag = false;
        }),
      ),
      Container(
        child: dropViewArea(context),
        color: _hoverFlag ? Colors.grey : null,
      ),
    ]));
  }

  Widget dropViewArea(BuildContext context) => Builder(builder: (context) {
        if (_fileMIME == null) {
          // まだドロップされていないとき。
          return Center(child: Text('ここにファイルをドロップしてね!'));
        } else if (_fileMIME!.startsWith('image')) {
          // 画像ファイルのとき。
          return Center(child: Image.memory(_fileData!));
        } else if (_fileMIME!.startsWith('text')) {
          // テキストファイルのとき。
          return Center(child: Text(utf8.decode(_fileData!)));
        } else {
          // 想定していないタイプのファイルがドロップされたとき。
          final str = [
            '対応していない形式のファイルがドロップされたよ。',
            'ファイル名:$_filename',
            'ファイルサイズ:${_fileSize}B',
            'MIMEタイプ:$_fileMIME',
          ].join('\n');
          return Center(child: Text(str));
        }
      });
}

動作イメージ

ファイルのドラッグ中

画像ファイルをドロップしたとき

テキストファイルをドロップしたとき

その他のファイルをドロップしたとき

コードの解説だったり説明だったりメモだったり

DropzoneViewStack

DropzoneViewは何も表示しないウィジェットなので、Stackを使って他のウィジェットと組み合わせる必要がある。

ということでStackで表示するウィジェットと重ねて、その背面に配置している。

DropzoneViewの引数

使いそうなものを抜粋。

onDrop:

必須。ファイルがドロップされたときに呼び出される関数(コールバック)を渡す。

コールバックへ引数として渡されるオブジェクト(上記コードだとev)はdynamicだけど、試してみた感じ、実体としてはdart:htmlFileクラスのオブジェクトっぽい。ということでプロパティからファイル名・サイズ・更新日等を直接取得できるけど、_handleFileDropでやっているような方法が正解だと思う。将来、Web以外の環境に対応するようなときにも行けるはずだし。

onCreated:

DropzoneViewが生成されたときに呼び出される関数(コールバック)を渡す。必須ではないけど、これがないと使い物にならない気がする。

コールバックに対して引数で渡されるのがDropzoneViewControllerのオブジェクト。これはあとでファイルの中身等を取得するときに使用するので、保持しておく。

onHover:onLeave:

ドロップするファイル(マウスカーソル)がウィジェットのエリアに入ったときと出たときに呼び出される関数を渡す。

上記コードのように、フラグをいじって背景色を変えてドロップ中だと分かりやすくしたりするときに使える。

operation:

ファイルをホバーしたときに、どういう動作表示にするかの値。DragOperation enumで定義されていてmovelinkといった動作を表示できる。

cursor:

ウィジェット上にマウスカーソルを重ねたときの形状を指定する。CursorType enumで定義されていて、ポインターだったり手だったりと好きに指定できる。

onLoaded:

DropzoneViewがロードされたときに呼び出される関数を渡す。使い道を思い付かない。

APIリファレンスには特に記載がないけど、このウィジェットが初めて描画されたときに呼び出されるっぽい。例えば実行時にウィンドウが初めて前面に表示されたときとか。順番としてはonCreated:の後になっている。

onError:

名前の通りエラーが発生したときに呼び出されるはず。だけどそれが具体的にどんなときなのかはよく分からない。

ファイル情報の読み取り方法

基本的なやり方

上記コードではファイルがドロップされたときに呼び出される(onDrop:に登録している)_handleFileDropメソッド内で実施している。中でawaitを使用しているのでasync

DropzoneViewControllerの各asyncメソッドを使用して、ファイルの名前等の情報と中身を取得している。このコントローラ自体は、onCreated:で取得できるもの。

getFileDataメソッドでファイルの中身を取得できる。戻り値はUint8List(要するにバイト列)なので、表示する際には適切な方法で変換してやる必要がある。具体的なやり方は後述してるけど、Dart/Flutterではバイト列からの初期化があちこちでサポートされているので簡単。

ちなみに当然といえば当然だけど、ファイルのパスを取得するようなことはできない。これはWebブラウザ上で実行されるので、セキュリティ的な理由によるもの。

ファイルへの一時的なURLの取得

ドロップされたファイルへの一時的なURLは、コントローラのcreateFileUrl()メソッドで取得できる。これは本当に一時的なもので、当該セッション内でしか有効ではない。

上記コードではprintしているだけだけど、実は通常のURLと同じように使える。つまりそのURLを指定して画像を読み込んだりできるということ。ファイルの中身を取得して~みたいな処理が不要になるので、ちょっと楽できる。

URL(というかファイル)が不要になったらreleaseFileUrl()で解放する。

dropViewAreaでの表示部の生成

MIMEタイプを元にして表示内容(画像かテキストかそれ以外か)を特定し、それぞれの表示方法を切り替えて、ファイルの中身からウィジェットを表示している。これが正攻法なのかは何とも言えないけど、ひとまず動作しているのでオッケーなんじゃないかな。

画像ファイルのときは、Image.memory()でバイト列から直接画像を読み込んでImageウィジェットを生成している。

プレーンテキストファイルのときは、utf8.decode()でバイト列をUTF-8文字列に変換してからTextウィジェットを生成している。

ただこれらは超手抜き実装で、対応していない画像フォーマットや他の文字コードのテキストファイルが渡されたようなときの対応が不十分なので、実際にはもっとちゃんとチェック・対応する必要がある。

Web以外で動かしたらどうなるの?

flutter_dropzoneはWebでしか動作しない。環境をチェックしているので、例えばWindowsネイティブでビルドして動かすと、ウィジェットにこんなエラーが表示されて動かない。

あとがき

ということで、Flutter Webのデスクトップ向けでしか使用できないという、どこに需要があるか分からない環境でのノウハウをまとめてみた。

自分がやったことの備忘なんだけど、そもそもこれをやろうとした背景として、デスクトップ環境でドロップされたファイルをいじるツールを作りたいなーと思って、ネイティブのデスクトップでできないかと調べてみたものの、現状のFlutter Desktopでは対応しておらず無理だったけど、Webなら実現可能だった、というひとまずの次善策でたどり着いたもの。

まぁ今回の方法は現状あくまでもWebでしか使用できないので、セキュリティ的にも色々と制限があってできないことが多くて困る。そのうち普通のデスクトップアプリでもファイルをドロップできるようになるのを期待してる。

Discussion