TextView 可以选择并包含链接吗?

Posted

技术标签:

【中文标题】TextView 可以选择并包含链接吗?【英文标题】:Can a TextView be selectable AND contain links? 【发布时间】:2013-04-05 14:19:41 【问题描述】:

我遇到了TextView 的问题。我可以使用setTextIsSelectable(true) 使其可选择,但是当我通过setMovementMethod(LinkMovementMethod.getInstance()) 启用要单击的链接时,它不再可选择。

请注意,我的意思不是让原始链接可点击,而是通过使用类似 setText(html.fromHtml("<a href='http://***.com'>Hello World!</a>")) 的 HTML 标记加载 TextView 来使实际单词可点击。

【问题讨论】:

你用 Linkify 试过了吗? 根据文档,Linkify 仅适用于带有 URL 方案前缀的文本,不适用于您想要转换为链接的任意文本。 我认为你必须创建自定义 TextView 是否可以继承 LinkMovementMethod 并对其进行修改以允许选择? TextView that is linkified and selectable?的可能重复 【参考方案1】:

oakes's 回答导致在 textview 上双击时出现异常

java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) 开始于 0...

我查看了 LinkMovementMethod 中的 onTouchEvent 实现,发现当 textview 不包含链接时它会删除选择。在这种情况下,选择从空值开始,当用户尝试更改它时应用程序崩溃。

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

...

所以我重写了 onTouchEvent 方法,它工作正常。

public class CustomMovementMethod extends LinkMovementMethod 
    @Override
    public boolean canSelectArbitrarily () 
        return true;
    

    @Override
    public void initialize(TextView widget, Spannable text) 
        Selection.setSelection(text, text.length());
    

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) 
        if ((dir & (View.FOCUS_FORWARD | View.FOCUS_DOWN)) != 0) 
            if (view.getLayout() == null) 
                // This shouldn't be null, but do something sensible if it is.
                Selection.setSelection(text, text.length());
            
         else 
            Selection.setSelection(text, text.length());
        
    

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

        return Touch.onTouchEvent(widget, buffer, event);
    

希望对某人有所帮助。

【讨论】:

这个解决方案对我有用,而不是下一个!非常感谢您的分享-您拯救了我的一天!在 2 台设备上测试。【参考方案2】:

我想通了。您需要继承 LinkMovementMethod 并添加对文本选择的支持。很遗憾它本身不支持它。我只是使用source code 中的等效方法为ArrowKeyMovementMethod 覆盖了相关方法。我想这是 android 开源的好处之一!

public class CustomMovementMethod extends LinkMovementMethod 
    @Override
    public boolean canSelectArbitrarily () 
        return true;
    

    @Override
    public void initialize(TextView widget, Spannable text) 
        Selection.setSelection(text, text.length());
    

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) 
       if ((dir & (View.FOCUS_FORWARD | View.FOCUS_DOWN)) != 0) 
           if (view.getLayout() == null) 
               // This shouldn't be null, but do something sensible if it is.
               Selection.setSelection(text, text.length());
           
        else 
           Selection.setSelection(text, text.length());
       
    

要使用它,只需直接实例化它,如下所示:

textView.setMovementMethod(new CustomMovementMethod());

【讨论】:

不错!非常感谢。 老兄,你太棒了!我开始认为这是不可能的,但后来我偶然发现了你的问题。一定要喜欢开源! 对我们来说这是可行的,但我们必须将 textIsSelectable 设置为 true 仅供参考,在某些情况下,此解决方案有时会导致“java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) 在 0 之前开始”,例如在 Nexus 5 设备上双击时。 完美!你拯救了我的一天。【参考方案3】:

TL;DR:只需在此答案末尾使用 LinkArrowKeyMovementMethod 即可获得完美解决方案。

如果您曾经尝试使用扩展 LinkMovementMethod 的投票最多的答案,则会出现一个烦人的错误 - 当您通过单击一些未选择的文本取消选择时,整个选择会从一开始就闪烁到选择结束,然后什么都没有。这是因为 LinkMovementMethod 实际上无法像 ArrowKeyMovementMethod 那样处理选择。

如果您已将android:autoLink 设置为true,则另一种方法可能是使用TextView 自己的解决方法,如TextView 的以下来源:

        final boolean textIsSelectable = isTextSelectable();
        if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) 
            // The LinkMovementMethod which should handle taps on links has not been installed
            // on non editable text that support text selection.
            // We reproduce its behavior here to open links for these.
            ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
                getSelectionEnd(), ClickableSpan.class);

            if (links.length > 0) 
                links[0].onClick(this);
                handled = true;
            
        

但我个人不想要自动链接功能(我有我自己的链接信息),所以在@Weidian Huang 的想法的基础上,我将LinkMovementMethod的功能合并到ArrowKeyMovementMethod中并构建了一个新的运动方法:

/**
 * @see LinkMovementMethod
 * @see ArrowKeyMovementMethod
 */
