写在前面

先来个段子:

刚工作时遇到一个特别难搞定的需求,当时没做出来,感到很羞耻。过了几年,再一次遇到这个需求,还是没做出来,只是不再感到羞耻了。

在我刚开始工作的时候,也有过一次这样的经历。当时项目中有个需求,让 TextView 中的文本可以选择复制,正常来讲,应该是很容易实现的,直接按照下面的设置就可以了:

mTextView.setTextIsSelectable(true);

但是,这个简单的实现并不是完美的,主要有几个问题:

以上说了这么多,问题的解决办法就是:自己写一个选择复制的功能,这样以上三个问题都能很好地解决了。

看起来很容易,但是对于当时刚刚入门的我来说,这是个完全没头绪的任务。

时隔一年之后,再遇到这个需求,这次通过 Google、GitHub ,以及参考 SDK 23 中 TextView 源码,基本上实现了自定义选择复制的功能,效果如下:

保证所有的平台上显示效果一致,弹出的操作菜单可以自己定制,并设置相应的操作。

实现要求和要点

在开始具体的实现之前,先确定下实现的要求:

本文最终实现的使用方式如下所示,均满足以上的实现要求:

mSelectableTextHelper = new SelectableTextHelper.Builder(mTvTest)
    .setSelectedColor(getResources().getColor(R.color.selected_blue))
    .setCursorHandleSizeInDp(20)
    .setCursorHandleColor(getResources().getColor(R.color.cursor_handle_color))
    .build();

整个自定义的选择复制功能视图上主要有三个部分:

在具体实现中有以下要点:

实现思路

在开始实践之前,查找资料是少不了的,首先找到了 记划词模块重构感受|开源实验室-张涛 这篇文章,但是这篇文章中更多是提供了一个改进某个开源项目的思路,并没有给出具体的代码,而且连那个开源项目也没给出地址。

后来通过搜索关键字,找到了那个开源项目: zhouray/SelectableTextView

如张涛吐槽的那样,这个项目的实现确实不够优雅,主要存在两个问题:

如果你有时间可以看一下这个项目的代码,在本文后面的实现中,也部分参考了该项目。

参考上面提到的文章和开源项目,实现思路基本确定了:

大致的思路确定,接下来就是具体的实现了。

具体实现过程

自定义的选择复制类取名为 SelectableTextHelper,其有一个字段 mTextView,持有需要设置选择复制的 TextView 对象。

初步设置

由于 TextView 的文本的 BufferType 类型是 SPANNABLE 时才可以设置 Span ,实现选中的效果,因此在一开始先给 TextView 设置下:

mTextView.setText(mTextView.getText(), TextView.BufferType.SPANNABLE);

接下来给 TextView 设置相关的点击、长按、Touch 事件:

    mTextView.setOnLongClickListener(new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View v) {
            showSelectView(mTouchX, mTouchY);
            return true;
        }
    });
    
    mTextView.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            mTouchX = (int) event.getX();
            mTouchY = (int) event.getY();
            return false;
        }
    });
    
    mTextView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            resetSelectionInfo();
            hideSelectView();
        }
    });

直接看一下 showSelectView()hideSelectView() 的实现:

显示选择相关组件

private void showSelectView(int x, int y) {
    hideSelectView();
    resetSelectionInfo();
    isHide = false;
    if (mStartHandle == null) mStartHandle = new CursorHandle(true);
    if (mEndHandle == null) mEndHandle = new CursorHandle(false);
    int startOffset = TextLayoutUtil.getPreciseOffset(mTextView, x, y);
    int endOffset = startOffset + DEFAULT_SELECTION_LENGTH;
    if (mTextView.getText() instanceof Spannable) {
        mSpannable = (Spannable) mTextView.getText();
    }
    if (mSpannable == null || startOffset >= mTextView.getText().length()) {
        return;
    }
    selectText(startOffset, endOffset);
    showCursorHandle(mStartHandle);
    showCursorHandle(mEndHandle);
    mOperateWindow.show();
}

通过传入『种』那个字附近的某个点的坐标 (x,y),就可以得出『种』在 TextView 的文本中的索引是 9 (从 0 开始计数)。

TextLayoutUtil.getPreciseOffset() 方法如下:

public static int getPreciseOffset(TextView textView, int x, int y) {
    Layout layout = textView.getLayout();
    if (layout != null) {
        int topVisibleLine = layout.getLineForVertical(y);
        int offset = layout.getOffsetForHorizontal(topVisibleLine, x);
        int offsetX = (int) layout.getPrimaryHorizontal(offset);
        if (offsetX > x) {
            return layout.getOffsetToLeftOf(offset);
        } else {
            return offset;
        }
    } else {
        return -1;
    }
}

这里涉及到 TextView 的文本布局类 Layout ,虽然看过这块的部分源码,但是这里的处理还是有点懵,本文就不多深入了,有兴趣的话可以自行了解下这块的源码。

隐藏选择相关组件

