Android mobile

Jetpack ComposeでText関連に画像を表示したい

Android mobile

この記事は🎄GMOペパボエンジニア Advent Calendar 2023の20日目の記事です。

minne事業部プロダクト開発チームのtepiです。
直近の開発でJetpack Composeを使って文字列の中に画像を表示するという仕様に遭遇し、四苦八苦したのでご紹介したいと思います。

  1. どんな物を作りたいか
  2. テキスト表示
  3. テキスト入力
    1. 1. ImageSpanの利用
    2. 2. 画像のサイズについて
    3. 3. 文字列の更新について
  4. まとめ

どんな物を作りたいか

minneではメッセージという機能があり、ユーザー間で情報のやり取りができるようになっています。 そのメッセージの表示と入力の際に画像をテキスト内に表示する必要がありました。メッセージには独自のマークダウンによるフォーマットが用意されています。 最終的には以下のような表示のものを作っていきます。

テキスト表示 テキスト入力
テキスト表示 テキストフィールドの入力

テキスト表示

Jetpack Compose以前の場合はAnnotationStringImageSpanクラスを利用して表示していましたが、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,
)

上記のソースの結果は以下の通りとなります。 inline-content結果

ただし、今回の用途には問題があります。それは見ても分かる通り、画像のサイズです。

Placeholderにて画像をどの程度のサイズで描画するかを決めなければなりませんが、InlineTextContentはあくまで「画像をテキストの一部として表示する」APIであるため、テキストのサイズであるSPで指定する必要があります。つまりは、テキストの文字サイズと合わせた表示しかできないことになります。
上記のソースでも、20.spが指定されており、すごく小さく画像が表示されていますが、大きくすると以下のようにtextとしてのBaseLineが決まっていて表示の下限が決まっているため、それより下に表示することができません。

inline-contentで40spにした結果

これは例えばImageのmodifierにpaddingを加えたり、placeholderVerticalAlignの値を変えても同様です。

また、minneの場合には、textに含まれる画像はウェブからダウンロードして表示する必要があり、かつ画像をダウンロードしてからじゃないと画像のサイズがわからないため、事前にサイズを指定する必要があるこの方法を取ることができませんでした。

結果、元も子もないのですが、minneではメッセージをパースしてJetpack ComposeのTextImageColumn内に並べるような実装になりました。

テキスト入力

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に渡した際に、そのままEditTextupdateで渡してしまうと外部からの文字列変更となるため、入力中の文字入力が終わってしまい文字の変換ができません。

よって、ソースにもある通り、変更がある場合のみ渡すような実装にし、通常の入力時は何も起こさず外部からの入力で文字列が勝手に変わった場合のみupdateで処理を行うようにしています。

また、今回は画像を文字列の一番最後に追加する仕様となっていますが、minneでは文字列の途中にも挿入できるため、EditTextからカーソルの位置変更を取得、保持しておき、現在のカーソルの位置に対して画像を挿入するような実装になっています。

まとめ

まだまだ特殊なことをするにはJetpack Composeでは足りないケースもありますが、AndroidViewの場合は上記の文字列の更新等のようにStateと絡めると複雑になってしまうので、今後に引き続き期待していきたいです。 あとになってみればなんともない実装ではありますが、いろいろ色々と調べてたどり着いたというところで、どなたかのお役に立てると幸いです。