この記事は🎄GMOペパボエンジニア Advent Calendar 2023の20日目の記事です。
minne事業部プロダクト開発チームのtepiです。
直近の開発でJetpack Composeを使って文字列の中に画像を表示するという仕様に遭遇し、四苦八苦したのでご紹介したいと思います。
どんな物を作りたいか
minneではメッセージという機能があり、ユーザー間で情報のやり取りができるようになっています。 そのメッセージの表示と入力の際に画像をテキスト内に表示する必要がありました。メッセージには独自のマークダウンによるフォーマットが用意されています。 最終的には以下のような表示のものを作っていきます。
テキスト表示 | テキスト入力 |
---|---|
テキスト表示
Jetpack Compose以前の場合はAnnotationString
とImageSpan
クラスを利用して表示していましたが、Jetpack Composeではサポートされていません。
そこで、代替としてInlineTextContentが用意されています。
val annotatedString = buildAnnotatedString {
append("この画像を確認してください。\n")
appendInlineContent(id = "imageId")
}
val inlineContentMap = mapOf(
"imageId" to InlineTextContent(
placeholder = Placeholder(
width = 20.sp,
height = 20.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline
),
children = {
Image(
painter = painterResource(id = R.drawable.image_original),
contentDescription = "富士山",
)
}
)
)
Text(
text = annotatedString,
inlineContent = inlineContentMap,
)
上記のソースの結果は以下の通りとなります。
ただし、今回の用途には問題があります。それは見ても分かる通り、画像のサイズです。
Placeholder
にて画像をどの程度のサイズで描画するかを決めなければなりませんが、InlineTextContent
はあくまで「画像をテキストの一部として表示する
」APIであるため、テキストのサイズであるSPで指定する必要があります。つまりは、テキストの文字サイズと合わせた表示しかできないことになります。
上記のソースでも、20.sp
が指定されており、すごく小さく画像が表示されていますが、大きくすると以下のようにtextとしてのBaseLineが決まっていて表示の下限が決まっているため、それより下に表示することができません。
これは例えばImage
のmodifierにpaddingを加えたり、placeholderVerticalAlign
の値を変えても同様です。
また、minneの場合には、textに含まれる画像はウェブからダウンロードして表示する必要があり、かつ画像をダウンロードしてからじゃないと画像のサイズがわからないため、事前にサイズを指定する必要があるこの方法を取ることができませんでした。
結果、元も子もないのですが、minneではメッセージをパースしてJetpack ComposeのText
とImage
をColumn
内に並べるような実装になりました。
テキスト入力
TextFieldにおいては現時点ではInlineTextContent
の実装がありません。また、VisualTransformation
による実装も調査しましたが、表示と同様にImageSpan
相当のSpanStyleがないため実装できませんでした。よって、ImageSpan
が利用できるAndroidViewのEditText
を使って実装をしていきます。
結論から言うと上記の実装は以下の通りになっています。
var text by remember { mutableStateOf("") }
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
EditText(it).apply {
addTextChangedListener { editableText -> text = editableText.toString() }
}
},
update = { editText ->
if (editText.text.toString() != text) {
val spannableStringBuilder = SpannableStringBuilder(text)
imageRegex.findAll(text).forEach { matchResult ->
val start = matchResult.range.first
val end = matchResult.range.last + 1
spannableStringBuilder.setSpan(
ImageSpan(editText.context, R.drawable.image),
start,
end,
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
editText.text = spannableStringBuilder
editText.setSelection(text.length)
}
}
)
Button(onClick = { text += "\n[image](image_url)\n" }) {
Text("画像を追加")
}
実装のポイントは以下の通りです。
1. ImageSpanの利用
文字列から画像部分を抽出し、setSpan()
を利用してImageSpan
を適用させます。
なお、今回はソースを簡素化させるため、ImageSpan
にはDrawableのリソースを直接指定していますが、
minneのメッセージ機能では自分が持っている写真をユーザーが選択して送るため、
IntentでGoogleフォト等から選択した画像をBitmap
に変換して保持して、ImageSpan
に渡しています。
2. 画像のサイズについて
ImageSpan
では画像のサイズを指定できませんので、もし画面サイズより大きい画像の場合はそのまま画面をはみ出して表示されてしまいます。
このソースではDrawableリソース自体のサイズを事前に調整して表示させていますが、実際には実装上で何かしらサイズ指定をした上でBitmap
に変換し、表示する必要があります。
minneでは上記の通りBitmapに変換しているため、その際にちょうどいいサイズに変更するようにしています。
3. 文字列の更新について
TextFieldとは違いEditTextでの実装なので、addTextChangedListener
で受け取った最新の値をtext
に渡した際に、そのままEditText
にupdate
で渡してしまうと外部からの文字列変更となるため、入力中の文字入力が終わってしまい文字の変換ができません。
よって、ソースにもある通り、変更がある場合のみ渡すような実装にし、通常の入力時は何も起こさず外部からの入力で文字列が勝手に変わった場合のみupdateで処理を行うようにしています。
また、今回は画像を文字列の一番最後に追加する仕様となっていますが、minneでは文字列の途中にも挿入できるため、EditText
からカーソルの位置変更を取得、保持しておき、現在のカーソルの位置に対して画像を挿入するような実装になっています。
まとめ
まだまだ特殊なことをするにはJetpack Composeでは足りないケースもありますが、AndroidViewの場合は上記の文字列の更新等のようにStateと絡めると複雑になってしまうので、今後に引き続き期待していきたいです。 あとになってみればなんともない実装ではありますが、いろいろ色々と調べてたどり着いたというところで、どなたかのお役に立てると幸いです。