🤖

Jetpack ComposeでLinkify Textやる

に公開

はじめに

初めまして、nacatlです。
年内にブログ一個書くぞという勢いでZenn初投稿していくスタイル。
機会があって最近調べたネタを書いて一年を〆たいと思います。

経緯と目的

ここで言うLinkifyとは文字列にURLが入ってるときに、自動検出してリンクアクションにしてくれる機能のことを指しています。
AndroidViewの頃は下記のフラグで一発でした。

<TextView
    android:autoLink="web"
    />

ただ、昨今のAndroid UI開発において、UIは基本的にComposeで構成します。
そして上記のフラグはandroidx.compose.uiのバージョン1.7.6において実装されていません。

というわけで、Text Composableで文章中のURLを抽出してリンクアクションを実装する方法を調査してみました。

実装

結論から言えば以下のリポジトリに実装を載せておきました。
中身について簡単に説明していきます。

https://github.com/nacatl/ComposeLinkifySample

目指すべきゴール

Text Composable内の文章からではなく外部の変数なり定数なりからURLを入れる場合なら、公式ドキュメントに記載があります。

https://developer.android.com/develop/ui/compose/text/user-interactions?hl=ja#create-clickable-text

逆に言えば、リンク化するURLString内におけるindex(start - end)の抽出ができれば、あとはドキュメント通りにComposeのAnnotatedStringLinkAnnotationの形で付与できるはずです。

なので、方針として「リンク化するURLString内におけるindex(start - end)の抽出」からLinkAnnotationへの変換を目指します。

URLとindexの抽出と変換

参考にさせていただいたのは主にこのStackOverFlowのスレッドです。

https://stackoverflow.com/questions/66130513/linkify-with-compose-text

これを見て、自分なりの方針を固めました。

  1. Android SDK公式のandroid.text.util.LinkifyでURL判定、リンクアクションが挿入されたSpannableStringを作成する
    • 公式で処理があるなら自前でメンテナンスしたくないな感
    • しかしandroid.text.util.Linkifyは基本的にTextViewに用いるSpannableString
    • なのでText Composableに入れても反応しない。そのままでは使えないので次へ
  2. SpannableStringからSpansをgetする
  3. getしたSpansのURLindex(start - end)LinkAnnotationとしてAnnotatedStringに付与する
  4. Text ComposableAnnotatedStringを渡す
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify

/**
 * ref. [linkify-with-compose-text(stack overflow)](https://stackoverflow.com/questions/66130513/linkify-with-compose-text)
 * 一度[SpannableString]に[Linkify]で追加したリンクを抽出し、[AnnotatedString]に貼り直す
 *
 * @param spannable 元となる[SpannableString]
 * @param linkStyles リンクテキストに充てる[TextLinkStyles]
 * @param linkInteractionListener リンクタップ時のコールバック。nullでは[Intent.ACTION_VIEW]相当でURLを開く
 */
private fun AnnotatedString.Builder.linkifyFromSpannable(
    spannable: SpannableString,
    linkStyles: TextLinkStyles,
    linkInteractionListener: LinkInteractionListener?,
) {
    // 1. 公式のLinkifyでURL判定、リンクアクションが挿入されたSpannableStringを作成する
    Linkify.addLinks(
        spannable,
        Linkify.WEB_URLS, // "http://", "https://", "rtsp://", "ftp://" に対応
    )

    // 2. SpannableからSpansをgetする
    val urlSpans = spannable.getSpans(
        0,
        spannable.length,
        URLSpan::class.java,
    )

    // 3. getしたSpansの`URL`と`index(start - end)`を`LinkAnnotation`としてAnnotatedStringに付与する
    urlSpans.forEach { urlSpan ->
        val start = spannable.getSpanStart(urlSpan)
        val end = spannable.getSpanEnd(urlSpan)

        val linkAnnotation = LinkAnnotation.Url(
            url = urlSpan.url,
            styles = linkStyles,
            linkInteractionListener = linkInteractionListener,
        )
        addLink(
            url = linkAnnotation,
            start = start,
            end = end,
        )
    }
}

対応schemeについては、android.text.util.Linkify.WEB_URLSが内部実装として以下の判定を行っていることからわかります。

if ((mask & WEB_URLS) != 0) {
    gatherLinks(links, text, Patterns.AUTOLINK_WEB_URL,
    new String[] { "http://", "https://", "rtsp://", "ftp://" },
    sUrlMatchFilter, null);
}

なお、android.text.util.LinkifyにはEMAIL_ADDRESSESPHONE_NUMBERSの抽出もありますが、必要なら条件に足すか別に作るか検討すればいいと思います。

Text Composableへの接続

前節でAnnotatedString.Builderの拡張関数としてリンク抽出とLinkAnnotation付与を行えたので、あとは特に難しくありません。
Text Composableと同様にStringAnnotatedStringを受けられるようにそれぞれの口を作って配置すれば終わります。
サンプルリポジトリではいろいろ設定してますが、環境に応じてComposableの切り方やパラメータなどカスタムするのがいいと思います。
ちなみにAnnotationは後勝ちっぽいので、文字列に色付けてたりしてもURL部分だけ上書きされます。

また、ClickableText Composable1.7.0からDeprecatedなので利用していません。

@Composable
fun LinkifyText(
    text: String, // or AnnotatedString,
    ~~~~,
) {
    val linkifyText = remember(text) {
        text.linkify(
            linkStyles = linkStyles,
            linkInteractionListener = linkInteractionListener,
        )
    }
    Text(
        text = linkifyText,
        ~~~~,
    )
}
private fun String.linkify( // or AnnotatedString.linkify
    linkStyles: TextLinkStyles,
    linkInteractionListener: LinkInteractionListener?,
) = buildAnnotatedString {
    append(this@linkify)

    linkifyFromSpannable(
        spannable = SpannableString(this@linkify),
        linkStyles = linkStyles,
        linkInteractionListener = linkInteractionListener,
    )
}

サンプルリポジトリの結果スクショです。

テストテキスト https://developer.android.com/ です。

余談

検討したけど採用しなかった手法がもう一つあります。
android.text.util.LinkifySpannableStringを作るまでは一緒ですが、その後にSpannsをgetするのではなくhtmlで繋ぎこめます。

    Linkify.addLinks(
        spannable,
        Linkify.WEB_URLS, // "http://", "https://", "rtsp://", "ftp://" に対応
    )
    Text(
        text = AnnotatedString.fromHtml(
            htmlString = spannable.toHtml(),
        ),
    )

ただ、文字列そのものをhtml形式に変換してしまう都合上、副作用を懸念して今回の処理では採用を見送りました。
何かしらで使えるかもしれないので備忘録として記録しておきます。

まとめ

年内に一つブログ書きたいなという欲が急に沸いたので、Jetpack ComposeでLinkifyTextを実装する方法を自分なりにまとめてみました。
この記事が皆様のお役に立てれば幸いです。

Discussion