49

I have a textview that can contain clickable links. When one of this links is clicked, I want to start an activity. This works fine, but it should also be possible to click the whole textview and start another activity.

So that's my current solution:

    TextView tv = (TextView)findViewById(R.id.textview01);      
    Spannable span = Spannable.Factory.getInstance().newSpannable("test link span");   
    span.setSpan(new ClickableSpan() {  
        @Override
        public void onClick(View v) {  
            Log.d("main", "link clicked");
            Toast.makeText(Main.this, "link clicked", Toast.LENGTH_SHORT).show(); 
        } }, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    tv.setText(span); 

    tv.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            Log.d("main", "textview clicked");
            Toast.makeText(Main.this, "textview clicked", Toast.LENGTH_SHORT).show();               
        }
    });

    tv.setMovementMethod(LinkMovementMethod.getInstance());

The problem is, that when I set an OnClickListener, everytime I click on a link first the listener for the whole textview and then the one for the ClickableSpan is called.

Is there a way to prevent android from calling the listener for the whole textview, when a link is clicked? Or to decide in the listener for the whole view, if a link was clicked or not?

Lukas
  • 886
  • 2
  • 7
  • 14
  • 1
    I am having similar problem I think. But it only happens when the ClickableSpan is the last "text" in the TextView. – Andrew Mackenzie Jan 18 '13 at 11:57
  • 1
    @AndrewMackenzie in those cases where the clickable span is the last text and you don't want the remaining space to be clickable, just append a non-spanned space. – Mark Mar 05 '21 at 11:30

8 Answers8

50

Found a workaround that is quite straight forward. Define ClickableSpan on all the text areas that are not part of the links and handle the click on them as if the text view was clicked:

TextView tv = (TextView)findViewById(R.id.textview01);      
Spannable span = Spannable.Factory.getInstance().newSpannable("test link span");   
span.setSpan(new ClickableSpan() {  
    @Override
    public void onClick(View v) {  
        Log.d("main", "link clicked");
        Toast.makeText(Main.this, "link clicked", Toast.LENGTH_SHORT).show(); 
    } }, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// All the rest will have the same spannable.
ClickableSpan cs = new ClickableSpan() {  
    @Override
    public void onClick(View v) {  
        Log.d("main", "textview clicked");
        Toast.makeText(Main.this, "textview clicked", Toast.LENGTH_SHORT).show(); 
    } };

// set the "test " spannable.
span.setSpan(cs, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

// set the " span" spannable
span.setSpan(cs, 6, span.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

tv.setText(span);

tv.setMovementMethod(LinkMovementMethod.getInstance());

Hope this helps (I know this thread is old, but in case anyone sees it now...).

Yoel Gluschnaider
  • 1,785
  • 1
  • 16
  • 20
39

This is a quite easy solution.. This worked for me

textView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        ClassroomLog.log(TAG, "Textview Click listener ");
        if (textView.getSelectionStart() == -1 && textView.getSelectionEnd() == -1) {
            // do your code here this will only call if its not a hyperlink
        }
    }
});
Lahiru Pinto
  • 1,621
  • 19
  • 20
12

Matthew suggested subclassing TextView and with that hint a came up with a rather ugly workaround. But it works:

I've created a "ClickPreventableTextView" which I use when I have clickablespans in a TextView that should be clickable as a whole.

In its onTouchEvent method this class calls the onTouchEvent method of MovementMethod before calling onTouchEvent on its base TextView class. So it is guaranted, that the Listener of the clickablespan will be invoked first. And I can prevent invoking the OnClickListener for the whole TextView

/**
 * TextView that allows to insert clickablespans while whole textview is still clickable<br>
 * If a click an a clickablespan occurs, click handler of whole textview will <b>not</b> be invoked
 * In your span onclick handler you first have to check whether {@link ignoreSpannableClick} returns true, if so just return from click handler
 * otherwise call {@link preventNextClick} and handle the click event
 * @author Lukas
 *
 */
public class ClickPreventableTextView extends TextView implements OnClickListener {
private boolean preventClick;
private OnClickListener clickListener;
private boolean ignoreSpannableClick;

public ClickPreventableTextView(Context context) {
    super(context);
}

public ClickPreventableTextView(Context context, AttributeSet attrs) {
    super(context, attrs);
}

public ClickPreventableTextView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
}

