できたもの
Flutterでインライン画像挿入できるメモアプリ作った pic.twitter.com/lwkykdFUQg
— かーにゃ (@popy1017) July 9, 2020
ソースコード
使用した主なパッケージ
パッケージ名 | 概要 | 使用目的 |
---|---|---|
extended_text_field | 拡張版TextField
|
今回の主役。TextFieldに画像を挿入するために使用 |
image_picker | デバイス内の画像やカメラで撮った画像を選択するやつ | 挿入する画像を選択するために使用 |
hive | NoSQL DB | メモの保存に使用。sqfliteなどの比べると高速らしいが、sqfliteでも別に良い。 |
ポイント
ポイント1 画像をTextFieldに埋め込む
これが最大のポイントで、あとはおまけのようなもの。
TextFieldに画像を埋め込むためには、上記で紹介したextended_text_field
パッケージのExtendedTextField
widgetを使う。
ExtendedTextFieldの処理の流れ
公式のサンプルが機能もりもりで解読に時間がかかったのでざっくり説明すると、
ExtendedTextField
では、入力値に変更があるたびに文字列にhtmlタグ(<img src='hoge' />
)のようなものが含まれていないかチェックし、含まれていれば、それをExtendedTextField
に挿入可能な形式に変換して表示する、といったことが行われている。
今回の画像を挿入する例では、入力値に変更があるたびに、specialTextSpanBuilder
プロパティに設定したImageSpanBuilder
が呼ばれ、タグの有無チェックが行われている。
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
を生成するコードも書かれている。
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
は使っていません。)
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が起動し選択した画像を挿入する、といった処理にした。
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);
});
}
メモへの画像挿入 pic.twitter.com/BhwS5D03Qa
— かーにゃ (@popy1017) July 10, 2020
以上で、テキストエディタへの画像挿入が完成。
ちなみに、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
パッケージを使って以下のように取得できる。
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;
}
以上。
苦労したわりにまとめるとポイント少ない。