[Flutter] 画像をインラインに挿入できるメモアプリを作成したときのポイント

# できたもの
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Flutterでインライン画像挿入できるメモアプリ作った <a href="https://t.co/lwkykdFUQg">pic.twitter.com/lwkykdFUQg</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1281201284888973312?ref_src=twsrc%5Etfw">July 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## ソースコード
https://github.com/popy1017/inline_image_memo

# 使用した主なパッケージ
| パッケージ名 | 概要 | 使用目的 |
|:--|:--|:--|
| [**extended_text_field**](https://pub.dev/packages/extended_text_field) | 拡張版`TextField` | 今回の主役。TextFieldに画像を挿入するために使用 |
| [image_picker](https://pub.dev/packages/image_picker) | デバイス内の画像やカメラで撮った画像を選択するやつ | 挿入する画像を選択するために使用 |
| [hive](https://pub.dev/packages/hive) | NoSQL DB | メモの保存に使用。sqfliteなどの比べると高速らしいが、sqfliteでも別に良い。 |

# ポイント
## ポイント1 画像をTextFieldに埋め込む
これが最大のポイントで、あとはおまけのようなもの。
TextFieldに画像を埋め込むためには、上記で紹介した`extended_text_field`パッケージの`ExtendedTextField`widgetを使う。

### ExtendedTextFieldの処理の流れ
公式のサンプルが機能もりもりで解読に時間がかかったのでざっくり説明すると、
`ExtendedTextField`では、入力値に変更があるたびに文字列にhtmlタグ(`<img src='hoge' />`)のようなものが含まれていないかチェックし、含まれていれば、それを`ExtendedTextField`に挿入可能な形式に変換して表示する、といったことが行われている。
今回の画像を挿入する例では、入力値に変更があるたびに、`specialTextSpanBuilder`プロパティに設定した`ImageSpanBuilder`が呼ばれ、タグの有無チェックが行われている。

```dart:note_edit_view.dartの一部
ExtendedTextField(
  keyboardType: TextInputType.multiline,
  autofocus: true,
  maxLines: 100,
  specialTextSpanBuilder: ImageSpanBuilder( // <- タグの有無をチェックして、変換する
    showAtBackground: true,
  ),
  controller: _controller,
  focusNode: _focusNode,
  decoration: InputDecoration(
    border: InputBorder.none,
  ),
),
```

`ImageSpanBuilder`は`extended_text_field`パッケージが提供している`SpecialTextSpanBuilder`クラスの拡張であり、後述する`SpecialText`(`ExtendedTextField`に表示できる形式の画像など)を生成している。
ちなみに、以下は公式のサンプルからほぼコピペしてきたもので、前半部分はあまり理解していないが、後半の`if (isStart(flag, ImageText.flag))`で画像タグの有無をチェックし、もしあればそれを`ImageText`に変換している。
サンプルでは、絵文字だったり、メンション(`@TanakaTaro`のような)テキストを表示する`SpecialText`を生成するコードも書かれている。

```dart:image_span_builder.dart
class ImageSpanBuilder extends SpecialTextSpanBuilder {
  ImageSpanBuilder({this.showAtBackground = false});

  /// whether show background for @somebody
  final bool showAtBackground;
  @override
  TextSpan build(String data,
      {TextStyle textStyle, SpecialTextGestureTapCallback onTap}) {
    final TextSpan textSpan =
        super.build(data, textStyle: textStyle, onTap: onTap);
    return textSpan;
  }

  @override
  SpecialText createSpecialText(String flag,
      {TextStyle textStyle, SpecialTextGestureTapCallback onTap, int index}) {
    if (flag == null || flag == '') {
      return null;
    }

    ///index is end index of start flag, so text start index should be index-(flag.length-1)
    if (isStart(flag, ImageText.flag)) {
      return ImageText(textStyle,
          start: index - (ImageText.flag.length - 1), onTap: onTap);
    }

    return null;
  }
}
```
`ImageText`は`extended_text_field`パッケージが提供している`SpecialText`クラスの拡張であり、タグに指定してある属性の値などを読み取り、表示するためのWidgetである`ExtendedWidgetSpan`を返している。
(こちらも公式のサンプルのほぼコピペです。`width`と`height`は使っていません。)

```dart:image_text.dart
class ImageText extends SpecialText {
  ImageText(TextStyle textStyle,
      {this.start, SpecialTextGestureTapCallback onTap})
      : super(
          ImageText.flag,
          '/>',
          textStyle,
          onTap: onTap,
        );

  static const String flag = '<img';
  final int start;
  String _imageUrl;
  String get imageUrl => _imageUrl;
  @override
  InlineSpan finishText() {
    ///content already has endflag '/'
    final String text = flag + getContent() + '>';

    ///'<img src='$url'/>'
//    var index1 = text.indexOf(''') + 1;
//    var index2 = text.indexOf(''', index1);
//
//    var url = text.substring(index1, index2);
//
    ////'<img src='$url' width='${item.imageSize.width}' height='${item.imageSize.height}'/>'
    final Document html = parse(text);

    final Element img = html.getElementsByTagName('img').first;
    final String url = img.attributes['src'];
    _imageUrl = url;

    ~~

    return ExtendedWidgetSpan(
      start: start,
      actualText: text,
      child: GestureDetector(
        onTap: () {
          onTap?.call(url);
        },
        child: ExtendedImage.asset(
          url,
          fit: fit,
        ),
      ),
    );
  }
  ~~
}
```
`ExtendedTextField`→`SpecialTextSpanBuilder`→`SpecialText`の流れができたら、あとは`ExtendedTextField`にhtmlタグを挿入するだけ。
挿入には、公式のサンプルで使われていた`insertText`関数をそのまま使った。
`insertText`関数では何やら複雑なことが行われているようだが、`_controller.text += '<img src=\'hoge\' />'`といった書き方でも動くので、そっちの方が簡単かもしれない。

今回作ったアプリでは、`image_picker`パッケージと組み合わせて、画像アイコンを押すとPickerが起動し選択した画像を挿入する、といった処理にした。

```dart:note_edit_view.dartの一部
  Future getImage() async {
    final PickedFile pickedFile =
        await _picker.getImage(source: ImageSource.gallery);

    if (pickedFile == null) {
      // キーボードを表示する処理
      SystemChannels.textInput.invokeMethod('TextInput.show');
      return;
    }

    setState(() {
      insertText(
          '<img src=\'${pickedFile.path}\' width=\'300\' height=\'300\'/>\n');
      _images.add(pickedFile);
    });
  }
```

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">メモへの画像挿入 <a href="https://t.co/BhwS5D03Qa">pic.twitter.com/BhwS5D03Qa</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1281385702421196801?ref_src=twsrc%5Etfw">July 10, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

以上で、テキストエディタへの画像挿入が完成。

>ちなみに、htmlタグの名前と一致している必要はないので、`<img ~`ではなくてもOK。
逆に、手動で`<img />`と入力しようとすると、最後の`>`が入力できなくなるのでタグ名を複雑な文字列にした方が良いかもしれない。

## ポイント2 画像付きのメモを保存する
#### 要点
* image_pickerで選択した画像はアプリの一時的な領域に保存されるため、画像付きのメモを保存する場合は永続的な領域に画像を保存し直す必要がある
* 永続的な領域として、アプリ固有のストレージ領域があるが、その領域へのパスが固定ではないため、保存する時は相対パスで、参照するときに逐一絶対パスに変換する必要がある

### 本題
`ExtendedTextField`に入力された文字列は、結局はただの文字列なので、画像付きのメモを保存するのは簡単である。例えば、`'メモ1\n<img src=\'filePath\' />\nメモ1の画像'`のようなただの文字列をDBなどに保存すれば良いだけである。
問題は、`filePath`に何を指定するかである。
どういうことかと言うと、`image_picker`で選択された画像はアプリの**一時的な領域に保存される**ので、選択した際はそのパスを使って表示すれば良いが、いつ消えてもおかしくないので、次にアプリを起動した時には画像が見つからない、といったことになりかねない。
そのため、画像を選択してメモを保存するまではアプリの一時的な領域を参照し、メモが保存される時や再度編集する場合などではアプリの永続的な領域に保存した画像を参照する必要がある。

今回は、メモ自体はDB(`hive`)に、画像はアプリのストレージ領域に保存することにしたが、DBに直接画像を保存した方が簡単かもしれない。(`sqlite`や`hive`にはバイナリを保存できるが、画像の大きさやデータ数によってはパフォーマンスに悪影響があるらしい。)

アプリのストレージ領域へのパスは、`path_provider`パッケージを使って以下のように取得できる。

```dart:file_helper.dartの一部
  Future<void> loadLocalPath() async {
    final directory = await getApplicationDocumentsDirectory();
    _localPath = directory.path;
  }
```
ここで注意しないといけないのが、`getApplicationDocumentsDirectory()`の結果が毎回異なる、ということである。(= アプリ固有のストレージ領域へのパスが変わる)
つまり、例えばアプリ起動時に`getApplicationDocumentsDirectory()`を実行する場合を考えると、その結果を使った絶対パスで画像を指定しているメモを保存してしまうと、次回起動時などにその画像が参照できなくなってしまう、ということである。

>例えば、1回目の起動に得られるストレージ領域のパスが`AAA`、2回目の起動時が`BBB`だとすると、1回目の起動時に`AAA/image.jpg`に保存したファイルは、2回目の起動時は`BBB/image.jpg`で参照しないといけない。

これは、iOSの仕様らしいが、対処方法としては、相対パスで保存して参照するときに絶対パスに変換する。
https://github.com/flutter/flutter/issues/23957

今回作成したアプリで保存した画像を参照したいタイミングは、以下の2つ。

* メモ一覧画面で、メモに含まれている一番上の画像を表示する
* メモ編集画面で、メモに含まれている画像をすべて表示する

これらを表示する際に、そのときのアプリストレージへのパスを使って、相対パスを絶対パスに変換する。
(なんか冗長な気もするので、もっとスマートなやり方がありそう。。。)

```dart:メモ一覧画面で、メモに含まれている一番上の画像を表示する
  Widget _noteImage(String text) {
    // メモに含まれている相対パスの画像名を取ってくる
    List<String> imageNames = _fileHelper.findImageNames(text);

    if (imageNames.length > 0) {
      // 絶対パスに変換する
      final String absolutePath =
          _fileHelper.relativePathToAbsolutePath(imageNames[0]);

      // Image widgetを返す
      return Image.file(
        File(absolutePath),
        fit: BoxFit.cover,
      );
    }

    return Image.network(
      'https://cafe-ajara.com/wp/wp-content/themes/ajara-new/images/no-image.png',
      fit: BoxFit.cover,
    );
  }
```

```dart:メモ編集画面で、メモに含まれている画像をすべて表示する
  @override
  void initState() {
    super.initState();

    if (isUpdateMode) {
      // 初期化処理時に、相対パスから絶対パスに変換して、それをinsertする
      final String textWithAbsolutePath =
          _replaceRelativePathWithAbsolutePath(widget.note);
      insertText(textWithAbsolutePath);
    }
  }

  Future<void> _saveNote() async {
    String _currentText = _controller.text;

    // 現在 指定している画像パスは一時的な領域にあるものなのでアプリ領域に画像を保存する
    for (int i = 0; i < _images.length; i++) {
      if (_currentText.contains(_images[i].path)) {
        await _fileHelper.saveImage(File(_images[i].path));
      }
    }

    _currentText = _replateAbsolutePathWithRelativePath(_currentText);

    if (isUpdateMode) {
      DBHelper.dbHelper.update(widget.noteIndex, _currentText);
    } else {
      DBHelper.dbHelper.add(_currentText);
    }

    Navigator.pop(context);
  }

  String _replaceRelativePathWithAbsolutePath(String text) {
    String newText = text;

    // <img src='' />のsrcに指定されている文字列をすべて取ってくる
    List<String> relativeImageNames = _fileHelper.findImageNames(text);

    for (int i = 0; i < relativeImageNames.length; i++) {
      final String absoluteImageName =
          _fileHelper.relativePathToAbsolutePath(relativeImageNames[i]);
      newText = newText.replaceAll(relativeImageNames[i], absoluteImageName);
    }
    return newText;
  }

  String _replateAbsolutePathWithRelativePath(String text) {
    String newText = text;
    List<String> absoluteImageNames = _fileHelper.findImageNames(text);

    for (int i = 0; i < absoluteImageNames.length; i++) {
      final String relativePath =
          _fileHelper.absolutePathToRelativePath(absoluteImageNames[i]);
      newText = newText.replaceAll(absoluteImageNames[i], relativePath);
    }
    return newText;
  }
```

以上。
苦労したわりにまとめるとポイント少ない。