public boolean onTouchEvent(MotionEvent event) {
    if (getMovementMethod() != null)
        getMovementMethod().onTouchEvent(this, (Spannable)getText(), event);
    this.ignoreSpannableClick = true;
    boolean ret = super.onTouchEvent(event);
    this.ignoreSpannableClick = false;
    return ret;
}

/**
 * Returns true if click event for a clickable span should be ignored
 * @return true if click event should be ignored
 */
public boolean ignoreSpannableClick() {
    return ignoreSpannableClick;
}

/**
 * Call after handling click event for clickable span
 */
public void preventNextClick() {
    preventClick = true;
}

@Override
public void setOnClickListener(OnClickListener listener) {
    this.clickListener = listener;
    super.setOnClickListener(this);
}

@Override
public void onClick(View v) {
    if (preventClick) {
        preventClick = false;
    } else if (clickListener != null)
        clickListener.onClick(v);
}
}

The listener for the clickable span now looks like that

    span.setSpan(new ClickableSpan() {  
        @Override
        public void onClick(View v) {  
            Log.d("main", "link clicked");
            if (widget instanceof ClickPreventableTextView) {
                if (((ClickPreventableTextView)widget).ignoreSpannableClick())
                    return;
                ((ClickPreventableTextView)widget).preventNextClick();
            }

            Toast.makeText(Main.this, "link clicked", Toast.LENGTH_SHORT).show(); 
        } }, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

For me the main disadvantage is, that now getMovementMethod().onTouchEvent will be called twice (TextView calls that method in it's onTouchEvent method). I don't know if this has any side effects, atm it works as expected.

Lukas
  • 886
  • 2
  • 7
  • 14
  • I had a similar workaround for a similar problem with an app I was working on, and on Samsung TouchWiz phones, the clicks were coming in the opposite order, which caused my "prevent next click" logic to not work properly. I ended up just scrapping the entire approach and doing something else. It's worth trying your solution on one of the Galaxy S phones if you can, just to make sure it's working properly. – jjb Mar 03 '11 at 20:07
  • Thx, I'll try to find someone with a Galaxy S. But I think because I'm calling MovementMethods's onTouchEvent in ClickPreventableTextView's onTouchEvent before calling onToucEvent of the base class (TextView), the clicks should always be in the correct order – Lukas Mar 05 '11 at 09:02
  • Actually Samsung Galaxy Tab gives me random events order, the span or textView catches the click first but both events are always triggered. – A-Live Oct 19 '12 at 02:16
7

The code is work for me and that is from source code of LinkMovementMethod

tv.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                TextView tv = (TextView) v;
                if (event.action == MotionEvent.ACTION_UP) {
                    int x = (int) event.getX();
                    int y = (int) event.getY();

                    Layout layout = tv.getLayout();
                    int line = layout.getLineForVertical(y);
                    int off = layout.getOffsetForHorizontal(line, x);

                    ClickableSpan[] link = contentSpan.getSpans(off, off, ClickableSpan.class);

                    if (link.length != 0) {
                        link[0].onClick(tv);
                    } else {
                       //do other click
                    }
                }
                return true;
            }
        });
Cui Qing
  • 157
  • 2
  • 5
2

Solved something very similar in a very nice way. I wanted to have text that has a link which is clickable!! and i wanted to be able to press the text Where there is no link and have a on click listener in it. I took the LinkMovementMethod from grepcode and changed it a little Copy and past this class and copy the bottom and it will work :

import android.text.Layout;
import android.text.NoCopySpan;
import android.text.Selection;
import android.text.Spannable;
import android.text.method.MovementMethod;
import android.text.method.ScrollingMovementMethod;
import android.text.style.ClickableSpan;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;