这里没啥好说的,就是判空下左右选择游标和操作框,如果非空,则调用对应的 dismiss() 方法

private void hideSelectView() {
    isHide = true;
    if (mStartHandle != null) {
        mStartHandle.dismiss();
    }
    if (mEndHandle != null) {
        mEndHandle.dismiss();
    }
    if (mOperateWindow != null) {
        mOperateWindow.dismiss();
    }
}

这里基本的流程和相关的实现细节已大概讲述了下,接下来就是就是选择游标和操作框的实现。

选择游标

由于游标的移动涉及到文字的选中,以及操作框的显隐、定位,就直接实现为 SelectableTextHelper 的内部类。直接上代码:

private class CursorHandle extends View {
    private PopupWindow mPopupWindow;
    private Paint mPaint;
  
    private int mCircleRadius = mCursorHandleSize / 2;
    private int mWidth = mCircleRadius * 2;
    private int mHeight = mCircleRadius * 2;
    private int mPadding = 25;
    private boolean isLeft;
    public CursorHandle(boolean isLeft) {
        super(mContext);
        this.isLeft = isLeft;
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(mCursorHandleColor);
        mPopupWindow = new PopupWindow(this);
        mPopupWindow.setClippingEnabled(false);
        mPopupWindow.setWidth(mWidth + mPadding * 2);
        mPopupWindow.setHeight(mHeight + mPadding / 2);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawCircle(mCircleRadius + mPadding, mCircleRadius, mCircleRadius, mPaint);
        if (isLeft) {
            canvas.drawRect(mCircleRadius + mPadding, 0, mCircleRadius * 2 + mPadding, mCircleRadius, mPaint);
        } else {
            canvas.drawRect(mPadding, 0, mCircleRadius + mPadding, mCircleRadius, mPaint);
        }
    }
  
  ......
    
}

直接继承 PopupWindow 的话,没有 onDraw 方法 ,这里直接继承 View ,然后在 CursorHandle 的构造函数中初始化了一个 PopupWindow ,并将 CursorHandle 实例作为 contentView 传递进去,然后在 onDraw() 方法中绘制了自定义的选择游标,仿照 6.0 的选择游标效果。

这个也是绘制起来也是很简单的,一个正方形和一个圆组合下即可,处理下是左边还是右边就可以了,具体参照上面的代码。

接下来就是设置相关的触摸事件,响应拖动游标时更新选中的文本。

private int mAdjustX;
private int mAdjustY;
private int mBeforeDragStart;
private int mBeforeDragEnd;
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mBeforeDragStart = mSelectionInfo.mStart;
            mBeforeDragEnd = mSelectionInfo.mEnd;
            mAdjustX = (int) event.getX();
            mAdjustY = (int) event.getY();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            mOperateWindow.show();
            break;
        case MotionEvent.ACTION_MOVE:
            mOperateWindow.dismiss();
            int rawX = (int) event.getRawX();
            int rawY = (int) event.getRawY();
            update(rawX + mAdjustX - mWidth, rawY + mAdjustY - mHeight);
            break;
    }
    return true;
}

操作框

操作框的实现则简单的多,就是自定义布局的 PopupWindow ,然后处理下内部的 View 的点击事件即可,直接贴代码:

private class OperateWindow {
    private PopupWindow mWindow;
    private int[] mTempCoors = new int[2];
    private int mWidth;
    private int mHeight;
    public OperateWindow(final Context context) {
        View contentView = LayoutInflater.from(context).inflate(R.layout.layout_operate_windows, null);
        contentView.measure(View.MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, View.MeasureSpec.AT_MOST),
            View.MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, View.MeasureSpec.AT_MOST));
        mWidth = contentView.getMeasuredWidth();
        mHeight = contentView.getMeasuredHeight();
        mWindow =
            new PopupWindow(contentView, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, false);
        mWindow.setClippingEnabled(false);
        contentView.findViewById(R.id.tv_copy).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ClipboardManager clip = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
                clip.setPrimaryClip(
                    ClipData.newPlainText(mSelectionInfo.mSelectionContent, mSelectionInfo.mSelectionContent));
                if (mSelectListener != null) {
                    mSelectListener.onTextSelected(mSelectionInfo.mSelectionContent);
                }
                SelectableTextHelper.this.resetSelectionInfo();
                SelectableTextHelper.this.hideSelectView();
            }
        });
        contentView.findViewById(R.id.tv_select_all).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                hideSelectView();
                selectText(0, mTextView.getText().length());
                isHide = false;
                showCursorHandle(mStartHandle);
                showCursorHandle(mEndHandle);
                mOperateWindow.show();
            }
        });
    }
  
    public void show() {
        mTextView.getLocationInWindow(mTempCoors);
        Layout layout = mTextView.getLayout();
        int posX = (int) layout.getPrimaryHorizontal(mSelectionInfo.mStart) + mTempCoors[0];
        int posY = layout.getLineTop(layout.getLineForOffset(mSelectionInfo.mStart)) + mTempCoors[1] - mHeight - 16;
        if (posX <= 0) posX = 16;
        if (posY < 0) posY = 16;
        if (posX + mWidth > TextLayoutUtil.getScreenWidth(mContext)) {
            posX = TextLayoutUtil.getScreenWidth(mContext) - mWidth - 16;
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            mWindow.setElevation(8f);
        }
        mWindow.showAtLocation(mTextView, Gravity.NO_GRAVITY, posX, posY);
    }
  
    public void dismiss() {
        mWindow.dismiss();
    }
}

