ListViewに含まれるTextViewのリンクをクリックできるようにする。

最近は自由登校期間で学校が休みで殆ど家に引きこもっています。

折角の休みなのでAndroid開発をするためにJavaの勉強をしていたのですが、自分の調べ方が悪いのか思う通りの結果が出てこなかった部分があり、先ほど解決したので備忘録として残しておきます。

起きた問題

ListViewにOnItemClickListenerをセット、ListViewに含まれるTextViewのテキストの一部にリンクを貼るためにURLSpan(ClickableSpanでもいい)をセット。

この状態で、

・TextViewをクリックするとリンクをクリックしたかどうがにかかわらず、ListViewにセットしたOnItemClickListenerが反応しない。

・行末がリンクの場合、リンクがTextViewの端まで及ぶ。

などの問題が起こる。

原因

タッチイベントは親Viewから子Viewに伝搬され、子Viewで処理した場合そこでタッチイベントが終了します。

また、子Viewで処理しなかった場合、子Viewがあれば子Viewにイベントを伝え、無ければ親Viewにイベントを伝えます。

要するに、親からイベントが伝わり、子から処理される仕組みらしいです。

自分の曖昧な理解を書いたため、これらについて間違いがあるかもしれません。

詳しくは、 Android のタッチイベントを理解する(その1) - Unmotivated が参考になります。

今回はTextViewにセットしたLinkMovementMethodがそこでイベントを遮っていたようです。

解決策

TextViewのonTouch時の動作を書き換えてあげればいいようです。

TextViewのリンク以外をクリックした時はListViewのクリックに行く方法 - oigamishunta’s blog

android - ListView: TextView with LinkMovementMethod makes list item unclickable? - Stack Overflow

これについてはいくつか解決策がありましたが、今回は「android - ListView: TextView with LinkMovementMethod makes list item unclickable? - Stack Overflow」の2番目の回答を参考にしました。

textView.setOnTouchListener(new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        CharSequence text = ((TextView) v).getText();
        Spannable stext = Spannable.Factory.getInstance().newSpannable(text);
        TextView widget = (TextView) v;
        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 = stext.getSpans(off, off, ClickableSpan.class);

            if (link.length != 0) {
                if (action == MotionEvent.ACTION_UP) {
                    link[0].onClick(widget);
                }
                return true;
            }
        }
        return false;
    }
});

「参考にしました」、というかほぼそのままです。

これで、「TextViewをクリックするとリンクをクリックしたかどうがにかかわらず、ListViewにセットしたOnItemClickListenerが反応しない。」という問題が解決しました。

しかし、このままだとリンクの範囲が少しおかしなことになり、行末がリンクの場合、リンクがTextViewの端まで及んでしまうようです。

というか、

SpannableString ss = new SpannableString(text);
ss.setSpan(new URLSpan("URLな文字列"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(ss);
textView.setMovementMethod(LinkMovementMethod.getInstance());

みたいにした場合、「行末がリンクの場合、リンクがTextViewの端まで及ぶ。」ことになると思う。

解決策のコードでgetSpansしてる辺り(=リンクを取得してる辺り)を見てみます。

SpannableStringBuilder | Android Developers 英語よくわからないけどgetSpansの部分を見ると、queryStartとqueryEndに重なるspansを返すみたいですね。

getSpans(off, off, ClickableSpan.class);の部分は問題なさそうなのでoffとlineについて見てみます。

Layout | Android Developers getLineForVerticalは引数で指定した垂直位置に存在する行番号を、getOffsetForHorizontalは引数で指定した行番号の行に存在する文字の中で、引数で指定した水平位置に最も近い文字の文字位置を返すみたいです。

getOffsetForHorizontalの最も近い文字の位置を返す部分に問題がありそうです。

Layout | Android Developersについて詳しく見てみたら、getLineMaxというメソッドがありました。

引数で行番号を指定するとその行の長さを返すみたいです。

getLineMaxで行の長さを取得し、これがクリックしたTextViewの位置より小さく、尚且つクリックした位置がリンクであればリンククリック時の処理をさせれば上手く行きそうです。

textView.setOnTouchListener(new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        CharSequence text = ((TextView) v).getText();
        Spannable stext = Spannable.Factory.getInstance().newSpannable(text);
        TextView widget = (TextView) v;
        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);

            if(x < layout.getLineMax(line)) {
                ClickableSpan[] link = stext.getSpans(off, off, ClickableSpan.class);
                if (link.length != 0) {
                    if (action == MotionEvent.ACTION_UP) {
                        link[0].onClick(widget);
                    }
                    return true;
                }
            }
        }
        return false;
    }
});
if(x < layout.getLineMax(line)) {}

これを加えただけですが、これで「行末がリンクの場合、リンクがTextViewの端まで及ぶ。」という問題も解決することが出来ました。

感想

久々に文章を書いたのでつらかった。

英語が読めないのでつらい。

語彙力の低下を感じたのでつらい。

JavaAndroid開発はたのしいと思う。