Android
TextView
LinkMovementMethod
ClickableSpan
onTouchEvent

android ClickableSpanを利用して一部をリンク化したTextViewで、非リンク部分をタップしたときは次のViewにイベントを渡す

More than 1 year has passed since last update.

少し前に「androidでTextViewの一部をリンク化したとき、リンク部分についてタッチフィードバックが得られるようにする」という記事を書きました。
今回は、この記事の続きになります。

サンプルコード等前回記事のものを適宜使いまわしていきますので、お時間ありましたらそちらの記事もご参照いただけると理解しやすいかもしれません。

SDKは API Level23 を使用しています。

お題

ListViewのアイテム(要素)やCardViewなどの子要素として、ClickableSpan, URLSpan などで一部をリンク化したTextViewを配置したとします。
このとき、TextView内のリンク部分がタップされたらTextViewのリンク部分のonClickイベントを発火させたい。
しかし、TextViewの通常テキスト部分がタップされたら親ウィジェット(TextViewの下に表示されているウィジェット)にイベントを発火させたい。

Screenshot_2016-02-05-17-45-55.png

解決策

やることは2つです。

まずは、LinkMovementMethod を継承した ExtendedLinkMovementMethod なる classを作成します。
中身はこんな感じです。

import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.view.MotionEvent;
import android.widget.TextView;

public class ExtendedLinkMovementMethod extends LinkMovementMethod {

    private static ExtendedLinkMovementMethod instance = null;

    public static ExtendedLinkMovementMethod getInstance() {
        if (ExtendedLinkMovementMethod.instance == null) {
            ExtendedLinkMovementMethod.instance = new ExtendedLinkMovementMethod();
        }
        return ExtendedLinkMovementMethod.instance;
    }

    protected ClickableSpan getClickableSpanFromPosition(TextView widget, Spannable buffer, int x, int y) {
        x -= widget.getTotalPaddingLeft();
        y -= widget.getTotalPaddingTop();

        x += widget.getScrollX();
        y += widget.getScrollY();

        Layout layout = widget.getLayout();
        int line = layout.getLineForVertical(y);
        int off = layout.getOffsetForHorizontal(line, x);

        ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
        if (link.length > 0) {
            return link[0];
        } else {
            return null;
        }

    }

    private ClickableSpan lastActionDownClickableSpan = null;

    @Override
    public boolean onTouchEvent(TextView widget, Spannable buffer,
                                MotionEvent event) {

        int action = event.getAction();

        if (action == MotionEvent.ACTION_UP ||
                action == MotionEvent.ACTION_DOWN ||
                action == MotionEvent.ACTION_CANCEL) {

            ClickableSpan currentClickableSpan = getClickableSpanFromPosition(widget, buffer, (int) event.getX(), (int) event.getY());
            if (action == MotionEvent.ACTION_UP) {
                if (lastActionDownClickableSpan != null &&
                        currentClickableSpan == lastActionDownClickableSpan) {
                    lastActionDownClickableSpan.onClick(widget);
                }
                lastActionDownClickableSpan = null;
                return true;
            } else if (action == MotionEvent.ACTION_DOWN) {
                if (currentClickableSpan != null) {
                    lastActionDownClickableSpan = currentClickableSpan;
                    return true;
                }
            } else if (action == MotionEvent.ACTION_CANCEL) {
                lastActionDownClickableSpan = null;
                return true;
            }
        }
        boolean result = super.onTouchEvent(widget, buffer, event);
        return false;
    }
}

androidでTextViewの一部をリンク化したとき、リンク部分についてタッチフィードバックが得られるようにする」のコードを取り込まれている方がいらっしゃいましたら、

public boolean onTouchEvent(TextView widget, Spannable buffer,
                            MotionEvent event);

の末尾の

return super.onTouchEvent(widget, buffer, event);

boolean result = super.onTouchEvent(widget, buffer, event);
return false;

に置き換えてください。

そして、TextView 初期化時に、このExtendedLinkMovementMethodをTextViewに設定します。

TextView textView = new TextView(this);
textView.setLinkMovementMethod(ExtendedLinkMovementMethod.getInstance());

しかし、これだけではうまくいきません。

やること2つ目です。
TextView を生成している箇所などで textView.setText(spannableString);した後に、下記のようなコードを追加してください。

  private void setupTextView () {
    ....
    mainText.setText(...);
    .....
    // ↓ここから
    mainText.setLongClickable(false);
    mainText.setClickable(false);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
      clearContextClickable(mainText);
    }
    // ↑ここまで
    ....
  }

    // このメソッド追加
    @TargetApi(Build.VERSION_CODES.M)
    private void clearContextClickable(View view) {
        view.setContextClickable(false);
    }

これでうまくいきます。

さて、ここから先は、やること2つ目がなぜ必要なのかの根拠を知りたい人向け。

TextView.onTouchEventのソースを読むと

@Override
public boolean onTouchEvent(MotionEvent event) {
  ...
  final boolean superResult = super.onTouchEvent(event);
  ...
  if (mMovement != null) {
    handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
  }
  ...
  if (handled) {
    return true;
  }
  ...
  return superResult;
} 

ExtendedLinkMoveMethod.onTouchEvent で false を返すと、 handled は false のままメソッドを抜けようとするんですが、superResultがtrue になってます(View.onTouchEventが true返してる)。
なので、イベント処理がここで終了する。=>上位のViewにイベントが回らない。上位Viewタップできない。
っていうことになってしまっています。

てことで、View.onTouchEventを見てみます。

public boolean onTouchEvent(MotionEvent event) {
  ...
  if (((viewFlags & CLICKABLE) == CLICKABLE ||
    (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
    (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
    ...
    return true;
  }
  return false;
}

やること2つめ、では、View.onTouchEvent() に true を返させないための回避コードを追加した形になっております。

なお、if 条件式の中身ですが、で省略した部分、実際はものすごくいろいろやっているようなのですが、正直フラグまみれのスパゲティーコードでちょっと読む気にならなかったので、何してるかは不明です。

今回は以上となります。

何かのお役に立てれば幸いです。