android ClickableSpan intercepts the click event
Asked Answered
H

5

35

I have a TextView in a Layout. It's so simple. I put a OnClickListener in the layout and some part of the TextView is set to be ClickableSpan. I want the ClickableSpan to do something in the onClick function when it's clicked and when the other part of the TextView is clicked, it has to do something in the onClick functions of the OnClickListener of the layout. Here's my code.

    RelativeLayout l = (RelativeLayout)findViewById(R.id.contentLayout);
    l.setOnClickListener(new OnClickListener(){
        @Override
        public void onClick(View v) {
            Toast.makeText(MainActivity.this, "whole layout", Toast.LENGTH_SHORT).show();
        }
    });

    TextView textView = (TextView)findViewById(R.id.t1);
    textView.setMovementMethod(LinkMovementMethod.getInstance());

    SpannableString spannableString = new SpannableString(textView.getText().toString());
    ClickableSpan span = new ClickableSpan() {
        @Override
        public void onClick(View widget) {
            Toast.makeText(MainActivity.this, "just word", Toast.LENGTH_SHORT).show();
        }
    };
    spannableString.setSpan(span, 0, 5, Spannable.SPAN_INCLUSIVE_INCLUSIVE);        
    textView.setText(spannableString);
Hulky answered 28/5, 2013 at 13:10 Comment(3)
And your problems is?Ruwenzori
When I click on the TextView outside of the ClickableSpan, "whole layout" message doesn't come out. I have to make it so.Tittup
Set its width and height to match_parent instead of wrap_contentRuwenzori
S
33

I've also run into this problem, and thanks to the source code @KMDev mentioned, I've came up with a much cleaner approach.

First, since I'm only having a TextView that is to be made partially clickable, in fact I don't need most of the functionalities LinkMovementMethod (and its super class ScrollingMovementMethod) which adds ability to handle key press, scrolling, etc.

Instead, create a custom MovementMethod that uses the OnTouch() code from LinkMovementMethod:

ClickableMovementMethod.java

package com.example.yourapplication;

import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.method.BaseMovementMethod;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.view.MotionEvent;
import android.widget.TextView;

/**
 * A movement method that traverses links in the text buffer and fires clicks. Unlike
 * {@link LinkMovementMethod}, this will not consume touch events outside {@link ClickableSpan}s.
 */
public class ClickableMovementMethod extends BaseMovementMethod {

    private static ClickableMovementMethod sInstance;

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

    @Override
    public boolean canSelectArbitrarily() {
        return false;
    }

    @Override
    public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {

        int action = event.getActionMasked();
        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();
            ClickableSpan[] links;
            if (y < 0 || y > layout.getHeight()) {
                links = null;
            } else {
                int line = layout.getLineForVertical(y);
                if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) {
                    links = null;
                } else {
                    int off = layout.getOffsetForHorizontal(line, x);
                    links = buffer.getSpans(off, off, ClickableSpan::class.java);
                }
            }

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

        return false;
    }

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

Then using this ClickableMovementMethod, touch event will not be consumed by movement method any more. However, TextView.setMovementMethod() which calls TextView.fixFocusableAndClickableSettings() will set clickable, long-clickable and focusable to true which will make View.onTouchEvent() consume the touch event. To fix for this, simply reset the three attributes.

So the final utility method, to accompany the ClickableMovementMethod, is here:

public static void setTextViewLinkClickable(TextView textView) {
    textView.setMovementMethod(ClickableMovementMethod.getInstance());
    // Reset for TextView.fixFocusableAndClickableSettings(). We don't want View.onTouchEvent()
    // to consume touch events.
    textView.setClickable(false);
    textView.setLongClickable(false);
}

This works like a charm for me.

Click events on ClickableSpans are fired, and click outside them are passed throught to parent layout listener.

Note that if your are making your TextView selectable, I haven't tested for that case, and maybe you need to dig into the source yourself :P

Spang answered 10/12, 2015 at 11:31 Comment(1)
Perfect Dreaming !Fulvia
P
16

The first answer to your question is that you aren't setting a click listener on your TextView which is consuming click events as user2558882 points out. After you set a click listener on your TextView, you'll see that areas outside the ClickableSpans's touch area will work as expected. However, you'll then find that when you click on one of your ClickableSpans, the TextView's onClick callback will be fired as well. That leads us to a difficult issue if having both fire is an issue for you. user2558882's reply can't guarantee that your ClickableSpan's onClick callback will be fired before your TextView's. Here's some solutions from a similar thread that are better implemented and an explanation from the source. The accepted answer that thread should work on most devices, but the comments for that answer mention certain devices having issues. It looks like some devices with custom carrier/manufacturer UIs are to blame, but that's speculation.

