android – Can a TextView be selectable AND contain links?-ThrowExceptions

Exception or error:

I’ve run into a problem with TextView. I can make it selectable using setTextIsSelectable(true), but when I enable links to be clicked via setMovementMethod(LinkMovementMethod.getInstance()), it is no longer selectable.

Please note, I don’t mean making raw links clickable, but rather making actual words clickable by loading the TextView with HTML markup using something like setText(Html.fromHtml("<a href='http://stackoverflow.com'>Hello World!</a>")).

How to solve:

oakes’s answer cause exception on double tap on textview

java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0...

I looked at the onTouchEvent impletentation in LinkMovementMethod and found that it removes selection when textview doesn’t contain link. In this case selection starts from empty value and application crash when user try to change it.

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

So i override onTouchEvent method, and it works fine.

public class CustomMovementMethod extends LinkMovementMethod {
    @Override
    public boolean canSelectArbitrarily () {
        return true;
    }

    @Override
    public void initialize(TextView widget, Spannable text) {
        Selection.setSelection(text, text.length());
    }

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) {
        if ((dir & (View.FOCUS_FORWARD | View.FOCUS_DOWN)) != 0) {
            if (view.getLayout() == null) {
                // This shouldn't be null, but do something sensible if it is.
                Selection.setSelection(text, text.length());
            }
        } else {
            Selection.setSelection(text, text.length());
        }
    }

    @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);

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

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

Hope it will be helpful for someone.

###

I figured it out. You need to subclass LinkMovementMethod and add support for text selection. It’s really unfortunate that it doesn’t support it natively. I just overrode the relevant methods using the equivalent ones from the source code for ArrowKeyMovementMethod. I guess that’s one benefit of Android being open source!

public class CustomMovementMethod extends LinkMovementMethod {
    @Override
    public boolean canSelectArbitrarily () {
        return true;
    }

    @Override
    public void initialize(TextView widget, Spannable text) {
        Selection.setSelection(text, text.length());
    }

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) {
       if ((dir & (View.FOCUS_FORWARD | View.FOCUS_DOWN)) != 0) {
           if (view.getLayout() == null) {
               // This shouldn't be null, but do something sensible if it is.
               Selection.setSelection(text, text.length());
           }
       } else {
           Selection.setSelection(text, text.length());
       }
    }
}

To use it, just instantiate it directly, like so:

textView.setMovementMethod(new CustomMovementMethod());

###

LinkMovementMethod() does not support text selection very well, even we can select the text, but after we scroll the textview, the selection will be lost.

The best implementation is extending from ArrowKeyMovementMethod, which supports the text selection very well.

Please see the details in here

###

The XML TextView should not have any link or any attributes that can be selected:

<TextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"/>

Then, set everything programmatically respecting the following order:

textView.setText(Html.fromHtml(myHtml));
Linkify.addLinks(textView, Linkify.WEB_URLS);
textView.setTextIsSelectable(true); // API-11 and above
textView.setMovementMethod(LinkMovementMethod.getInstance());

###

TL;DR: Simply use the LinkArrowKeyMovementMethod at the end of this answer for a perfect solution.

There is an annoying bug if you ever tried to used the top-voted answers that extends LinkMovementMethod — when you cancel a selection by clicking some text that’s not selected, the whole selection flashes to be from the very beginning to the selection end, and then becomes nothing. This is because LinkMovementMethod cannot actually handle selection as good as ArrowKeyMovementMethod.

Another way around could have been using TextView‘s own workaround if you have set android:autoLink to true, as in the following source from TextView:

        final boolean textIsSelectable = isTextSelectable();
        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 = ((Spannable) mText).getSpans(getSelectionStart(),
                getSelectionEnd(), ClickableSpan.class);

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

But I personally don’t want the auto link feature (I have my own link information), so building upon @Weidian Huang ‘s idea, I incorporated LinkMovementMethod‘s functionality into ArrowKeyMovementMethod and built a new movement method:

/**
 * @see LinkMovementMethod
 * @see ArrowKeyMovementMethod
 */
public class LinkArrowKeyMovementMethod extends ArrowKeyMovementMethod {

    private static final int CLICK = 1;
    private static final int UP = 2;
    private static final int DOWN = 3;

    private static Object FROM_BELOW = new NoCopySpan.Concrete();

    private static LinkArrowKeyMovementMethod sInstance;

    public static LinkArrowKeyMovementMethod getInstance() {
        if (sInstance == null) {
            sInstance = new LinkArrowKeyMovementMethod();
        }
        return sInstance;
    }

