Android TextView 使用ClickableSpan时遭遇的几个坑总结与优化

2,187 阅读4分钟

一、背景

在之前我们有使用Span实现了Android富文本编辑器,具体可以看:Android Span架构介绍 & 如何基于TextView 实现富文本编辑器

在TextView中设置ClickableSpan后,如果需要其生效点击事件,我们需要调用TextView.setMovementMethod()接口设置 LinkMovementMethod。而Android SDK中提供的存在较多体验问题,这里基于这个细节点聊聊。

二、点击空白处响应问题

某日UX同学反馈点击含有链接的文本的空白区域也会响应点击事件,这不符合用户的预期,希望能够改进。

大致是这个位置:

POPO20240527-204801.jpg

经过排查问题是出现在 LinkMovementMethod 中。

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

            if (links.length != 0) {
                ClickableSpan link = links[0];
                if (action == MotionEvent.ACTION_UP) {
                    if (link instanceof TextLinkSpan) {
                        ((TextLinkSpan) link).onClick(
                                widget, TextLinkSpan.INVOCATION_METHOD_TOUCH);
                    } else {
                        link.onClick(widget);
                    }
                } else if (action == MotionEvent.ACTION_DOWN) {
                    if (widget.getContext().getApplicationInfo().targetSdkVersion
                            >= Build.VERSION_CODES.P) {
                        // Selection change will reposition the toolbar. Hide it for a few ms for a
                        // smoother transition.
                        widget.hideFloatingToolbar(HIDE_FLOATING_TOOLBAR_DELAY_MS);
                    }
                    Selection.setSelection(buffer,
                            buffer.getSpanStart(link),
                            buffer.getSpanEnd(link));
                }
                return true;
            } else {
                Selection.removeSelection(buffer);
            }
        }

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

看代码发现,源码中是获取了用户点击事件的x、y位置。然后依据x、y的位置获取其在TextView中的行数,获取这行中是否存在 ClickableSpan ,存在ClickableSpan即响应点击事件。

优化方案

既然是源码中没有判断具体的落点位置,那么我们就可以通过计算用户的点击位置是否是在这一行的文本的宽度内优化掉这个问题。 具体代码如下:

if (link.length > 0) {
    if (action == MotionEvent.ACTION_UP) {
        //只需要点击事件
        float textWidth = layout.getLineWidth(line);
        if (x <= textWidth) {
            link[0].onClick(widget);
        }
    }
    return true;
}

三、EditText 光标乱跳问题

在富文本编辑器中,每次调整光标发现光标都会先出现在头部,然后再出现在正确的selection位置。这个给用户的体验就非常不好。 优化前:

SVID_20240527_204355 -original-original.gif

定位发现是在引入富文本链接之后出现的该问题。

优化方案

进一步定位发现是在 LinkMovementMethod 类中onTouch()方法中每次UP事件都会调用Selection.removeSelection(buffer)方法最终导致光标跳动。

改进的办法就是扩展 LinkMovementMethod 然后删除这部分对响应 ClickableSpan 的onClick方法不影响的逻辑。 优化后:

SVID_20240527_204646 -original-original.gif

四、同时响应ClickableSpan以及TextView的OnClick事件

在某个业务中,我们有一个稍微有些特殊的需求,即TextView以及其文本中的ClickableSpan都要响应事件,同时ClickableSpan响应事件之后,TextView不可以再响应事件。

而现实是用户点击了链接之后,即响应了链接的事件,也响应了TextView的事件。其原因是在TextView在处理 LinkMovementMethod.onTouchEvent()之前会先调用super.onTouchEvent。就算是LinkMovementMethod.onTouchEvent()消费了这个事件也无法阻止,TextView响应点击事件。

代码位置

public boolean onTouchEvent(MotionEvent event) {
        if (DEBUG_CURSOR) {
            logCursor("onTouchEvent", "%d: %s (%f,%f)",
                    event.getSequenceNumber(),
                    MotionEvent.actionToString(event.getActionMasked()),
                    event.getX(), event.getY());
        }
        ...

        final boolean superResult = super.onTouchEvent(event);
        if (DEBUG_CURSOR) {
            logCursor("onTouchEvent", "superResult=%s", superResult);
        }

        ...

        final boolean touchIsFinished = (action == MotionEvent.ACTION_UP)
                && (mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();

        if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
                && mText instanceof Spannable && mLayout != null) {
            boolean handled = false;

            if (mMovement != null) {
                handled |= mMovement.onTouchEvent(this, mSpannable, event);
            }

            ...

            ...
        }

        return superResult;
    }

优化方案

初级方案

使用一个静态变量来控制,当 ClickableSpan 响应事件之后,TextView设置的ClickListener的onClick方法即使回调了也不执行。问题当然可以解决,但这个方法缺点也比较明显。静态变量需要注意维护其数据,合适设置什么值都需要考虑清楚。同时整个逻辑也变得难以维护。

优雅一些的方案

接收到ACTION_DOWN事件之后,就直接发送一个像View发送一个CANCEL事件。这样TextView的点击事件就不会响应了。代码如下:

widget.onTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_CANCEL, 0, 0, 0));

五、总结

本片主要介绍了我们在使用 ClickSpan 时遭遇到的一些交互优化点。

实际上当我们第一次被要求到做这些优化时,可能第一个反应是【这就是系统API的设计,这种我们怎么优化】类似这样的想法。或者干脆没有任何头绪。此时确实需要静下心来一点点来梳理代码逻辑才能将交互体验优化到最好。