🚲

Jetpack Compose でテキスト内リンクを実装する

2023/11/03に公開

Jetpack Compose におけるテキスト内リンクは、Jetpack Compose Roadmap では現在 In Focus となっていますが、 AnnotatedString を用いることで実装可能です。

クリックを処理する部分は Android Developers にも記載がありますが、本記事ではさらにタップ時のフィードバックについても触れます。

https://developer.android.com/jetpack/compose/text/user-interactions#click-with-annotation

やりたいこと

  • テキスト内にリンクを配置する
  • リンクをタップしたことをユーザーにフィードバックする

環境

  • Kotlin: 1.9.10
  • Compose Compiler: 1.5.3
  • Compose BoM: 2023.10.01

テキスト内にリンクを配置する

実装を以下に示します。

表示は以下のようになり、「こちら」の周囲をタップすることで onClickTextLink で渡した処理が実行されます。
place_link_in_text

リンクとなる箇所には withAnnotationtag, annotation を付加します。
(こちらは現在実験的 API とされているため、使用時には OptIn が必要となります。これを用いずに実装する場合は pushStringAnnotation 及び pop を用います。)
その際、 withStyle を用いて見た目を変えることでユーザーになにがタップ可能かを伝えられるとよりよさそうです。

ClickableText はクリックイベントを処理できるコンポーネントであり、onClick でタップ時のオフセット(文字単位)を受け取ることができます。
AnnotatedString#getStringAnnotationsAnnotatedString.Range を取得し、 item から付加した annotaion (= url) を取得します。

リンクをタップしたことをユーザーにフィードバックする

実装を以下に示します。

表示は以下のようになり、タップ時に Ripple エフェクトが表示されます。

click_with_ripple

大枠としては、テキストに Spacer を重ねてそこに Ripple を表示しています。
Ripple は graphicsLayer によって pressedLinkTextShape の形に切り取られ、結果リンク部分のみ Ripple が見えるようになります。

押下時の情報が欲しいため、タップイベントは pointerInput で実装します。
(そのため、ClickableText はもはや不要なので Text になっています)
onPress ではリンクとなる箇所を覆う PathTextLayoutResult を用いて求めます。
これは TextonTextLayout で取得ができます。
TextLayoutResult#getOffsetForPosition で文字単位のオフセットが得られるため、後は以前と同様にアノテーションが取得できます。
リンクとなる箇所を覆う PathTextLayoutResult#getPathForRange で得られるので、GenericShapeShape として pressedLinkTextShape に保持します。

細かいチューニング

以上で問題なく動いているように見えますが、実際にはいくつか問題が出てきます。

リンクの当たり判定がズレている

fontSize を大きくしてみるとよくわかります。
tuning1_tap_detection
これは TextLayoutResult#getOffsetForPosition の返値が「タップ位置に一番近い左端を持つ文字のオフセット」なためです。
上の例で言うと、「は」の左側をタップしたときには「は」の 2 が返り、「は」の右側をタップしたときには「こ」の 3 が返ります。

この問題の改善例を以下に示します。

TextLayoutResult#getOffsetForPosition で返る値は「画面上でタップした文字」または「そのひとつ先の文字」に対応するオフセットであると考えられるため、「返値に対応する文字」と「そのひとつ前の文字」の各文字について実際にタップ位置がその文字の領域に含まれているかを確認します。
これには TextLayoutResult#getBoundingBox を使うことができます。

この修正により、「中央寄せをしていたりするときに、左右の余白をタップでも反応する」という問題も同時に解決できます。(次セクションの画像も参照)

行をまたぐ場合に Ripple の表示が広すぎる

特に中央寄せにしていると目立ちます。
tuning2_path
これは TextLayoutResult#getPathForRange が左右の余白も含めてしまうためです。

この問題の改善例を以下に示します。

行末の文字のみ TextLayoutResult#getBoundingBox を使うことで、各行の余白を含めない Path を求めています。
文字単位のオフセットから行の index を求めるには TextLayoutResult#getLineForOffset を使うことができます。

改善後

これらの修正結果は以下のようになります。
improved

まだまだ改善できる点はあります。
例えば、以下のようにリンクとなる箇所以外に動かしても、テキスト内であれば反応が残ったままになります。
(下のボタンのようにそれ以外の部分に動かしたら反応を消したいですね)
これは Text 全体でタップのキャンセルを見ているためで、改善には pointerInput の中でドラッグイベントを見ていく必要がありそうです。

next

おわりに

ここまで読んでいただければ分かるように、AnnotatedStringTextLayoutResult を組み合わせることで様々なテキストの情報にアクセスできます。
実現したい内容に合わせてカスタマイズしてみてください。

コード全体は以下から確認できます。
https://gist.github.com/warahiko/32dd1e0607fccd7a08d817bc1d6977e4#file-textwithlink-kt

Discussion