🔽

Flutter WebでCSVダウンロードする

2021/10/09に公開

タイトル通り、下記 gif のようなものです。

CHANGELOG

はじめに

Flutter Web は日々進化しているものの、まだ一般的な Web アプリケーションとして提供するには不足している部分が多い現状です。

https://zenn.dev/tsuruo/articles/773a5a7ca14924

一方で、簡単なポートフォリオサイトや管理画面のような「細かい部分は気にせずブラウザで動かしたい」要件については、十分使うことができます。今回は、管理画面を作る上で必須となる「CSV ダウンロード」の機能を見ていきます。

dart:io libraryは使えない

Dart は I/O 処理が言語レベルで整っており、iOS/Android でのファイル操作など基本的に dart:io library を使えば済むことが多いです。

https://api.dart.dev/stable/2.14.3/dart-io/dart-io-library.html

しかし、ドキュメントに明記されている通り Web アプリケーション(Flutter Web)ではこのライブラリを利用できません。

File, socket, HTTP, and other I/O support for non-web applications.
Important: Browser-based apps can't use this library. Only the following can import and use the dart:io library:

  • Servers
  • Command-line scripts
  • Flutter mobile apps
  • Flutter desktop apps

[追記: 2023.05.21]
私は未検証ですが universal_io という、dart:io をすべてのプラットフォームで利用できるパッケージがありますので、これを試すと楽に実装できるかもしれません。

https://pub.dev/packages/universal_io

AnchorElementクラス

本記事の主旨である、AnchorElement クラスです。dart:html library にあるクラスであり、HtmlElement class - dart:html library - Dart API を継承しています。HtmlElement クラスを見るとわかりますが、前述の AnchorElement クラスの他に、BodyElement や TextAreaElement など HTMLElement に対応するクラスが多数用意されています。これらクラスを用いることで、JavaScript で行なうような DOM 操作ができるわけですね。

https://api.flutter.dev/flutter/dart-html/AnchorElement-class.html

MDN のドキュメントに HTMLAnchorElement について記載されています。確かではないですが、この Web API の Dart ラッパーが dart:html library であると解釈して問題無さそうです(Web と同じ API が Dart のクラスとして提供されている)。

https://developer.mozilla.org/ja/docs/Web/API/HTMLAnchorElement

つまり、この AnchorElement クラスを使い HTML 属性に download を付ければ想定の挙動が実現できそうだと想像がつきます。今回のファイルダウンロード機能の他に HTML 属性を使用した機能を実装したい場合にも、対応した HtmlElement クラスを使って実装すれば良さそうですね。

実装する

CSV の整形については csv | Dart Package が便利です。

pubspec.yaml
dependencies:
  csv:

Model

よくある一般的な User モデルを適当に用意しました。

user.dart
user.dart

class User {
  const User({
    required this.name,
    required this.age,
    required this.blood,
    required this.birthDate,
  });
  final String name;
  final int age;
  final Blood blood;
  final DateTime birthDate;

  List<String> toCsvFormat() => [
        name,
        '$age',
        describeEnum(blood).toUpperCase(),
        DateFormat('yyyy/MM/dd').format(birthDate),
      ];
}

特筆することはないですが、CSV フォーマット前提のモデルなので下記のような整形メソッドを用意しておくと良いと思います。一部微妙な実装もありますが、本記事の主旨ではないのでスルーします。

user.dart
List<String> toCsvFormat() => [
        name,
        '$age',
        // Dart2.15以降ではEnum拡張の`name`が使えるようになります
        // `Blood.values.name.toUpperCase()`
        describeEnum(blood).toUpperCase(),
        // DateFormatインスタンスの生成コストが余計なので別で定義した方がベター
        DateFormat('yyyy/MM/dd').format(birthDate),
      ];

ダミーデータ

Perfume の3人です。綺麗に歳を重ねて感慨深いですね。.。

