🔗

Kotlin Multiplatformでテキスト内リンクを開く

2024/08/08に公開

はじめに

アプリケーション開発において、リンクをもとにブラウザを開くような処理は大抵出てくると思いますが、各プラットフォームごとに処理を書くことになりがちで、面倒です。
Kotlin Multiplatform(以下KMP)も苦労するだろうな…と思っていたのですが、LocalUriHandler が用意されており、簡単に実装できます。デスクトップの場合、マウスカーソル周りの処理が追加で必要になるため、それについても本記事で紹介します。

AnnotatedStringの準備

単一テキスト内に複数のスタイルを適用したい場合は AnnotatedString を使用する必要があります。これを用いてテキスト内リンクの色を変更・下線を引く処理を行い、加えてリンクに関する情報を withAnnotation で追加します。

@Composable
fun App() {
    val linkUri = "https://www.jetbrains.com/kotlin-multiplatform/"
    val linkStyle = SpanStyle(
        color = MaterialTheme.colors.primary,
        textDecoration = TextDecoration.Underline
    )

    val annotated = buildAnnotatedString {
        withAnnotation(tag = "link", annotation = linkUri) {
            withStyle(linkStyle) {
                append("Kotlin Multiplatform")
            }
        }
        append(" でマルチプラットフォーム開発")
    }
    // ...
}

withAnnotationtag: Stringannotation: String をパラメータとして取り、アノテーションを認識するためのタグと、アノテーションの中身に入れる情報を渡すことができます。後々このタグを使い、クリック時にリンク情報を引っ張り出すことになります。

表示部・URLを開く処理の実装

URLを開く処理はシンプルです。LocalUriHandler がプラットフォームごとにハンドラを捕まえてくれるため、実装では openUri() に対象のリンクを渡すだけです。

val handler = LocalUriHandler.current
handler.openUri(url)

LocalUriHandler.current@Composable 関数の中でしか呼び出せないため、その点は注意が必要です。下記サンプルでは予めハンドラを取得しておき、onClick() 内で openUri() を呼び出しています。

クリック箇所の判別については、ClickableTextonClick にはクリック箇所の情報を offset として取得する事ができるため、これを利用します。getStringAnnotation を使ってクリックしたoffset位置に対象のアノテーションがあるかチェックします。

アノテーション箇所だった場合、AnnotatedString.Range<String> が返ってきて、tag でタグ、item でアノテーションの中身を取得できるので、これを使ってリンクを開けます。

@OptIn(ExperimentalTextApi::class, ExperimentalFoundationApi::class)
@Composable
fun App() {
    val linkUri = "https://www.jetbrains.com/kotlin-multiplatform/"
    val linkStyle = SpanStyle(
        color = MaterialTheme.colors.primary,
        textDecoration = TextDecoration.Underline
    )

    val annotated = buildAnnotatedString {
        withAnnotation(tag = "link", annotation = linkUri) {
            withStyle(linkStyle) {
                append("Kotlin Multiplatform")
            }
        }
        append(" でマルチプラットフォーム開発")
    }

    val handler = LocalUriHandler.current
    MaterialTheme {
        Box(Modifier.fillMaxSize()) {
            ClickableText(
                text = annotated,
                onClick = { offset ->
                    val link = annotated.getStringAnnotations(
                        tag = "link", start = offset, end = offset
                    ).firstOrNull()

                    if (link != null) {
                        handler.openUri(link.item)
                    }
                }
            )
        }
    }
}

リンク箇所にカーソルをホバーしたとき指マークにする

一応この時点でリンクを開く処理は完成しているのですが、デスクトップで動かしてみると少し違和感があります。

カーソルを合わせても指マークになりません。色でリンクだと分かりはしますが、やはりカーソルのアイコンが変わったほうが直感的です。この点を直していきます。

これについてはModifier.pointerHoverIconで解決します。コンポーザブルがホバーされたときのアイコンを変更することができるため、リンク部にホバーしているかどうかの判定と組み合わせて実装していきます。

ClickableTextonHoveronClickと同様にoffsetを取ることができますが、こちらはInt?が返ってくるのでnullの時(=ホバーされていない時)は弾く必要があります。

@OptIn(ExperimentalTextApi::class, ExperimentalFoundationApi::class)
@Composable
fun App() {
    val linkUri = "https://www.jetbrains.com/kotlin-multiplatform/"
    val linkStyle = SpanStyle(
        color = MaterialTheme.colors.primary,
        textDecoration = TextDecoration.Underline
    )

    val annotated = buildAnnotatedString {
        withAnnotation(tag = "link", annotation = linkUri) {
            withStyle(linkStyle) {
                append("Kotlin Multiplatform")
            }
        }
        append(" でマルチプラットフォーム開発")
    }

    val handler = LocalUriHandler.current
    var isLinkHovered by remember { mutableStateOf(false) }
    MaterialTheme {
        Box(Modifier.fillMaxSize()) {
            ClickableText(
                text = annotated,
                onClick = { offset ->
                    val link = annotated.getStringAnnotations(
                        tag = "link", start = offset, end = offset
                    ).firstOrNull()

                    if (link != null) {
                        handler.openUri(link.item)
                    }
                },
                onHover = { offset ->
                    if (offset == null) return@ClickableText

                    val link = annotated.getStringAnnotations(
                        tag = "link", start = offset, end = offset
                    ).firstOrNull()

                    isLinkHovered = (link != null)
                },
                modifier = Modifier.pointerHoverIcon(
                        if (isLinkHovered) PointerIcon.Hand
                        else PointerIcon.Default
                    )
            )
        }
    }
}

これで完成です👍

まとめ

KMPだとWindows, Android共にリンクの処理が LocalUriHandler 一つで解決するので、かなり簡単に実装することができます。ClickableText については少々扱いがややこしいですが、色々と使い道はあるので覚えていて損はないと思います。

という記事を書いている最中に気づいたのですが、細心のパッチノートを見るとClickableText1.7.0-alpha07 でDeprecatedになった ようです…。今のところ落ちてくる安定版は1.6.xなので大丈夫そうですが、近い将来別の方法に乗り換えた方がよさそうです。
1.7では LinkAnnotation に置き換わるようなので、最新版が降ってきたらまた記事を書き直そうと思います…

https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.7.0-beta07

参考

https://zenn.dev/warahiko/articles/68d81f9c2ce722
https://kaleidot.net/2023/10/compose-multiplatform-uri-handler/
https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).pointerHoverIcon(androidx.compose.ui.input.pointer.PointerIcon,kotlin.Boolean)

Discussion