Android: ClickableSpan in clickable TextView
Asked Answered
M

8

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?

Mlle answered 3/3, 2011 at 16:37 Comment(2)
I am having similar problem I think. But it only happens when the ClickableSpan is the last "text" in the TextView.Iridium
@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.Criner
M
11

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.

Mlle answered 3/3, 2011 at 18:21 Comment(3)
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.Atp
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 orderMlle
Actually Samsung Galaxy Tab gives me random events order, the span or textView catches the click first but both events are always triggered.Anthropogeography
S
49

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

Sherrer answered 11/11, 2012 at 9:42 Comment(0)
P
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
        }
    }
});
Pericline answered 29/2, 2016 at 7:34 Comment(4)
Works like a charm. This should be the accepted answer.Trivia
just in case if you wonder why it will work, search Selection.setSelection inside onTouchEvent of LinkMovementMethod class.Pliner
this should be accepted answer.. Works very well. Thanks @Lahiru PintoAdhesion
First time click on textview both are always -1. Second click working properlyAerodonetics
M
11

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.

Mlle answered 3/3, 2011 at 18:21 Comment(3)
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.Atp
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 orderMlle
Actually Samsung Galaxy Tab gives me random events order, the span or textView catches the click first but both events are always triggered.Anthropogeography
P
6

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;
            }
        });
Prolactin answered 24/10, 2016 at 9:10 Comment(3)
if (event.action == MotionEvent.ACTION_UP) { // }Keyway
What is contentSpan ?Aerodonetics
contentSpan is SpannableString for TextView.setText's parameter that generate beforeProlactin
A
5

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); 
Arrogance answered 30/8, 2021 at 15:52 Comment(0)
S
1

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);
Siddur answered 22/8, 2013 at 9:15 Comment(0)
H
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?

Hock answered 3/3, 2011 at 16:55 Comment(3)
Hm, I cannot attach an onClickListener to a background (drawable)? Or am I missing something?Mlle
A background view behind the TextView, as in overlapping Views.Hock
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 elementMlle
I
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")
    }
Interrogator answered 20/11, 2020 at 5:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.