AndroidのListView内のURLをロングタップできるようにカスタマイズする方法

この記事は、[Android その３ Advent Calendar 2016](http://qiita.com/advent-calendar/2016/android_third) の22日目の記事です。

# URLのタップ

ListView内のテキストにURLがあってそれをタップするとブラウザを開く処理は以下のように書きます。

```java:adapter
String url = "http://qiita.com";
String text = " なにかテキスト";
SpannableStringBuilder builder = new SpannableStringBuilder();
builder.append(url);
builder.append(text);
builder.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                // URLタップ時の処理
            }
        }, 0, url.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(builder);
textView.setMovementMethod(LinkMovementMethod.getInstance(getContext()));
```

# URLのロングタップ

では、URLのロングタップイベントを取得したい時はどうでしょうか？標準のClickableSpanでは取得はできないようですので、カスタマイズをすることにします。

## カスタムSpannable

まずはClickableSpanを拡張して、ロングタップイベントを返すことのできるClickableSpanの継承クラスを定義しましょう。

```java:CustomClickableSpan
public abstract class CustomClickableSpan extends ClickableSpan {
    public abstract void onLongClick(View widget);
}
```

## TextViewへのセット

次に、先程のTextViewに対してSpannableをセットしている箇所を直します。

```java:adapter
String url = "http://qiita.com";
String text = " なにかテキスト";
SpannableStringBuilder builder = new SpannableStringBuilder();
builder.append(url);
builder.append(text);
builder.setSpan(new CustomClickableSpan() { // 置き換える
            @Override
            public void onClick(View widget) {
                // URLタップ時の処理
            }
            @Override
            public void onLongClick(View widget) {
                // URLロングタップ時の処理　← New!
            }
        }, 0, url.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(builder);
textView.setMovementMethod(LinkMovementMethod.getInstance(getContext()));
```

## カスタムLinkMovementMethod

これだけでは動きません。なぜなら、CustomClickableSpan#onLongClickを実行する箇所が存在しないからです。

### URLタップイベントはどこで発動するか

少し戻って、ClickableSpan#onClickを実行するのはどこでしょうか？それは、LinkMovementMethodクラスです。

```java:LinkMovementMethod
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
                            MotionEvent event) {
    int action = event.getAction();

    if (action == MotionEvent.ACTION_UP ||
        action == MotionEvent.ACTION_DOWN) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        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); // (a)

        if (link.length != 0) {
            if (action == MotionEvent.ACTION_UP) {
                link[0].onClick(widget); // (b)
            } else if (action == MotionEvent.ACTION_DOWN) {
                Selection.setSelection(buffer,
                                       buffer.getSpanStart(link[0]),
                                       buffer.getSpanEnd(link[0]));
            }

            return true;
        } else {
            Selection.removeSelection(buffer);
        }
    }

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

TextView#setMovementMethodでこのLinkMovementMethodのインスタンスを渡すことで、TextViewのonTouchEvent発動時にLinkMovementMethod#onTouchEventが発動します。

発動すると、タッチした座標に存在するSpannableを取得します(ソースのa)。もしタッチ箇所にURLなど無ければ、```link```変数は空となります。

もしSpannableがある場合で、指が画面から離れた時（UPイベント）に、このSpannable#onClick()が実行されます。

そうするとURLのロングタップイベントの取得は以下の手順でできそうです。

1. LinkMovementMethod#onTouchMethodでロングタップを検知する
2. タッチ座標からCustomClickableSpanを取得する
3. CustomClickableSpanが取得できれば、CustomClickableSpan#onLongClickを実行する

### ロングタップイベントを取得する

ロングタップを検出するには、GestureDetectorを使いましょう。その前に、LinkMovementMethodの継承クラスを作り、onTouchMethodからGestureDetectorにタッチイベントが伝搬できるようカスタマイズします。

```java:CustomLinkMovementMethod

/** タッチ中のSpannable */
private CustomClickableSpan pressingSpan;

/** スパンをタッチ中の座標 */
private int pressingSpanX;
private int pressingSpanY;

/** タッチイベント発動中のView */
private View currentWidget;

@Override
public boolean onTouchEvent(TextView widget,
                            Spannable buffer, MotionEvent event) {
    int action = event.getAction();

    int x = (int) event.getX();
    int y = (int) event.getY();
    this.currentWidget = widget;

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

        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) {
            // タッチ位置にスパンが存在する場合
            if (action == MotionEvent.ACTION_UP) {
                Selection.removeSelection(buffer);
            } else if (action == MotionEvent.ACTION_DOWN) {
                // (1) タッチし始めた位置にURLがある場合、そのSpannableをフィールドに保存する
                // 発動はGestureDetector側で行なう
                this.pressingSpan = link[0];
                this.pressingSpanX = x;
                this.pressingSpanY = y;
                Selection.setSelection(buffer,
                        buffer.getSpanStart(link[0]),
                        buffer.getSpanEnd(link[0]));
            }
            // (2) ここでGestureDetectorにイベントを伝搬させる
            mGestureDetector.onTouchEvent(event);
            // (3) trueを返すことで、後続のタッチ処理（ListView#onItemClickの発動を抑止する）
            return true;
        } else {
            // タッチ位置にスパンが存在しない場合
            this.pressingSpan = null;
            Selection.removeSelection(buffer);
            // (4) Touch.onTouchEventを通すとDOWNイベントの時にtrueが返り、
            // ListView#onItemClickを発動できなくなる。
            return false;
        }
    } else if (action == MotionEvent.ACTION_MOVE) {
        // (5) 一定以上動いた場合はロングタップイベントを発行しないようんする
        if (Math.abs(this.pressingSpanX - x) > 50 || Math.abs(this.pressingSpanY - y) > 50) {
            clearPressingSpan();
        }
    }

    return Touch.onTouchEvent(widget, buffer, event);
}