So why can't you guarantee onClick callback order? If you take a look at the source for TextView (Android 4.3), you'll notice that in the onTouchEvent method, boolean superResult = super.onTouchEvent(event); (super is View) is called before handled |= mMovement.onTouchEvent(this, (Spannable) mText, event); which is the call to your movement method which then calls your ClickableSpan's onClick. Taking a look at super's (View) onTouchEvent(..), you'll notice:

    // Use a Runnable and post this rather than 
    // performClick directly. This lets other visual 
    // of the view update before click actions start.
    if (mPerformClick == null) {
        mPerformClick = new PerformClick();
    }
    if (!post(mPerformClick)) { // <---- In the case that this won't post, 
        performClick();         //    it'll fallback to calling it directly
    }

performClick() calls the click listener set, which in this case is our TextView's click listener. What this means, is that you won't know in what order your onClick callbacks are going to fire. What you DO know, is that your ClickableSpan and TextView click listeners WILL be called. The solution on the thread I mentioned previously, helps ensure the order so you can use flags.

If ensuring compatibility with a lot of devices is a priority, you are best served by taking a second look at your layout to see if you can avoid being stuck in this situation. There are usually lots of layout options to skirt cases like this.

Edit for comment answer:

When your TextView executes onTouchEvent, it calls your LinkMovementMethod's onTouchEvent so that it can handle calls to your various ClickableSpan's onClick methods. Your LinkMovementMethod does the following in its onTouchEvent:

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

            }
         }

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

You'll notice that it takes the MotionEvent, gets the action (ACTION_UP: lifting finger, ACTION_DOWN: pressing down finger), the x and y coordinates of where the touch originated and then finds which line number and offset (position in the text) the touch hit. Finally, if there are ClickableSpans that encompass that point, they are retrieved and their onClick methods are called. Since we want to pass on any touches to your parent layout, you could either call your layouts onTouchEvent if you want it to do everything it does when touched, or you could call it's click listener if that implements your needed functionality. Here's where do to that:

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

                // Your call to your layout's onTouchEvent or it's 
                //onClick listener depending on your needs

            }
         }

So to review, you'll create a new class that extends LinkMovementMethod, override it's onTouchEvent method, copy and paste this source with your calls in the correct position where I commented, ensure you're setting your TextView's movement method to this new subclass and you should be set.

Edited again for possible side effect avoidance Take a look at ScrollingMovementMethod's source (LinkMovementMethod's parent) and you'll see that it's a delegate method which calls a static method return Touch.onTouchEvent(widget, buffer, event); This means that you can just add that as your last line in the method and avoid calling super's (LinkMovementMethod's) onTouchEvent implementation which would duplicate what you're pasting in and other events can fall through as expected.

Pterous answered 2/9, 2013 at 15:25 Comment(4)
Thanks for the excellent answer. However what I want is the parent layout of the text View to handle the touch in areas outside the Clickable Spans's touch area, but I am still not able to get it. If I intercept the touch in parent, I won't be able to reach the clickable spans. Can you suggest a solution to overcome this?Horsewhip
So can I add super.onTouchEvent(widget, buffer, event); in the else part below Selection.removeSelection(buffer); so that the touch will be handled by some parent of the textview which implements ontouch method?Horsewhip
If you call super.onTouchEvent(widget, buffer, event); that will specifically call ScrollingMovementMethod 's onTouchEvent implementation. You can call any other class that implements that method or something with a similar signature though. So if you wanted say a RelativeLayout to handle it, You'd do something like RelativeLayout mLayout = new RelativeLayout(context); mLayout.onTouchEvent();Pterous
Thanks. for now I am calling layout.performclick() in textview's clicklistener which seems to be working fine.. But it was nice to know the details of these things :)Horsewhip
A
6

Here is an easy solution and it worked for me

You can achieve this using a work around in getSelectionStart() and getSelectionEnd() functions of the Textview class,

tv.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        ClassroomLog.log(TAG, "Textview Click listener ");
        if (tv.getSelectionStart() == -1 && tv.getSelectionEnd() == -1) {
            //This condition will satisfy only when it is not an autolinked text
            //Fired only when you touch the part of the text that is not hyperlinked 
        }
    }
});
Amide answered 29/2, 2016 at 10:6 Comment(1)
This actually worked for me, in the following case: 1. My TextView is clickable and calls parent.perfomClick() 2. Part of my TextView is a clickable Span that fires a separate event, in which case I don't want to trigger my TextView's click eventDefloration
H
0

