Jetpack Compose でテキスト内リンクを実装する
Jetpack Compose におけるテキスト内リンクは、Jetpack Compose Roadmap では現在 In Focus となっていますが、 AnnotatedString
を用いることで実装可能です。
クリックを処理する部分は Android Developers にも記載がありますが、本記事ではさらにタップ時のフィードバックについても触れます。
やりたいこと
- テキスト内にリンクを配置する
- リンクをタップしたことをユーザーにフィードバックする
環境
- Kotlin: 1.9.10
- Compose Compiler: 1.5.3
- Compose BoM: 2023.10.01
テキスト内にリンクを配置する
実装を以下に示します。
表示は以下のようになり、「こちら」の周囲をタップすることで onClickTextLink
で渡した処理が実行されます。
リンクとなる箇所には withAnnotation
で tag
, annotation
を付加します。
(こちらは現在実験的 API とされているため、使用時には OptIn が必要となります。これを用いずに実装する場合は pushStringAnnotation
及び pop
を用います。)
その際、 withStyle
を用いて見た目を変えることでユーザーになにがタップ可能かを伝えられるとよりよさそうです。
ClickableText
はクリックイベントを処理できるコンポーネントであり、onClick
でタップ時のオフセット(文字単位)を受け取ることができます。
AnnotatedString#getStringAnnotations
で AnnotatedString.Range
を取得し、 item
から付加した annotaion
(= url) を取得します。
リンクをタップしたことをユーザーにフィードバックする
実装を以下に示します。
表示は以下のようになり、タップ時に Ripple エフェクトが表示されます。
大枠としては、テキストに Spacer
を重ねてそこに Ripple を表示しています。
Ripple は graphicsLayer
によって pressedLinkTextShape
の形に切り取られ、結果リンク部分のみ Ripple が見えるようになります。
押下時の情報が欲しいため、タップイベントは pointerInput
で実装します。
(そのため、ClickableText
はもはや不要なので Text
になっています)
onPress
ではリンクとなる箇所を覆う Path
を TextLayoutResult
を用いて求めます。
これは Text
の onTextLayout
で取得ができます。
TextLayoutResult#getOffsetForPosition
で文字単位のオフセットが得られるため、後は以前と同様にアノテーションが取得できます。
リンクとなる箇所を覆う Path
は TextLayoutResult#getPathForRange
で得られるので、GenericShape
で Shape
として pressedLinkTextShape
に保持します。
細かいチューニング
以上で問題なく動いているように見えますが、実際にはいくつか問題が出てきます。
リンクの当たり判定がズレている
fontSize を大きくしてみるとよくわかります。
これは TextLayoutResult#getOffsetForPosition
の返値が「タップ位置に一番近い左端を持つ文字のオフセット」なためです。
上の例で言うと、「は」の左側をタップしたときには「は」の 2
が返り、「は」の右側をタップしたときには「こ」の 3
が返ります。
この問題の改善例を以下に示します。
TextLayoutResult#getOffsetForPosition
で返る値は「画面上でタップした文字」または「そのひとつ先の文字」に対応するオフセットであると考えられるため、「返値に対応する文字」と「そのひとつ前の文字」の各文字について実際にタップ位置がその文字の領域に含まれているかを確認します。
これには TextLayoutResult#getBoundingBox
を使うことができます。
この修正により、「中央寄せをしていたりするときに、左右の余白をタップでも反応する」という問題も同時に解決できます。(次セクションの画像も参照)
行をまたぐ場合に Ripple の表示が広すぎる
特に中央寄せにしていると目立ちます。
これは TextLayoutResult#getPathForRange
が左右の余白も含めてしまうためです。
この問題の改善例を以下に示します。
行末の文字のみ TextLayoutResult#getBoundingBox
を使うことで、各行の余白を含めない Path
を求めています。
文字単位のオフセットから行の index を求めるには TextLayoutResult#getLineForOffset
を使うことができます。
改善後
これらの修正結果は以下のようになります。
まだまだ改善できる点はあります。
例えば、以下のようにリンクとなる箇所以外に動かしても、テキスト内であれば反応が残ったままになります。
(下のボタンのようにそれ以外の部分に動かしたら反応を消したいですね)
これは Text
全体でタップのキャンセルを見ているためで、改善には pointerInput
の中でドラッグイベントを見ていく必要がありそうです。
おわりに
ここまで読んでいただければ分かるように、AnnotatedString
と TextLayoutResult
を組み合わせることで様々なテキストの情報にアクセスできます。
実現したい内容に合わせてカスタマイズしてみてください。
コード全体は以下から確認できます。
Discussion