ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

Android TextViewのURL自動検出と文字列選択が引き起こす罠

はじめに

モバイルエンジニアの譚です。開発中に遭遇したAndroidのURL自動検出機能とタップ時の競合問題について紹介させていただきます。

開発環境

  • Android Studio Otter
  • Pixel 7 Pro
  • Android 16

発生した事象

文字列内の URL を自動検出し、URL をタップすると外部ブラウザへ遷移する機能を実装しました。さらに、テキストをコピーできるようにしたいという要望があったため、文字列選択機能も有効にしました。

XML ファイルでは、以下のように設定しています。

  • android:autoLink="web" で URL の自動検出を有効化
  • android:textIsSelectable="true" で文字列選択を有効化
<TextView
    android:id="@+id/txtView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:autoLink="web"
    android:textIsSelectable="true"
    ... />

動作確認の結果、URL は正しく検出されました。しかし、検出された URL をタップすると、1 回目は正常に外部ブラウザへ遷移するものの、ブラウザからアプリに戻った後、TextView 内の任意の場所をタップすると、再び外部ブラウザに遷移してしまう現象が発生しました。

調査

現象の詳細を確認したところ、以下のことが分かりました。

  1. 一度この不具合現象を起こした後、再度アプリに戻り、URL 以外の箇所をタップしても遷移しなくなります。つまり、URL を一度もタップしていない状態では、URL 以外をタップしても意図しない遷移は発生しません
  2. ネットで調査すると、textView.movementMethod = LinkMovementMethod.getInstance() を設定することで解消できる、という情報が見つかりました。
  3. android:textIsSelectable="true" を無効にすると、この不具合は発生しません。

最初は URL 検出とタップ処理の競合を疑いました(上記 1・2)。しかし、3 の結果から、文字列の選択状態が強く関係していそうだと考えられました。

そこで、https://cs.android.com を使って TextView のソースコードを確認してみます。

TextView.java (外部リンク)

文字列選択を有効にすると、初期化処理の中で以下のコードが実行され、ArrowKeyMovementMethod が設定されていることが分かりました。

    public void setTextIsSelectable(boolean selectable) {
        ...

        setMovementMethod(selectable ? ArrowKeyMovementMethod.getInstance() : null);
        setText(mText, selectable ? BufferType.SPANNABLE : BufferType.NORMAL);

        ...
    }

ArrowKeyMovementMethod.java (外部リンク)

ArrowKeyMovementMethod では、タッチ処理の中でテキストの選択位置を管理しています。

            } else if (action == MotionEvent.ACTION_UP) {
                ...
                if (wasTouchSelecting) {
                    final int startOffset = buffer.getSpanStart(LAST_TAP_DOWN);
                    final int endOffset = widget.getOffsetForPosition(event.getX(), event.getY());
                    // ここで設定した選択位置はTextViewでも使われる
                    Selection.setSelection(buffer, Math.min(startOffset, endOffset),
                            Math.max(startOffset, endOffset));
                    buffer.removeSpan(LAST_TAP_DOWN);
                }
                ...

一方、TextView のタッチイベント処理では、URL自動検出ONかつ文字列選択可能の場合、保存された選択位置から Spannable を取得しています。取得できる場合、その Spannable の onClick が呼び出されます。 本件では Spannable は URLSpan となり、URLSpan.onClick によって外部ブラウザへ遷移します。

TextViewのタッチ処理一部 (外部リンク)

if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
    // The LinkMovementMethod which should handle taps on links has not been installed
    // on non editable text that support text selection.
    // We reproduce its behavior here to open links for these.
    ClickableSpan[] links = mSpannable-getSpans(getSelectionStart(),
        getSelectionEnd(), ClickableSpan.class);

    if (links.length > 0) {
        links[0].onClick(this);
        handled = true;
    }
}

不具合挙動になる原因の分析

ソースコードを確認した結果、不具合が発生する際の流れは以下の通りであることが分かりました。

1 回目の URL タップ

  • URLSpan.onClick が呼ばれ、外部ブラウザへ遷移する
  • 同時に、ArrowKeyMovementMethod のタッチイベント処理により、タップした URL の位置が「選択位置」として保存される

アプリに戻ってから 2 回目のタップ(URL 以外の箇所)

  • TextView のタッチイベント処理で、前回保存された選択位置(URL の位置)から URLSpan が抽出される
  • URLSpan.onClick が再び呼び出され、外部ブラウザへ遷移してしまう
  • 同時に、ArrowKeyMovementMethod により、今回タップした URL 以外の位置が新たな選択位置として保存される

その後

  • 選択位置が URL 以外になるため、URLSpan が抽出できなくなり、意図しない遷移は発生しなくなる

原因まとめ

文字列選択を有効にすると、一度タップした位置が「選択位置」として保持されます。 その結果、次のタッチイベント時に、実際にタップした位置ではなく、前回保存された選択位置から URLSpan を誤って取得してしまい、onClick が発火していました

解決案

いくつかの回避策が考えられます。

1. LinkMovementMethod を明示的に設定する

画面初期化後に、以下のように movementMethod を設定します。 URL のタップ処理を明示的に指定することで、将来的なメンテナンス性の向上にもつながります。

textView.movementMethod = LinkMovementMethod.getInstance()
// `LinkMovementMethod` では選択位置を保持せず、
// TextViewはタップした位置に URL があるかどうかのみを判定するため、
// 意図しない挙動は発生しません。

2. movementMethodnull に設定してクリアする

今回の問題は、textIsSelectable によって内部的に ArrowKeyMovementMethod が設定されたことが原因です。 そのため、不要な movementMethod を明示的にクリアするという対応も考えられます。ただし、コード上で null を設定する場合、意図が分かりづらくなることがあります。特別な理由がない限り、この方法はあまりおすすめできません。

3. XML ではなくコードで設定する

単独の対策というわけではありませんが、上記の案1・案2を踏まえると、XML と Kotlin / Java の双方に設定が分散している点が気になる場合もあります。そのような場合は、TextView の各種メソッドや Linkify を用いて、URL の検出や文字列選択の設定をすべてコード側で行う方法も考えられます。

その際は、movementMethod の設定を忘れないよう注意が必要です。

この方法では、すべての設定を Kotlin または Java コードに一元管理できるため、XML と実装コードが混在することに違和感がある方にはおすすめです。

最後に

今回は、Android の TextView において URL の自動検出と文字列選択を同時に有効にした際に発生する挙動の競合について紹介しました。 同様の問題に遭遇した方の参考になればと思います。

今後もローソンデジタルイノベーションでは技術ブログを更新していきますので、是非「読者になる」で応援していただけますと幸いです。