Declare a global boolean variable:

boolean wordClicked = false;

Declare and initialize l as final:

final RelativeLayout l = (RelativeLayout)findViewById(R.id.contentLayout);

Add an OnClickListener to textView:

textView.setOnClickListener(new OnClickListener() {

    @Override
    public void onClick(View v) {
        if (!wordClicked) {
            // Let the click be handled by `l's` OnClickListener
            l.performClick();   
        }
    }
});

Change span to:

ClickableSpan span = new ClickableSpan() {

    @Override
    public void onClick(View widget) {
        wordClicked = true;
        Toast.makeText(Trial.this, "just word", Toast.LENGTH_SHORT).show();

        // A 100 millisecond delay to let the click event propagate to `textView's` 
        // OnClickListener and to let the check `if (!wordClicked)` fail
        new Handler().postDelayed(new Runnable() {

            @Override
            public void run() {
                wordClicked = false;                        
            }
        }, 100L);
    }
};

Edit:

Keeping user KMDev's answer in view, the following code will meet your specifications. We create two spans: one for the specified length: spannableString.setSpan(.., 0, 5, ..); and the other with the remainder: spannableString.setSpan(.., 6, spannableString.legth(), ..);. The second ClickableSpan(span2) performs a click on the RelativeLayout. Moreover, by overriding updateDrawState(TextPaint), we are able to give the second span a non-distinctive (non-styled) look. Whereas, first span has a link color and is underlined.

final RelativeLayout l = (RelativeLayout)findViewById(R.id.contentLayout);
    l.setOnClickListener(new OnClickListener(){
        @Override
        public void onClick(View v) {
            Toast.makeText(Trial.this, "whole layout", Toast.LENGTH_SHORT).show();
        }
    });

TextView textView = (TextView)findViewById(R.id.t1);
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setHighlightColor(Color.TRANSPARENT);

SpannableString spannableString = new SpannableString(textView.getText().toString());

ClickableSpan span = new ClickableSpan() {
    @Override
    public void onClick(View widget) {
        Toast.makeText(Trial.this, "just word", Toast.LENGTH_SHORT).show();
    }
};

spannableString.setSpan(span, 0, 5, Spannable.SPAN_INCLUSIVE_INCLUSIVE);     

ClickableSpan span2 = new ClickableSpan() {
    @Override
    public void onClick(View widget) {
        l.performClick();
    }

    @Override
    public void updateDrawState(TextPaint tp) {
        tp.bgColor = getResources().getColor(android.R.color.transparent);
        tp.setUnderlineText(false);
    }
};

spannableString.setSpan(span2, 6, spannableString.length(), 
                                      Spannable.SPAN_INCLUSIVE_INCLUSIVE);  

textView.setText(spannableString);

Special thanks to user KMDev for noticing the issues with my original answer. There's no need for performing a (faulty) check using boolean variable(s), and setting an OnclickListener for the TextView is not required.

Hindward answered 2/9, 2013 at 12:58 Comment(3)
Actually I am creating the layouts dynamically and are local. Thus I have many textviews which will need this implementation. So I guess I cannot keep a global boolean variable.Horsewhip
@DeepakSenapati You can keep reusing the global boolean variable with your dynamically created layouts. The boolean is a blocking mechanism. It is fairly independent of the layouts that use it. I will be editing/improving my answer because user KMDev raises some excellent points.Hindward
@DeepakSenapati I have updated my answer (see edit), removing the requirement for a global boolean variable. Tested it: works well on my end.Hindward
A
0

The easiest and fastest way to implement ClickableSpan is:

new SmartClickableSpan
   .Builder(this)
   .regularText("I agree to all ")
   .clickableText(new ClickableOptions().setText("Terms of Use").setOnClick(new 
       ClickableSpan() {
           @Override
           public void onClick(@NonNull View view) {
               // Your Code..
           }
   }))
   .into(myTextView);

Adding regular Clickable Spans in Android requires calculating sizes for each clickable text, and when it comes to adding a lot of clickable and regular words or sentences in a TextView is becomes a mess.. By using SmartClickableSpan, you'll be able to add whatever amount of clickable words or sentences without any worries of calculating length of each text on every update on it.

SmartClickableSpan Github:

https://github.com/HseinNd98/SmartClickableSpan
Adenoidectomy answered 20/9, 2020 at 21:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.