11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

できたもの

ソースコード

使用した主なパッケージ

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

ポイント

ポイント1 画像をTextFieldに埋め込む

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

ExtendedTextFieldの処理の流れ

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

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,
  ),
),

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

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;
  }
}

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

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,
        ),
      ),
    );
  }
  ~~
}

ExtendedTextFieldSpecialTextSpanBuilderSpecialTextの流れができたら、あとはExtendedTextFieldにhtmlタグを挿入するだけ。
挿入には、公式のサンプルで使われていたinsertText関数をそのまま使った。
insertText関数では何やら複雑なことが行われているようだが、_controller.text += '<img src=\'hoge\' />'といった書き方でも動くので、そっちの方が簡単かもしれない。

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

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);
    });
  }

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

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

ポイント2 画像付きのメモを保存する

要点

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

本題

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

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

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

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つ。

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

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

メモ一覧画面で、メモに含まれている一番上の画像を表示する
  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,
    );
  }
メモ編集画面で、メモに含まれている画像をすべて表示する
  @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;
  }

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

11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?