public class LinkArrowKeyMovementMethod extends ArrowKeyMovementMethod 

    private static final int CLICK = 1;
    private static final int UP = 2;
    private static final int DOWN = 3;

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

    private static LinkArrowKeyMovementMethod sInstance;

    public static LinkArrowKeyMovementMethod getInstance() 
        if (sInstance == null) 
            sInstance = new LinkArrowKeyMovementMethod();
        
        return sInstance;
    

    @Override
    public void initialize(TextView widget, Spannable text) 
        super.initialize(widget, text);

        text.removeSpan(FROM_BELOW);
    

    @Override
    public void onTakeFocus(TextView view, Spannable text, int dir) 
        super.onTakeFocus(view, text, dir);

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

    @Override
    protected boolean handleMovementKey(TextView widget, Spannable buffer, int keyCode,
                                        int movementMetaState, KeyEvent event) 
        switch (keyCode) 
            case KeyEvent.KEYCODE_DPAD_CENTER:
            case KeyEvent.KEYCODE_ENTER:
                if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) 
                    if (event.getAction() == KeyEvent.ACTION_DOWN &&
                            event.getRepeatCount() == 0 && action(CLICK, widget, buffer)) 
                        return true;
                    
                
                break;
        
        return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, 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) 
        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;
    

    @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[] links = buffer.getSpans(off, off, ClickableSpan.class);

            if (links.length != 0) 
                if (action == MotionEvent.ACTION_UP) 
                    links[0].onClick(widget);
                 else if (action == MotionEvent.ACTION_DOWN) 
                    Selection.setSelection(buffer,
                            buffer.getSpanStart(links[0]),
                            buffer.getSpanEnd(links[0]));
                
                return true;
            
            // Removed
            //else 
            //    Selection.removeSelection(buffer);
            //
        

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

要使用它,只需调用:

textView.setTextIsSelectable(true);
textView.setMovementMethod(LinkArrowKeyMovementMethod.getInstance());

这对我来说非常有效。

【讨论】:

【参考方案4】:

LinkMovementMethod()不太支持文本选择,即使我们可以选择文本,但是滚动textview后,选择会丢失。

最好的实现是从ArrowKeyMovementMethod扩展而来,它很好地支持了文本选择。

详情请看here

【讨论】:

【参考方案5】:

XML TextView 不应有任何链接或任何可以选择的属性:

<TextView
    android:layout_
    android:layout_/>

然后,按照以下顺序以编程方式设置所有内容:

textView.setText(Html.fromHtml(myHtml));
Linkify.addLinks(textView, Linkify.WEB_URLS);
textView.setTextIsSelectable(true); // API-11 and above
textView.setMovementMethod(LinkMovementMethod.getInstance());

【讨论】:

没想到这个排序修复会起作用,但它确实起作用了!【参考方案6】:

另外,订单很重要

textView.setTextIsSelectable(true);
textView.setMovementMethod(LinkMovementMethod.getInstance());

允许选择内容并且链接点击工作完美

【讨论】:

【参考方案7】:

这是我对 Kotlin 的看法(大致基于 @hai-zhang 的回答)。 简化!请参阅我的要点以获得更好的版本。我目前将它用于自定义跨度,而不是 HTML,它仍然对我有用,尤其是当我需要将用户点击的位置传递给跨度对象时。

需要设置移动方法after setTextIsSelectable(true)

/** Minimal version of Smart Movement that only has limited support of [ClickableSpan] */
object SmartMovementMethodMinimal : ArrowKeyMovementMethod() 

    override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?) =
        handleMotion(event!!, widget!!, buffer!!) || super.onTouchEvent(widget, buffer, event)

    private fun handleMotion(event: MotionEvent, widget: TextView, buffer: Spannable): Boolean 
        if (event.action == MotionEvent.ACTION_UP) 
            // Get click position
            val target = Point().apply 
                x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
                y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
            

            // Get span line and offset
            val line = widget.layout.getLineForVertical(target.y)
            val offset = widget.layout.getOffsetForHorizontal(line, target.x.toFloat())

            if (event.action == MotionEvent.ACTION_UP) 
                val spans = buffer.getSpans<ClickableSpan>(offset, offset)
                if (spans.isNotEmpty()) 
                    spans.forEach  it.onClick(widget) 
                    return true
                
            
        

        return false
    

更详细和更复杂的代码和示例在这里:https://gist.github.com/sQu1rr/210f7e08dd939fa30dcd2209177ba875

【讨论】:

【参考方案8】:

是否可以将 TextView 与 URL 关联? 如果您有 10 个 TextView 和 10 个 URL,编写代码应该很简单,如果单击 TextView[3],它会触发带有 URL[3] 的 webview(或浏览器)的意图

【讨论】:

不幸的是,我需要在TextView 中拥有多个链接的能力,就像在普通的 HTML 标记中一样。我知道我可以使用WebView,但我试图避免这种资源密集型控制。

以上是关于TextView 可以选择并包含链接吗?的主要内容,如果未能解决你的问题,请参考以下文章

3行后在TextView末尾添加“查看更多”[重复]

SpannableString的使用

同一个TextView中的多个id

TextView 使用详解

Android界面 使用TextView实现跑马灯效果

如何修改TextView链接点击实现(包含链接生成与点击原理分析)