Android
TextView
LinkMovementMethod
ClickableSpan
タッチフィードバック

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

More than 1 year has passed since last update.

はじめまして。ある程度の実益も得られることを目指しつつ、趣味でandroidアプリを個人開発しているnoranuk0と申します。
今回がQiita初投稿となります。よろしくお願いします。

タイトルに書かれているとおり、TextViewに表示されたテキストの一部をリンク化したときのタッチフィードバックを実現する方法を書きたいと思います。
SDKは APILevel23のものを使用しています。
また、動作確認はandroid 5.1.1(arrows RM02)で行っています。

なお、今回、記事を書くにあたり、以下の記事を参考にさせていただきました。どうもありがとうございます。
- TextViewに埋め込んだUrlリンクをタップした時の動作を置き換える

androidのTextViewの一部をリンク化する(例えば、テキストビューをタップしたとき、タップした部分がURLだった場合は、Webブラウザを表示する)方法としては下のような方法があります。

  • layout.xml内で autolink="true" に設定する
  • Html#fromHtml() を利用する
  • ClickableSpan, URLSpanなどを使用してSpannableStringを自前で作成、設定する

それぞれの方法の具体的なコーディング方法については他所に譲ります。
googleさんでそれっぽいキーワードで検索すると、日本語、英語記事が色々ヒットしますのでそちらをご参照お願いします。

さて、タッチフィードバックについては、マテリアルデザインのガイドラインでも、google MaterialDesign - Responsive interactionあたりで、タップ可能な個所をタップしたときは何らかのフィードバックを返しなさいといったことが書かれています。
しかし、残念ながら、どの方法を使っても標準ではタップしたときにハイライトしたり色が変わったりといったタップフィードバックは得られません。

今回は、Twitter 公式アプリで使われているような、ツイートのURL部分をタップしたらリンクテキスト部分の背景色が変わるようなものを実装してみます。

TextViewの一部をリンク化する方法には、上でも書いた通りいくつかあるのですが、タップフィードバックの仕組みを持たせるためには、SpannableStringを自前で作成する方法を使います。

以下、実装コードになります。

まず、タップされたときのイベントを取得するためのListerをinterfaceとして定義します。

public interface OnHighlightListener {
    public void onHighlight(boolean isHighlight);
}

次に、OnHighLightListenerを実装した、TapFeedbackURLSpanを作成します。

public class TapFeedbackURLSpan extends URLSpan implements OnHighlightListener {
    public TapFeedbackURLSpan(String url) {
        super(url);
        this.isHighlight = false;
    }

    private boolean isHighlight = false;
    public void onHighlight(boolean isHighlight) {
        this.isHighlight = isHighlight;
    }

    protected int createHighlightBackgroundColor(int source) {
        return Color.argb(0x40, Color.red(source), Color.green(source), Color.blue(source));
    }

    @Override
    public void updateDrawState(TextPaint ds) {
        super.updateDrawState(ds);

        if (isHighlight) {
            ds.bgColor = createHighlightBackgroundColor(ds.linkColor);
        }
        ds.setColor(ds.linkColor);
        ds.setUnderlineText(false);
    }
}

ここではURLSpanのサブクラスとして実装しましたが、タップ対象がURLでない場合などは ClickableSpanなどのサブクラスとして実装します。
現在タップされているのか、されていないのかを isHighlight というフラグで管理しており、onHilightイベントでフラグを操作している感じです。
また、updateDrawStateメソッド内で、タップ中の場合には背景色を linkColorの透明度を75%に設定した色で置き換えています。
このメソッドは、TextViewの中身を描画する際、テキストの色、フォントなどを決定するために呼び出されます。

そして次が、タップされたときにTapFeedbackURLSpan.onHighlight() を呼び出し、表示を更新すためのコードです。

こちらは少しトリッキーです。

通常、以下のようなコードでtextViewに対してLinkMovementMethodクラスのインスタンスを設定します。

textView.setMovementMethod(LinkMovementMethod.getInstance());