public class
        CustomLinkMovementMethod
        extends ScrollingMovementMethod
{
    private static final int CLICK = 1;
    private static final int UP = 2;
    private static final int DOWN = 3;

public abstract interface TextClickedListener {
    public abstract void onTextClicked();
}
TextClickedListener listener = null;
public void setOnTextClickListener(TextClickedListener listen){
    listener = listen;
}
@Override
public boolean onKeyDown(TextView widget, Spannable buffer,
                         int keyCode, KeyEvent event) {
    switch (keyCode) {
        case KeyEvent.KEYCODE_DPAD_CENTER:
        case KeyEvent.KEYCODE_ENTER:
            if (event.getRepeatCount() == 0) {
                if (action(CLICK, widget, buffer)) {
                    return true;
                }
            }
    }

    return super.onKeyDown(widget, buffer, keyCode, 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) {
    boolean handled = false;

    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;
}

public boolean onKeyUp(TextView widget, Spannable buffer,
                       int keyCode, KeyEvent event) {
    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[] 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;
        } else {
            Selection.removeSelection(buffer);

            if (action == MotionEvent.ACTION_UP) {
                if(listener != null)
                    listener.onTextClicked();
            }
        }
    }

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





public void initialize(TextView widget, Spannable text) {
    Selection.removeSelection(text);
    text.removeSpan(FROM_BELOW);
}

public void onTakeFocus(TextView view, Spannable text, int dir) {
    Selection.removeSelection(text);

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

public static MovementMethod getInstance() {
    if (sInstance == null)
        sInstance = new CustomLinkMovementMethod();

    return sInstance;
}

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

}

Then in your code where the text view is add:

 CustomLinkMovementMethod link = (CustomLinkMovementMethod)CustomLinkMovementMethod.getInstance();
        link.setOnTextClickListener(new CustomLinkMovementMethod.TextClickedListener() {
            @Override
            public void onTextClicked() {
                Toast.makeText(UserProfileActivity.this, "text Pressed", Toast.LENGTH_LONG).show();

            }
        });
        YOUR_TEXTVIEW.setMovementMethod(link);
shimi_tap
  • 7,822
  • 5
  • 23
  • 23
2

It's quite simple, you can cancell textview's pending intent about click in ClickableSpan callback

span.setSpan(new ClickableSpan() {  
    @Override
    public void onClick(View v) {  
        tv.cancelPendingInputEvents() //here new line, textview will not receive click event

        Log.d("main", "link clicked");
        Toast.makeText(Main.this, "link clicked", Toast.LENGTH_SHORT).show();
    } }, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.setText(span); 
P1NG2WIN
  • 764
  • 1
  • 9
  • 24
0

I think that this involves subclassing TextView and changing its behavior, unfortunately. Have you thought about trying to put a background behind the TextView and attaching an onClickListener to it?

Matthew
  • 44,826
  • 10
  • 98
  • 87
  • Hm, I cannot attach an onClickListener to a background (drawable)? Or am I missing something? – Lukas Mar 03 '11 at 17:36
  • A background view behind the TextView, as in overlapping Views. – Matthew Mar 03 '11 at 17:50
  • I've tried using a FrameLayout with the TextView and another view in the background. But the "background view" never got the click event, I think they are always handled be the top most element – Lukas Mar 03 '11 at 18:36
0

copy below function

private fun setClickableHighLightedText(
        tv: TextView,
        textToHighlight: String,
        onClickListener: View.OnClickListener?
    ) {
        val tvt = tv.text.toString()
        var ofe = tvt.indexOf(textToHighlight, 0)
        val clickableSpan = object : ClickableSpan() {
            override fun onClick(textView: View) {
                onClickListener?.onClick(textView)
            }

            override fun updateDrawState(ds: TextPaint) {
                super.updateDrawState(ds)
                //set color of the text
                ds.color = getColor(R.color.black)
                //draw underline base on true/false 
                ds.isUnderlineText = false
            }
        }
        val wordToSpan = SpannableString(tv.text)
        var ofs = 0
        while (ofs < tvt.length && ofe != -1) {
            ofe = tvt.indexOf(textToHighlight, ofs)
            if (ofe == -1)
                break
            else {
                wordToSpan.setSpan(
                    clickableSpan,
                    ofe,
                    ofe + textToHighlight.length,
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
                )
                tv.setText(wordToSpan, TextView.BufferType.SPANNABLE)
                tv.movementMethod = LinkMovementMethod.getInstance()
            }
            ofs = ofe + 1
        }
    }

use above function and pass textview,clickble string

 setClickableHighLightedText(tvTest,"test") {
    showMessage("click")
    }
Jai Khambhayta
  • 4,198
  • 2
  • 22
  • 29