在显示的之后,判断了下是否会显示到屏幕外面,如果会超出屏幕,则做一下微调即可。

一些细节的处理

嵌套在滚动视图中的处理

在一开始的实现要点中就提到,需要注意一下嵌套在滚动视图中的处理,在尝试了一些方法之后,最终直接设置 OnScrollChangedListener 来解决,具体代码如下:

mOnScrollChangedListener = new ViewTreeObserver.OnScrollChangedListener() {
    @Override
    public void onScrollChanged() {
        if (!isHideWhenScroll && !isHide) {
            isHideWhenScroll = true;
            if (mOperateWindow != null) {
                mOperateWindow.dismiss();
            }
            if (mStartHandle != null) {
                mStartHandle.dismiss();
            }
            if (mEndHandle != null) {
                mEndHandle.dismiss();
            }
        }
    }
};
mTextView.getViewTreeObserver().addOnScrollChangedListener(mOnScrollChangedListener);

这倒是解决了滑动时可以隐藏相关的选择控件的问题,但是停止滚动之后呢,如何重新显示选择控件呢?

在经过一些尝试之后,发现了 OnPreDrawListener 这个接口,在 TextView 发生滚动时期间一直在被调用,因此在这个接口里处理重新显示选择控件的逻辑是合适的:

mOnPreDrawListener = new ViewTreeObserver.OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        if (isHideWhenScroll) {
            isHideWhenScroll = false;
            showSelectView();
        }
        return true;
    }
};
mTextView.getViewTreeObserver().addOnPreDrawListener(mOnPreDrawListener);

在这样的设置之后,确实能保证停止滚动时重新显示选择相关的控件,但是整个滚动过程变得异常卡顿。

原因其实很简单,前面也提到了,onPreDraw 方法在 TextView 发生滚动时期间一直在被调用,然后这里一直处理显示选择控件的逻辑,能不卡顿么?

最后的解决方法是在源码中找到的,将 showSelectView() 方法替换成 postShowSelectView() 方法,

private void postShowSelectView(int duration) {
    mTextView.removeCallbacks(mShowSelectViewRunnable);
    if (duration <= 0) {
        mShowSelectViewRunnable.run();
    } else {
        mTextView.postDelayed(mShowSelectViewRunnable, duration);
    }
}

private final Runnable mShowSelectViewRunnable = new Runnable() {
    @Override
    public void run() {
        if (isHide) return;
        if (mOperateWindow != null) {
            mOperateWindow.show();
        }
        if (mStartHandle != null) {
            showCursorHandle(mStartHandle);
        }
        if (mEndHandle != null) {
            showCursorHandle(mEndHandle);
        }
    }
};

很巧妙的方法,通过延迟调用具体的逻辑,避免了一直调用显示选择控件的逻辑,又学习到了。

TextView 移除出 Window 时一些处理

在一开始没处理这个的时候,一直报如下的错误:

这么明显的错误可不能不管,处理起来也很简单:

mTextView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
    @Override
    public void onViewAttachedToWindow(View v) {
    }
    @Override
    public void onViewDetachedFromWindow(View v) {
        destroy();
    }
});

public void destroy() {
    mTextView.getViewTreeObserver().removeOnScrollChangedListener(mOnScrollChangedListener);
    mTextView.getViewTreeObserver().removeOnPreDrawListener(mOnPreDrawListener);
    resetSelectionInfo();
    hideSelectView();
    mStartHandle = null;
    mEndHandle = null;
    mOperateWindow = null;
}

将上面添加 Listener 也移除,同时隐藏响应的视图并置空。

写在最后

至此,自定义的选择复制功能完成,效果如下,GitHub 地址:laobie/SelectableTextHelper

在开发之初,通过简单的查阅资料,梳理了个大概的实现思路,并考虑到实现中需要注意到的点,保证在开发中保持足够的警惕,不给自己挖坑。在整个开发过程中,通过阅读他人的源码,以及直接看官方的源码,一点点解决所遇到的问题,以及一点点地尝试,都是一次不错的开发经历,也算是弥补了当初没做出来这个任务的缺憾。

当然,这个项目还是有很多值得优化的地方,比如一些边界状态的处理,多个 TextView 的选择复制的场景等等,代码上的内部类的使用也是不够优雅的,不能够做到足够的解耦,都是有优化空间的,欢迎沟通交流。

Title
搭个梯子,去看看墙外的世界(超低价,亲测稳定、速度快)