    @Override
    public void initialize(TextView widget, Spannable text) {
        super.initialize(widget, text);

        text.removeSpan(FROM_BELOW);
    }

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) {
        super.onTakeFocus(view, text, dir);

        if ((dir & View.FOCUS_BACKWARD) != 0) {
            text.setSpan(FROM_BELOW, 0, 0, Spannable.SPAN_POINT_POINT);
        } else {
            text.removeSpan(FROM_BELOW);
        }
    }

    @Override
    protected boolean handleMovementKey(TextView widget, Spannable buffer, int keyCode,
                                        int movementMetaState, KeyEvent event) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_CENTER:
            case KeyEvent.KEYCODE_ENTER:
                if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) {
                    if (event.getAction() == KeyEvent.ACTION_DOWN &&
                            event.getRepeatCount() == 0 && action(CLICK, widget, buffer)) {
                        return true;
                    }
                }
                break;
        }
        return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event);
    }

    @Override
    protected boolean up(TextView widget, Spannable buffer) {
        if (action(UP, widget, buffer)) {
            return true;
        }

        return super.up(widget, buffer);
    }

    @Override
    protected boolean down(TextView widget, Spannable buffer) {
        if (action(DOWN, widget, buffer)) {
            return true;
        }

        return super.down(widget, buffer);
    }

    @Override
    protected boolean left(TextView widget, Spannable buffer) {
        if (action(UP, widget, buffer)) {
            return true;
        }

        return super.left(widget, buffer);
    }

    @Override
    protected boolean right(TextView widget, Spannable buffer) {
        if (action(DOWN, widget, buffer)) {
            return true;
        }

        return super.right(widget, buffer);
    }

    private boolean action(int what, TextView widget, Spannable buffer) {
        Layout layout = widget.getLayout();

        int padding = widget.getTotalPaddingTop() +
                widget.getTotalPaddingBottom();
        int areaTop = widget.getScrollY();
        int areaBot = areaTop + widget.getHeight() - padding;

        int lineTop = layout.getLineForVertical(areaTop);
        int lineBot = layout.getLineForVertical(areaBot);

        int first = layout.getLineStart(lineTop);
        int last = layout.getLineEnd(lineBot);

        ClickableSpan[] candidates = buffer.getSpans(first, last, ClickableSpan.class);

        int a = Selection.getSelectionStart(buffer);
        int b = Selection.getSelectionEnd(buffer);

        int selStart = Math.min(a, b);
        int selEnd = Math.max(a, b);

        if (selStart < 0) {
            if (buffer.getSpanStart(FROM_BELOW) >= 0) {
                selStart = selEnd = buffer.length();
            }
        }

        if (selStart > last)
            selStart = selEnd = Integer.MAX_VALUE;
        if (selEnd < first)
            selStart = selEnd = -1;

        switch (what) {
            case CLICK:
                if (selStart == selEnd) {
                    return false;
                }

                ClickableSpan[] link = buffer.getSpans(selStart, selEnd, ClickableSpan.class);

                if (link.length != 1)
                    return false;

                link[0].onClick(widget);
                break;

            case UP:
                int bestStart, bestEnd;

                bestStart = -1;
                bestEnd = -1;

                for (int i = 0; i < candidates.length; i++) {
                    int end = buffer.getSpanEnd(candidates[i]);

                    if (end < selEnd || selStart == selEnd) {
                        if (end > bestEnd) {
                            bestStart = buffer.getSpanStart(candidates[i]);
                            bestEnd = end;
                        }
                    }
                }

                if (bestStart >= 0) {
                    Selection.setSelection(buffer, bestEnd, bestStart);
                    return true;
                }

                break;

            case DOWN:
                bestStart = Integer.MAX_VALUE;
                bestEnd = Integer.MAX_VALUE;

                for (int i = 0; i < candidates.length; i++) {
                    int start = buffer.getSpanStart(candidates[i]);

                    if (start > selStart || selStart == selEnd) {
                        if (start < bestStart) {
                            bestStart = start;
                            bestEnd = buffer.getSpanEnd(candidates[i]);
                        }
                    }
                }

                if (bestEnd < Integer.MAX_VALUE) {
                    Selection.setSelection(buffer, bestStart, bestEnd);
                    return true;
                }

                break;
        }

        return false;
    }

    @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[] links = buffer.getSpans(off, off, ClickableSpan.class);

            if (links.length != 0) {
                if (action == MotionEvent.ACTION_UP) {
                    links[0].onClick(widget);
                } else if (action == MotionEvent.ACTION_DOWN) {
                    Selection.setSelection(buffer,
                            buffer.getSpanStart(links[0]),
                            buffer.getSpanEnd(links[0]));
                }
                return true;
            }
            // Removed
            //else {
            //    Selection.removeSelection(buffer);
            //}
        }

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

To use it, simply call:

textView.setTextIsSelectable(true);
textView.setMovementMethod(LinkArrowKeyMovementMethod.getInstance());

And this worked perfectly for me.

###

Also, order matters

textView.setTextIsSelectable(true);
textView.setMovementMethod(LinkMovementMethod.getInstance());

Allows the content to be selectable and link clicks working just perfect

###

Here is my take on it for Kotlin (loosely based on @hai-zhang’s answer). Simplified! See my gist for the better version. I currently use it for custom spans, not the HTML, and it is still relevant for me, especially when I need to pass the position of the user click to the span object.

You need to set the movement method after setTextIsSelectable(true)

/** Minimal version of Smart Movement that only has limited support of [ClickableSpan] */
object SmartMovementMethodMinimal : ArrowKeyMovementMethod() {

    override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?) =
        handleMotion(event!!, widget!!, buffer!!) || super.onTouchEvent(widget, buffer, event)

    private fun handleMotion(event: MotionEvent, widget: TextView, buffer: Spannable): Boolean {
        if (event.action == MotionEvent.ACTION_UP) {
            // Get click position
            val target = Point().apply {
                x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
                y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
            }

            // Get span line and offset
            val line = widget.layout.getLineForVertical(target.y)
            val offset = widget.layout.getOffsetForHorizontal(line, target.x.toFloat())

            if (event.action == MotionEvent.ACTION_UP) {
                val spans = buffer.getSpans<ClickableSpan>(offset, offset)
                if (spans.isNotEmpty()) {
                    spans.forEach { it.onClick(widget) }
                    return true
                }
            }
        }

        return false
    }
}

More detailed and complex code with examples here: https://gist.github.com/sQu1rr/210f7e08dd939fa30dcd2209177ba875

###

would it be possible to associate the TextView with a URL?
Is you have 10 TextView and 10 URLs it should be simple to write code that if TextView[3] is clicked it fires off an intent for webview (or browser) with URL[3]

Leave a Reply

Your email address will not be published. Required fields are marked *