Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TextView fromHtml links broken on Lollipop

Our application had several instances of TextViews with its contents set by myTv.setText(Html.fromHtml()); that have been working for Android 4.4.0 and below.

Starting from 4.4.2 and Lollypop these links have stopped working. The text still appears underlined and with a hyperlink color, but tapping them yields no results.

It has to be said that those fields are marked as copy-pasteable, which is known to have interactions with those spannables.

Has anyone been able to solve this issue?

like image 302
MLProgrammer-CiM Avatar asked Oct 19 '22 16:10

MLProgrammer-CiM


2 Answers

The problem is that when enabling copy&paste in a TextView, Android will use an ArrowKeyMovementMethod which supports selection of text but not clicking of links. When you use a LinkMovementMethod then you can click links but not select text (regardless whether you're on Lollipop, KitKat or a lower Android version).

In order so solve this I extended the ArrayKeyMovementMethod class and overrode the onTouchEvent with the LinkMovementMethod onTouchEvent. To allow text selection I had to remove but three lines of code. Since I'm using that class in a rich text editor with lots of text formatting I also added logic to find the clicked character regardless of the text size, its indentation or the text alignment. If you want the simple solution that works fine with plain text, use this in a custom ArrowKeyMovementMethod class:

@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;
        }
        /* These are the lines of code you want to remove
        else {
            Selection.removeSelection(buffer);
        }*/
    }

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

Don't forget to call: myTv.setMovementMethod(new ClickAndSelectMovementMethod());

If you want the version that supports all kinds of text formatting use this instead:

import android.graphics.Rect;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.method.ArrowKeyMovementMethod;
import android.text.method.MovementMethod;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.text.style.LeadingMarginSpan;
import android.view.MotionEvent;
import android.widget.TextView;

/**
 * ArrowKeyMovementMethod does support selection of text but not the clicking of
 * links. LinkMovementMethod does support clicking of links but not the
 * selection of text. This class adds the link clicking to the
 * ArrowKeyMovementMethod. We basically take the LinkMovementMethod onTouchEvent
 * code and remove the line Selection.removeSelection(buffer); which de-selects
 * all text when no link was found.
 */
public class ClickAndSelectMovementMethod extends ArrowKeyMovementMethod {

    private static Rect sLineBounds = new Rect();

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

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

            int index = getCharIndexAt(widget, event);
            if (index != -1) {
                ClickableSpan[] link = buffer.getSpans(index, index, 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;
                }
            }
            /*
             * else { Selection.removeSelection(buffer); }
             */

        }

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

    private int getCharIndexAt(TextView textView, MotionEvent event) {
        // get coordinates
        int x = (int) event.getX();
        int y = (int) event.getY();
        x -= textView.getTotalPaddingLeft();
        y -= textView.getTotalPaddingTop();
        x += textView.getScrollX();
        y += textView.getScrollY();

        /*
         * Fail-fast check of the line bound. If we're not within the line bound
         * no character was touched
         */
        Layout layout = textView.getLayout();
        int line = layout.getLineForVertical(y);
        synchronized (sLineBounds) {
            layout.getLineBounds(line, sLineBounds);
            if (!sLineBounds.contains(x, y)) {
                return -1;
            }
        }

        // retrieve line text
        Spanned text = (Spanned) textView.getText();
        int lineStart = layout.getLineStart(line);
        int lineEnd = layout.getLineEnd(line);
        int lineLength = lineEnd - lineStart;
        if (lineLength == 0) {
            return -1;
        }
        Spanned lineText = (Spanned) text.subSequence(lineStart, lineEnd);

        // compute leading margin and subtract it from the x coordinate
        int margin = 0;
        LeadingMarginSpan[] marginSpans = lineText.getSpans(0, lineLength, LeadingMarginSpan.class);
        if (marginSpans != null) {
            for (LeadingMarginSpan span : marginSpans) {
                margin += span.getLeadingMargin(true);
            }
        }
        x -= margin;

        // retrieve text widths
        float[] widths = new float[lineLength];
        TextPaint paint = textView.getPaint();
        paint.getTextWidths(lineText, 0, lineLength, widths);

        // scale text widths by relative font size (absolute size / default size)
        final float defaultSize = textView.getTextSize();
        float scaleFactor = 1f;
        AbsoluteSizeSpan[] absSpans = lineText.getSpans(0, lineLength, AbsoluteSizeSpan.class);
        if (absSpans != null) {
            for (AbsoluteSizeSpan span : absSpans) {
                int spanStart = lineText.getSpanStart(span);
                int spanEnd = lineText.getSpanEnd(span);
                scaleFactor = span.getSize() / defaultSize;
                int start = Math.max(lineStart, spanStart);
                int end = Math.min(lineEnd, spanEnd);
                for (int i = start; i < end; i++) {
                    widths[i] *= scaleFactor;
                }
            }
        }

        // find index of touched character
        float startChar = 0;
        float endChar = 0;
        for (int i = 0; i < lineLength; i++) {
            startChar = endChar;
            endChar += widths[i];
            if (endChar >= x) {
                // which "end" is closer to x, the start or the end of the character?
                int index = lineStart + (x - startChar < endChar - x ? i : i + 1);
                return index;
            }
        }

        return -1;
    }
}
like image 73
Emanuel Moecklin Avatar answered Oct 22 '22 07:10

Emanuel Moecklin


By default, Material Buttons and Textviews are styled to show text in all-caps. However, there is a bug in the AllCapsTransformationMethod that causes discarding other text formatting eg. Spannable. So when you try change font size for Button on Lollipop like follow:

SpannableString span = new SpannableString(text);
span.setSpan(new AbsoluteSizeSpan(8, true), 5, 10, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
testButton.setText(span);

it also won't work (only for Lollipop).

Solution:

workaround for your case and described Spannable case is set textAllCaps to false:

<TextView
...
android:textAllCaps="false" />
like image 21
klimat Avatar answered Oct 22 '22 09:10

klimat