user.dart
// ref. https://www.perfume-web.jp/profile/
final users = [
  User(
    name: 'a-chan',
    age: 32,
    blood: Blood.a,
    birthDate: DateTime(1989, 2, 15),
  ),
  User(
    name: 'NOCCHi',
    age: 33,
    blood: Blood.a,
    birthDate: DateTime(1988, 9, 20),
  ),
  User(
    name: 'KASHIYUKA',
    age: 32,
    blood: Blood.a,
    birthDate: DateTime(1988, 12, 23),
  ),
];

CSVダウンロード処理

最後にメインの CSV ダウンロード処理です。AnchorElement で HTML 属性の download を指定します。対象のアンカーリンクをクリックすることで CSV がブラウザからダウンロードされる挙動となります。setAttribute に指定する属性は、MDN のドキュメントに HTML 属性のリファレンスがあるので参照すると良いと思います。download 属性もこの中にあります。
https://developer.mozilla.org/ja/docs/Web/HTML/Attributes

final header = ['name', 'age', 'blood', 'birth'];
final rows = users.map((u) => u.toCsvFormat()).toList();
final csv = const ListToCsvConverter().convert(
    [header, ...rows],
);
AnchorElement(href: 'data:text/plain;charset=utf-8,$csv')
    ..setAttribute('download', 'users.csv')
    ..click();

また、ダウンロードした CSV を Excel で開く場合に日本語文字が含まれていると化けてしまうので、必要に応じて UTF-8 BOM を付与するメソッドに拡張しました(下記)。BOM の付与については csv パッケージに該当のイシューがありました。
https://github.com/close2/csv/issues/41#issuecomment-899038353

csv_download.dart
csv_download.dart
void csvDownload({
  required List<String> header,
  required List<List<String>> rows,
  bool utf8BOM = false,
}) {
  AnchorElement anchorElement;
  if (utf8BOM) {
    // Excelで開く用に日本語を含む場合はUTF-8 BOMにする措置
    // ref. https://github.com/close2/csv/issues/41#issuecomment-899038353
    final csv = const ListToCsvConverter(fieldDelimiter: ';').convert(
      [header, ...rows],
    );
    final bomUtf8Csv = [0xEF, 0xBB, 0xBF, ...utf8.encode(csv)];
    final base64CsvBytes = base64Encode(bomUtf8Csv);
    anchorElement = AnchorElement(
      href: 'data:text/plain;charset=utf-8;base64,$base64CsvBytes',
    );
  } else {
    final csv = const ListToCsvConverter().convert(
      [header, ...rows],
    );
    anchorElement = AnchorElement(
      href: 'data:text/plain;charset=utf-8,$csv',
    );
  }
  anchorElement
    ..setAttribute('download', 'users.csv')
    ..click();
}

おまけ

今回は、主に Dart の HTMLElement クラスについて紹介した内容となりました。「CSV ダウンロードはそこまで躓くポイントはないだろう」と思って調べてみたら dart:io ライブラリが使えず、はじめて使うク、ラスがいくつか出てきたので記事にしました。

ところで、Perfume3名の名前表記が大文字小文字で統一されていないことを不思議に思いませんでしたか?
実はこれは誤りではなく公式に発表されています。英語表記は彼女たちがワールド進出する際にファンの間で話題になったのですが、その表記の変遷や背景も一緒に紹介しておきます。

  • あ〜ちゃん:「a-chan」ですべて小文字
    • 海外の人に伸ばして発音してもらえないため - を表記
    • 小文字にすることで一癖ありそう、興味を持ってもらいたいとラジオで公言
    • 「Aa-CHAN」の時期もあったが、A が A,B,C..。の見出しと誤解されたため削除
  • のっち:「NOCCHi」で本人の希望により最後の i のみ小文字(Apple オマージュ?)
  • かしゆか:「KASHIYUKA」ですべて大文字

(参考)Perfume「あ〜ちゃん」の英語表記が「A-CHAN」から「Aa-CHAN」へ変更される
(参考)Perfumeって何? Perfume(a-chan,KASHIYUKA,NOCCHi)

参考

Discussion