Android:可点击TextView中的ClickableSpan

50

我有一个可以包含可点击链接的TextView。当其中一个链接被点击时,我想要启动一个Activity。这个功能运行良好,但也应该可以点击整个TextView并启动另一个Activity。

这是我目前的解决方案:

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

问题是,当我设置一个OnClickListener时,每次点击链接时首先会调用整个textview的监听器,然后才调用ClickableSpan的监听器。

有没有办法阻止Android在单击链接时调用整个textview的监听器?或者在整个视图的监听器中决定是否单击了链接?


1
我认为我遇到了类似的问题。但只有当ClickableSpan是TextView中最后一个“文本”时才会发生这种情况。 - Andrew Mackenzie
1
在那些可点击的跨度是最后一段文本且您不希望剩余空间可点击的情况下,只需添加一个非跨度空格即可。 - Mark
8个回答

50

我找到了一个相当简单的解决方法。在所有不是链接的文本区域上定义ClickableSpan,并在它们上处理点击事件,就好像点击了文本视图:

发现一个相对简单的解决方法。对于那些不属于链接的文本区域,定义ClickableSpan并将点击事件处理为点击文本视图即可。

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

希望这可以帮到你(我知道这篇文章已经有些老了,但如果有人现在看到它...)。


40

这是一个非常简单的解决方案... 对我很有效

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

4
效果很好。这应该被接受作为答案。 - futurexiong
2
如果你想知道为什么它会起作用,可以在LinkMovementMethod类的onTouchEvent中搜索Selection.setSelection - dumbfingers
这个很好用。非常感谢@Lahiru Pinto。 - Mert Gunay
第一次点击TextView时,两个值始终为-1。第二次点击可以正常工作。 - Krutika Chotara

12

Matthew建议通过子类化TextView来解决问题,我借此想出了一个相当丑陋的解决方案,但它可以工作:

我创建了一个“ClickPreventableTextView”,当我在TextView中有可点击的span需要整体可点击时,我使用它。

在它的onTouchEvent方法中,该类在调用其基类TextView类的onTouchEvent之前调用可移动方法(MovementMethod)的onTouchEvent方法。因此,可以保证会先调用clickablespan的监听器。我可以防止调用整个TextView的OnClickListener。

/**
 * 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);
}
}

现在可点击的span的监听器看起来像这样

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

对我来说,主要的缺点是现在getMovementMethod().onTouchEvent会被调用两次(TextView在它的onTouchEvent方法中调用了该方法)。我不知道这是否会有任何副作用,目前它的表现如预期。


我曾就我的一个应用程序遇到的类似问题使用了类似的解决方法,在三星TouchWiz手机上,点击事件的顺序相反,导致我的“防止下一次点击”的逻辑无法正常工作。最终,我放弃了整个方法,采取了其他方法。如果可以的话,值得在Galaxy S手机上尝试您的解决方案,以确保它能正常工作。 - jjb
谢谢,我会尝试找一个Galaxy S的人。但是我认为因为我在ClickPreventableTextView的onTouchEvent中调用了MovementMethods的onTouchEvent,然后才调用基类(TextView)的onTouchEvent,所以点击事件应该总是按正确顺序进行。 - Lukas
实际上,三星Galaxy Tab会给我随机的事件顺序,span或textView会先捕获点击,但两个事件总是被触发。 - A-Live

7
这段代码对我来说可用,是从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;
            }
        });

如果 (event.action == MotionEvent.ACTION_UP) { // } - vishnu benny
什么是 contentSpan? - Krutika Chotara
contentSpan是用于TextView.setText方法的SpannableString参数,在生成之前。 - Cui Qing

2

很简单,您可以在ClickableSpan回调中取消textview的点击待定意图

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

2

我曾经以一种非常好的方式解决了类似的问题。我想要有一个可点击的链接文本!并且我希望在没有链接的文本区域也能够点击并添加一个点击监听器。我使用了grepcode中的LinkMovementMethod并做了一些修改。复制粘贴这个类和底部的代码,它就可以工作:

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

然后在你的代码中添加文本视图:

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

0
我认为这涉及到子类化TextView并更改其行为。不幸的是,您考虑过尝试在TextView后面放置一个背景并将onClick监听器附加到它上面吗?

哦,我不能将一个 onClickListener 附加到背景(drawable)上吗?或者我漏掉了什么? - Lukas
一个背景视图在TextView后面,就像重叠的视图。 - Matthew
我尝试使用FrameLayout,将TextView和另一个视图放在后台。但是“背景视图”从未收到点击事件,我认为它们总是由最上层的元素处理。 - Lukas

0
复制以下函数。
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
        }
    }

使用上述函数,传递TextView和可点击字符串。
 setClickableHighLightedText(tvTest,"test") {
    showMessage("click")
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接