LinkMovementMethodには onTouchEvent()メソッドが実装されています。
このメソッドは、TextView内でタッチイベントが発生したとき、タッチされたSpanを特定し、対象のSpanの onClickイベントを送信するなどの目的で使用されます。

これを拡張して、タップされた対象のSpanがOnHighlightListerインタフェースを実装していれば、必要に応じて、 OnHighlightLister.onHighlight() を呼び出すようにonTouchEvent()の処理を変更します。

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 instanceof OnHighlightListener) {
                    ((OnHighlightListener) lastActionDownClickableSpan).onHighlight(false);
                    widget.invalidate();
                }
                if (lastActionDownClickableSpan != null &&
                        currentClickableSpan == lastActionDownClickableSpan) {
                    lastActionDownClickableSpan.onClick(widget);
                }
                lastActionDownClickableSpan = null;
                return true;
            } else if (action == MotionEvent.ACTION_DOWN) {
                if (currentClickableSpan != null) {
                    if (currentClickableSpan instanceof OnHighlightListener) {
                        ((OnHighlightListener) currentClickableSpan).onHighlight(true);
                        widget.invalidate();
                    }
                    lastActionDownClickableSpan = currentClickableSpan;
                    return true;
                }
            } else if (action == MotionEvent.ACTION_CANCEL) {
                if (lastActionDownClickableSpan instanceof OnHighlightListener) {
                    ((OnHighlightListener) lastActionDownClickableSpan).onHighlight(false);
                    widget.invalidate();
                }
                lastActionDownClickableSpan = null;
                return true;
            }
        }
        return super.onTouchEvent(widget, buffer, event);
    }
}

対応が必要なアクションは以下の3つです。

  • ACTION_DOWN(TextView内を指などで押した)
  • ACTION_UP(TextView内で指が離された)
  • ACTION_CANCEL (タップ操作がキャンセルされた。例えば、TextView内で指が押された後、そのままTextView外に指がスライドさせたときなどに発生します)

onHighlight() の呼出し後は、widget.invalidate(); で、TextViewの表示を更新しています。
invalidate() はSpan(に実装したListener側)で呼び出したほうが良いかもしれません。
ただ、もともとのSpanはTextViewの参照を保持していないので、その場合には TapFeedbackURLSpan のコンストラクタなどで 対象となるTextViewの参照を渡してやる必要があります。

また、このソースはタッチ操作前提です。androidTVなど、タッチ操作以外のオペレーションで操作する場合、もう少しいろいろ頑張る必要があると思われます。

2016/01/27 ソース読み直しててなんかおかしい気がしてきたので一部書き直しました

最後に、TextViewにSpanを設定する側のコードは以下のようになります。

SpannableString ss = new SpannableString("googleのページを開くにはここをタップしてください");

int startPosition = message.indexOf("ここ");
int endPosition = startPosition + "ここ".length();
ss.setSpan(new TapFeedbackURLSpan("http://www.google.co.jp") {
    @Override
    public void onClick(View textView) {
        Uri uri = Uri.parse(this.getURL());
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        startActivity(intent);
    }
}, startPosition , endPosition, Spanned.SPAN_INCLUSIVE_INCLUSIVE);

TextView textView = findViewByID(R.id.....);
textview.settext(ss);

これを、Activityの onCreate() の中などに記載します。

あと、TextViewを配置したlayout.xml はなんか適当に作ってください。

実行すると テキスト内の「ここ」の部分はリンク用のカラーで表示されます(色は、機種、androidバージョンにより異なると思います)。
そして、タッチすると、背景色が変わりタッチ中であることが視覚的にわかるようになっています。
そして指を離すと、ブラウザが立ち上がりgoogle のページが表示されます。

以上となります。

最後に宣伝です。
この辺のノウハウを利用した noteMe というメモアプリをつい先日リリースいたしました。
テキストメモ、URLメモを好きなだけ保存することができます。
URLをメモした場合、アプリからそのサイトが開けるのはもちろんですが、ページタイトル、説明文、代表画像なんかも自動で取り込んでくれます。
宜しければお試しいただけるとうれしいです。

noteMe:思いついたこと、気になる記事などを簡単に記録

最後までお読みいただきありがとうございました。