private class GestureListener implements GestureDetector.OnGestureListener {
        @Override
        public boolean onDown(MotionEvent e) {
            return false;
        }

        @Override
        public void onShowPress(MotionEvent e) {
        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            // (6) タップを検出し、CustomClickableSpan#onClick()を実行する
            if (this.pressingSpan != null) {
                pressingSpan.onClick(currentWidget);
                this.pressingSpan = null;
                return true;
            }
            return false;
        }

        @Override
        public boolean onScroll (MotionEvent e1, MotionEvent e2,float distanceX, float distanceY){
            return false;
        }

        @Override
        public void onLongPress (MotionEvent e){
            // (7) ロングタップを検出し、CustomClickableSpan#onLongClick()を実行する
            if (this.pressingSpan != null) {
                pressingSpan.onLongClick(currentWidget);
                this.pressingSpan = null;
            }
        }

        @Override
        public boolean onFling (MotionEvent e1, MotionEvent e2,float velocityX, float velocityY) {
            return false;
        }
```

#### ポイント

番号は、ソースコード内のコメントの番号に対応します。

1. DOWNイベントの際、Spannableがあったらそれをフィールドに保存します。これまではLinkMovementMethod#onTouchMethod()内でClickableSpan#onClick()を実行していましたが、それらをGestureDetectorに移譲するので、フィールドに保存する必要があります。
2. タッチ位置にCustomClickableSpanがあった場合で、UPかDOWNイベントの場合に、GestureDetectorを通します。これにより、GestureDetector#onLongPress()でURLのロングタップを検出できるようになります
3. GestureDetectorにイベントを渡したのでreturnします。ここではtrueを返すことで、後続のタッチ処理を中止しています。これをしないと、URLのタップイベントと同時に、ListViewのアイテムクリックイベントも発動して何かと具合が悪いので、忘れないようにしましょう。
4. URLのない場所でタッチした場合、継承元のLinkMovementMethodのように、Touch#onTouchEventを実行すると、trueが返り後続処理が止まってしまい、ListView#onItemClick()など発動しなくなるので、(3)とは逆にfalseを明示的に返すようにします
5. ここは無くてもいいですが、URLをタップしてそのまま指をずらしてキャンセルするということをできるようにしています。URLタップ時の座標を予め保存しておいて、指をずらした際に発生するMOVEイベント発生時にタップ時の座標から50pxずれていれば登録しているClickableSpanを解除することで、GestureDetector側でイベントが発生しても、Spannableに対してはイベントを出さないようにしています。
6. GestureDetector側で、シングルタップを検知すると、対象のClickableSpanがあればそれに対してonClickイベントを発行します
7. GestureDetector側で、ロングタップを検知すると、対象のClickableSpanがあればそれに対してonLongClickイベントを発行します

# まとめ

これを実装する際、ListView#onItemClick(), ListView#onItemLongClick()が発動しなくなることがありましたが、この方法だと、両方共発動できます。

もっといい方法があるよ！という方は情報をご教示くださいm(_ _(m

また、これらのサンプルは、
https://github.com/s17er/android-listview-touch-sample
にアップしていますので見てみてください。