23

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>")).

oakes
  • 743
  • 5
  • 12

8 Answers8

26

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.

Community
  • 1
  • 1
Ranzed
  • 281
  • 3
  • 10
  • 1
    This solution WORKED for me rather then next one! Thank you very much for sharing - you saved my day! Tested on 2 devices. – Ivan Oct 18 '15 at 07:48
15

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());
oakes
  • 743
  • 5
  • 12
  • Dude, you're awesome! I was starting to think this was impossible but then I stumbled upon your question. Gotta love Open Source! – Pkmmte Aug 12 '13 at 03:38
  • For us this worked, but we had to set textIsSelectable to true – Danilo Apr 09 '14 at 17:28
  • 5
    FYI, this solution sometimes cause "java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0" in some cases, for example on double tap on Nexus 5 devices. – Bracadabra Dec 17 '14 at 11:35
  • Perfect! You saved my day. – Veronnie Sep 04 '15 at 15:29
  • unfortunately it didnt work for me - tested on 2 devices - Galaxy note 2 and Samsung XPeria Z, tried setMovementMethod(CustomMovementMethod.getInstance()); and setMovementMethod(new CustomMovementMethod()); – Ivan Oct 18 '15 at 07:40
  • @Vang I had the same problem; The exception you've mentioned is pure android coused exception. When I take a look at it, the specific exception case was to set "textIsSelectable" parameter through the xml file or before the "setMovementMethod" function. – Evren Ozturk Nov 03 '16 at 08:09
4

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.

Hai Zhang
  • 5,574
  • 1
  • 44
  • 51
3

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());
Rav
  • 1,327
  • 3
  • 18
  • 32
Sachin Tanpure
  • 1,068
  • 11
  • 12
2

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

Weidian Huang
  • 2,787
  • 2
  • 20
  • 29
2

Also, order matters

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

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

-1

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]

Martin
  • 4,711
  • 4
  • 29
  • 37
  • Unfortunately no, I need the ability to have multiple links potentially in a `TextView`, just as in normal HTML markup. I know I could use a `WebView`, but I'm trying to avoid such a resource-heavy control. – oakes Apr 05 '13 at 20:16
-1

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

Aleksandr Belkin
  • 412
  • 5
  • 17