Android TextView长按选择复制工具类

开篇废话

最近有个需求,需要做一个像微信聊天一样可以长按可以任意选择复制的功能,这就要用到了Spannable了,但不止止的Spannable,在写的过程中也是遇到了很多的坑,为了避免大家踩坑,把我写的SelectableTextHelper分享给大家。
SelectableTextHelper之GitHub地址,帮我点个Star,赠人玫瑰,手留余香,谢谢。

先讲一下大致思路

首先需要三个弹窗,分别是选中文字左边和角标、选中文字右边的角标、带复制全选按钮的弹窗。
我们可以通过选中的文字的区域去算出角标的位置,同时我们在移动角标时也需要算出角标停留位置选中的文字范围。
效果图

选择复制工具类

SelectableTextHelper.java

package com.cc.selectable_text_helper.java;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.Layout;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.TextView;

import androidx.annotation.DrawableRes;
import androidx.cardview.widget.CardView;

import com.cc.selectable_text_helper.R;

/**
 * Created by guoshichao on 2021/3/17
 * <p>
 * 此View只能包含一个子View
 * CursorHandle  是两个游标
 * OperateWindow  是弹出的操作框
 * FullScreenWindow  全屏弹窗,点击空白全部弹窗消失
 */
public class SelectableTextHelper {

    private Context mContext;
    private TextView mTextView;

    private View mOperateView;
    private int mArrowRes;

    private Spannable mSpannable;
    private final SelectionInfo mSelectionInfo = new SelectionInfo();
    private final static int DEFAULT_SELECTION_LENGTH = 1;
    private BackgroundColorSpan mSpan;
    private final int mCursorHandleColor = R.color.selectable_cursor;
    private final int mSelectedColor = R.color.selectable_select_text_bg;
    private CursorHandle mStartHandle;
    private CursorHandle mEndHandle;
    private boolean isShow = true;
    private OperateWindow mOperateWindow;
    private FullScreenWindow mFullScreenWindow;

    private SelectableOnChangeListener onChangeListener;

    public SelectableTextHelper(View operateView, @DrawableRes int arrowRes) {
        if (operateView == null) {
            throw new SelectFrameLayoutException("操作框View不可为null");
        }
        this.mOperateView = operateView;
        this.mArrowRes = arrowRes;
    }

    public String getSelectedText() {
        return mSelectionInfo.mSelectionContent;
    }

    public void setSelectableOnChangeListener(SelectableOnChangeListener onChangeListener) {
        this.onChangeListener = onChangeListener;
    }

    public void showSelectView(TextView textView, int x, int y) {
        if (textView.getPaddingLeft() > 0 || textView.getPaddingRight() > 0
                || textView.getPaddingTop() > 0 || textView.getPaddingBottom() > 0
                || textView.getPaddingStart() > 0 || textView.getPaddingEnd() > 0) {
            throw new SelectFrameLayoutException("不可给TextView设置padding");
        }

        mContext = textView.getContext();
        mTextView = textView;
        mTextView.setText(mTextView.getText(), TextView.BufferType.SPANNABLE);

        if (mOperateWindow == null)
            mOperateWindow = new OperateWindow(mContext);
        if (mFullScreenWindow == null)
            mFullScreenWindow = new FullScreenWindow(mContext);

        hideSelectView();
        resetSelectionInfo();
        isShow = true;
        if (mStartHandle == null)
            mStartHandle = new CursorHandle(true);
        if (mEndHandle == null)
            mEndHandle = new CursorHandle(false);

        //点哪选哪
//        int startOffset = TextLayoutUtil.getPreciseOffset(this, x, y);
//        int endOffset = startOffset + DEFAULT_SELECTION_LENGTH;
        //全选
        int startOffset = 0;
        int endOffset = mTextView.length();
        if (mTextView.getText() instanceof Spannable) {
            mSpannable = (Spannable) mTextView.getText();
        }
        if (mSpannable == null || startOffset >= mTextView.getText().length()) {
            return;
        }
        selectText(startOffset, endOffset);
        mFullScreenWindow.show();
        showCursorHandle(mStartHandle);
        showCursorHandle(mEndHandle);
        mOperateWindow.firstShowWithTextView();
    }

    private void showCursorHandle(CursorHandle cursorHandle) {
        Layout layout = mTextView.getLayout();
        int offset = cursorHandle.isLeft ? mSelectionInfo.getStart(mTextView)
                : mSelectionInfo.getEnd(mTextView);
        cursorHandle.show((int) layout.getPrimaryHorizontal(offset),
                layout.getLineBottom(layout.getLineForOffset(offset)));
    }

    public void copyText() {
        ClipboardManager clip = (ClipboardManager) mContext
                .getSystemService(Context.CLIPBOARD_SERVICE);
        clip.setPrimaryClip(ClipData.newPlainText(
                mSelectionInfo.mSelectionContent,
                mSelectionInfo.mSelectionContent));
    }

    public void selectAll() {
        hideSelectView();
        selectText(0, mTextView.getText().length());
        isShow = true;
        mFullScreenWindow.show();
        showCursorHandle(mStartHandle);
        showCursorHandle(mEndHandle);
        mOperateWindow.showWithTextView();
    }

    public void dismiss() {
        resetSelectionInfo();
        hideSelectView();
    }

    public void resetSelectionInfo() {
        mSelectionInfo.mSelectionContent = null;
        if (mSpannable != null && mSpan != null) {
            mSpannable.removeSpan(mSpan);
            mSpan = null;
        }
    }

    public void hideSelectView() {
        isShow = false;

        if (mStartHandle != null) {
            mStartHandle.dismiss();
        }
        if (mEndHandle != null) {
            mEndHandle.dismiss();
        }
        if (mOperateWindow != null) {
            mOperateWindow.dismiss();
        }
        if (mFullScreenWindow != null) {
            mFullScreenWindow.dismiss();
        }
    }

    /*
     * startPos:起始索引 endPos:尾部索引
     */
    private void selectText(int startPos, int endPos) {
        if (startPos != -1) {
            mSelectionInfo.setStart(startPos);
        }
        if (endPos != -1) {
            mSelectionInfo.setEnd(endPos);
        }
        if (mSelectionInfo.getStart(mTextView) > mSelectionInfo.getEnd(mTextView)) {
            int temp = mSelectionInfo.getStart(mTextView);
            mSelectionInfo.setStart(mSelectionInfo.getEnd(mTextView));
            mSelectionInfo.setEnd(temp);
        }

        if (mSpannable != null) {
            if (mSpan == null) {
                mSpan = new BackgroundColorSpan(mContext.getResources().getColor(mSelectedColor));
            }

            mSelectionInfo.mSelectionContent = mSpannable.subSequence(
                    mSelectionInfo.getStart(mSpannable), mSelectionInfo.getEnd(mSpannable)).toString();

            // 调用系统方法设置选中文本的状态
            mSpannable.setSpan(mSpan, mSelectionInfo.getStart(mTextView), mSelectionInfo.getEnd(mTextView), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);

            if (onChangeListener != null) {
                onChangeListener.onChange(mSelectionInfo.mSelectionContent,
                        startPos == 0 && endPos == mTextView.getText().length());
            }
        }
    }

    public int getTextViewX() {
        int[] location = new int[2];
        mTextView.getLocationOnScreen(location);
        return location[0];
    }

    public int getTextViewY() {
        int[] location = new int[2];
        mTextView.getLocationOnScreen(location);
        return location[1];
    }

    /*
     * 游标类
     */
    class CursorHandle extends View {

        private final int mCursorHandleSize = 48;
        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(mContext.getResources().getColor(mCursorHandleColor));

            mPopupWindow = new PopupWindow(this);
            mPopupWindow.setClippingEnabled(false);
            mPopupWindow.setWidth(mWidth + mPadding * 2);
            mPopupWindow.setHeight(mHeight + mPadding / 2);

            invalidate();
        }

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

        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.getStart(mTextView);
                    mBeforeDragEnd = mSelectionInfo.getEnd(mTextView);
                    mAdjustX = (int) event.getX();
                    mAdjustY = (int) event.getY();
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    mOperateWindow.showWithTextView();
                    break;
                case MotionEvent.ACTION_MOVE:
                    mOperateWindow.dismiss();
                    int rawX = (int) event.getRawX();
                    int rawY = (int) event.getRawY();
                    update(rawX + mAdjustX - mWidth - getTextViewX(), rawY + mAdjustY - mHeight);
                    break;
            }
            return true;
        }

        private void changeDirection() {
            isLeft = !isLeft;
            invalidate();
        }

        public void dismiss() {
            mPopupWindow.dismiss();
        }

        private int[] mTempCoors = new int[2];

        public void update(int x, int y) {
            mTextView.getLocationInWindow(mTempCoors);
            int oldOffset;
            if (isLeft) {
                oldOffset = mSelectionInfo.getStart(mTextView);
            } else {
                oldOffset = mSelectionInfo.getEnd(mTextView);
            }

            y -= mTempCoors[1];

            int offset = TextLayoutUtils.getHysteresisOffset(mTextView, x,
                    y, oldOffset);

            if (offset != oldOffset) {
                resetSelectionInfo();
                if (isLeft) {
                    if (offset > mBeforeDragEnd) {
                        CursorHandle handle = getCursorHandle(false);
                        changeDirection();
                        handle.changeDirection();
                        mBeforeDragStart = mBeforeDragEnd;
                        selectText(mBeforeDragEnd, offset);
                        handle.updateCursorHandle();
                    } else {
                        selectText(offset, -1);
                    }
                    updateCursorHandle();
                } else {
                    if (offset < mBeforeDragStart) {
                        CursorHandle handle = getCursorHandle(true);
                        handle.changeDirection();
                        changeDirection();
                        mBeforeDragEnd = mBeforeDragStart;
                        selectText(offset, mBeforeDragStart);
                        handle.updateCursorHandle();
                    } else {
                        selectText(mBeforeDragStart, offset);
                    }
                    updateCursorHandle();
                }
            }
        }

        private void updateCursorHandle() {
            mTextView.getLocationInWindow(mTempCoors);
            Layout layout = mTextView.getLayout();
            if (isLeft) {
                mPopupWindow.update(
                        (int) layout
                                .getPrimaryHorizontal(mSelectionInfo.getStart(mTextView))
                                - mWidth + getExtraX(),
                        layout.getLineBottom(layout
                                .getLineForOffset(mSelectionInfo.getStart(mTextView)))
                                + getExtraY(), -1, -1);
            } else {
                mPopupWindow.update(
                        (int) layout.getPrimaryHorizontal(mSelectionInfo.getEnd(mTextView))
                                + getExtraX(),
                        layout.getLineBottom(layout
                                .getLineForOffset(mSelectionInfo.getEnd(mTextView)))
                                + getExtraY(), -1, -1);
            }
        }

        public void show(int x, int y) {
            mTextView.getLocationInWindow(mTempCoors);
            int offset = isLeft ? mWidth : 0;
            mPopupWindow.showAtLocation(mTextView, Gravity.NO_GRAVITY, x
                    - offset + getExtraX(), y + getExtraY());
        }

        public int getExtraX() {
            return mTempCoors[0] - mPadding + mTextView.getPaddingLeft();
        }

        public int getExtraY() {
            return mTempCoors[1] + mTextView.getPaddingTop();
        }

    }

    private CursorHandle getCursorHandle(boolean isLeft) {
        if (mStartHandle.isLeft == isLeft) {
            return mStartHandle;
        } else {
            return mEndHandle;
        }
    }

    /*
     * 操作框
     */
    private class OperateWindow {

        private int screenWidth;
        private int paddingLR;

        private PopupWindow mWindow;

        private View contentView;
        private CardView cvRoot;
        private ImageView ivArrow;

        public OperateWindow(final Context context) {
            screenWidth = TextLayoutUtils.getScreenWidth(mContext);
            paddingLR = TextLayoutUtils.dip2px(mContext, 13);

            contentView = LayoutInflater.from(context).inflate(
                    R.layout.select_text_operate_windows, null);
            contentView.measure(View.MeasureSpec.makeMeasureSpec(0,
                    View.MeasureSpec.UNSPECIFIED), View.MeasureSpec
                    .makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
            mWindow = new PopupWindow(contentView,
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT, false);
            mWindow.setClippingEnabled(false);

            cvRoot = contentView.findViewById(R.id.cv_root);
            cvRoot.addView(mOperateView);

            ivArrow = contentView.findViewById(R.id.iv_arrow);
            if (mArrowRes > 0) {
                ivArrow.setVisibility(View.VISIBLE);
                ivArrow.setImageResource(mArrowRes);
            } else {
                ivArrow.setVisibility(View.GONE);
            }
        }

        private int getWindowWidth() {
            return contentView.getMeasuredWidth();
        }

        private int getWindowHeight() {
            return contentView.getMeasuredHeight();
        }

        private int getWindowRemoveRight() {
            int removeX = 0;
            Layout layout = mTextView.getLayout();
            int start = (int) layout.getPrimaryHorizontal(mSelectionInfo.getStart(mTextView));
            int end = (int) layout.getPrimaryHorizontal(mSelectionInfo.getEnd(mTextView));
            boolean isSameLine = layout.getLineTop(layout.getLineForOffset(mSelectionInfo.getStart(mTextView))) == layout.getLineTop(layout.getLineForOffset(mSelectionInfo.getEnd(mTextView)));
            if (end > start && isSameLine) {
                removeX = end - start;
            } else {
                removeX = mTextView.getWidth() - start;
            }
            return removeX / 2;
        }

        public void firstShowWithTextView() {
            showWithTextView();
            mTextView.post(new Runnable() {
                @Override
                public void run() {
                    dismiss();
                    showWithTextView();
                }
            });
        }

        public void showWithTextView() {
            Layout layout = mTextView.getLayout();
            int posX = (int) layout.getPrimaryHorizontal(mSelectionInfo.getStart(mTextView))
                    + getTextViewX()
                    - getWindowWidth() / 2
                    + getWindowRemoveRight();
            int posY = layout.getLineTop(layout.getLineForOffset(mSelectionInfo.getStart(mTextView)))
                    + getTextViewY()
                    - getWindowHeight()
                    - paddingLR;
            int removeArrow = 0;
            if (posX < paddingLR) {
                removeArrow = posX - paddingLR;
                posX = paddingLR;
            }
            if (posY < 0) {
                posY = paddingLR;
            }
            if (posX + getWindowWidth() > screenWidth - paddingLR) {
                removeArrow = posX - (screenWidth - getWindowWidth() - paddingLR);
                posX = screenWidth - getWindowWidth() - paddingLR;
            }

            ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) ivArrow.getLayoutParams();
            lp.leftMargin = removeArrow;
            ivArrow.setLayoutParams(lp);

            mWindow.showAtLocation(mTextView, Gravity.NO_GRAVITY, posX, posY);
        }

        public void showWithView() {
            int posX = getTextViewX()
                    - getWindowWidth() / 2
                    + mTextView.getMeasuredWidth() / 2;
            int posY = getTextViewY()
                    - getWindowHeight()
                    + mTextView.getPaddingTop()
                    - paddingLR;
            int removeArrow = 0;
            if (posX < paddingLR) {
                removeArrow = posX - paddingLR;
                posX = paddingLR;
            }
            if (posY < paddingLR) {
                posY = paddingLR;
            }
            if (posX + getWindowWidth() > screenWidth - paddingLR) {
                removeArrow = posX - (screenWidth - getWindowWidth() - paddingLR);
                posX = screenWidth - getWindowWidth() - paddingLR;
            }

            ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) ivArrow.getLayoutParams();
            lp.leftMargin = removeArrow;
            ivArrow.setLayoutParams(lp);

            mWindow.showAtLocation(mTextView, Gravity.NO_GRAVITY, posX, posY);
        }

        public void dismiss() {
            mWindow.dismiss();
        }

        public boolean isShowing() {
            return mWindow.isShowing();
        }

    }

    /*
     * 全屏Window,用来点击空白使其它弹窗消失
     */
    private class FullScreenWindow {

        private PopupWindow mFullScreenWindow;

        public FullScreenWindow(Context context) {
            View contentView = LayoutInflater.from(context).inflate(
                    R.layout.select_text_full_screen_windows, null);
            mFullScreenWindow = new PopupWindow(contentView,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT, false);
            mFullScreenWindow.setClippingEnabled(false);

            mFullScreenWindow.setTouchInterceptor(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {
                    if (mOperateWindow == null
                            || mOperateWindow.contentView == null) {
                        dismiss();
                    }
                    if (!TextLayoutUtils.isInView(mOperateWindow.contentView, event)) {
                        if (mStartHandle != null && mEndHandle != null) {
                            if (!TextLayoutUtils.isInView(mStartHandle, event)
                                    && !TextLayoutUtils.isInView(mEndHandle, event)) {
                                resetSelectionInfo();
                                hideSelectView();
                            }
                        } else {
                            hideSelectView();
                        }
                    }
                    return true;
                }
            });
        }

        public void show() {
            mFullScreenWindow.showAtLocation(mTextView, Gravity.NO_GRAVITY, 0, 0);
        }

        public void dismiss() {
            mFullScreenWindow.dismiss();
        }
    }

}

关键的计算角标及选中文字位置的类

TextLayoutUtils.java

package com.cc.selectable_text_helper.java;

import android.content.Context;
import android.graphics.Rect;
import android.text.Layout;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;

public class TextLayoutUtils {

    public static int getScreenWidth(Context context) {
        return context.getResources().getDisplayMetrics().widthPixels;
    }

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

    public static int getHysteresisOffset(TextView textView, int x, int y, int previousOffset) {
        final Layout layout = textView.getLayout();
        if (layout == null) return -1;

        int line = layout.getLineForVertical(y);

        // The "HACK BLOCK"S in this function is required because of how Android Layout for
        // TextView works - if 'offset' equals to the last character of a line, then
        //
        // * getLineForOffset(offset) will result the NEXT line
        // * getPrimaryHorizontal(offset) will return 0 because the next insertion point is on the next line
        // * getOffsetForHorizontal(line, x) will not return the last offset of a line no matter where x is
        // These are highly undesired and is worked around with the HACK BLOCK
        //
        // @see Moon+ Reader/Color Note - see how it can't select the last character of a line unless you move
        // the cursor to the beginning of the next line.
        //
        HACK BLOCK

        if (isEndOfLineOffset(layout, previousOffset)) {
            // we have to minus one from the offset so that the code below to find
            // the previous line can work correctly.
            int left = (int) layout.getPrimaryHorizontal(previousOffset - 1);
            int right = (int) layout.getLineRight(line);
            int threshold = (right - left) / 2; // half the width of the last character
            if (x > right - threshold) {
                previousOffset -= 1;
            }
        }
        ///

        final int previousLine = layout.getLineForOffset(previousOffset);
        final int previousLineTop = layout.getLineTop(previousLine);
        final int previousLineBottom = layout.getLineBottom(previousLine);
        final int hysteresisThreshold = (previousLineBottom - previousLineTop) / 2;

        // If new line is just before or after previous line and y position is less than
        // hysteresisThreshold away from previous line, keep cursor on previous line.
        if (((line == previousLine + 1) && ((y - previousLineBottom) < hysteresisThreshold)) || ((line == previousLine - 1) && ((
            previousLineTop
                - y) < hysteresisThreshold))) {
            line = previousLine;
        }

        int offset = layout.getOffsetForHorizontal(line, x);

        // This allow the user to select the last character of a line without moving the
        // cursor to the next line. (As Layout.getOffsetForHorizontal does not return the
        // offset of the last character of the specified line)
        //
        // But this function will probably get called again immediately, must decrement the offset
        // by 1 to compensate for the change made below. (see previous HACK BLOCK)
        /HACK BLOCK///
        if (offset < textView.getText().length() - 1) {
            if (isEndOfLineOffset(layout, offset + 1)) {
                int left = (int) layout.getPrimaryHorizontal(offset);
                int right = (int) layout.getLineRight(line);
                int threshold = (right - left) / 2; // half the width of the last character
                if (x > right - threshold) {
                    offset += 1;
                }
            }
        }
        //

        if (offset > textView.getText().length()) {
            offset = textView.getText().length();
        }

        return offset;
    }

    private static boolean isEndOfLineOffset(Layout layout, int offset) {
        return offset > 0 && layout.getLineForOffset(offset) == layout.getLineForOffset(offset - 1) + 1;
    }

    /**
     * 判断触摸的点是否在View范围内
     */
    public static boolean isInView(View view, MotionEvent event) {
        int[] location = {0, 0};
        view.getLocationInWindow(location);
        int left = location[0], top = location[1], bottom = top + view.getHeight(), right = left + view.getWidth();
        float eventX = event.getX();
        float eventY = event.getY();
        Rect rect = new Rect(left, top, right, bottom);
        return rect.contains((int) eventX, (int) eventY);
    }

    public static int dip2px(Context context, float dpValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }
}

其它类

SelectionInfo.java

package com.cc.selectable_text_helper.java;

import android.widget.TextView;

public class SelectionInfo {
    private int mStart;
    private int mEnd;
    public String mSelectionContent;

    public int getStart(TextView textView) {
        if (textView == null) {
            return 0;
        }
        if (mStart > textView.length()) {
            return textView.length();
        }
        if (mStart < 0) {
            return 0;
        }
        return mStart;
    }

    public int getStart(CharSequence charSequence) {
        if (charSequence == null) {
            return 0;
        }
        if (mStart > charSequence.length()) {
            return charSequence.length();
        }
        if (mStart < 0) {
            return 0;
        }
        return mStart;
    }

    public void setStart(int start) {
        this.mStart = start;
    }

    public int getEnd(TextView textView) {
        if (textView == null) {
            return 0;
        }
        if (mEnd > textView.length()) {
            return textView.length();
        }
        if (mEnd < 0) {
            return 0;
        }
        return mEnd;
    }

    public int getEnd(CharSequence charSequence) {
        if (charSequence == null) {
            return 0;
        }
        if (mEnd > charSequence.length()) {
            return charSequence.length();
        }
        if (mEnd < 0) {
            return 0;
        }
        return mEnd;
    }

    public void setEnd(int end) {
        this.mEnd = end;
    }
}

SelectFrameLayoutException.java

package com.cc.selectable_text_helper.java;

/**
 * Created by guoshichao on 2021/3/17
 */
public class SelectFrameLayoutException extends RuntimeException {

    private static final long serialVersionUID = 20210317L;

    public SelectFrameLayoutException() {
        super();
    }

    public SelectFrameLayoutException(String string) {
        super(string);
    }

}

SelectableOnChangeListener.java

package com.cc.selectable_text_helper.java;

/**
 * Created by guoshichao on 2021/3/9
 */
public interface SelectableOnChangeListener {

    void onChange(CharSequence text, boolean isSelectAll);

}

其它xml

select_text_operate_windows.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <androidx.cardview.widget.CardView
        android:id="@+id/cv_root"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:cardBackgroundColor="@color/selectable_select_pop_bg"
        app:cardCornerRadius="3dp"
        app:cardElevation="3dp"
        app:cardMaxElevation="6dp">

    </androidx.cardview.widget.CardView>

    <ImageView
        android:id="@+id/iv_arrow"
        android:layout_width="19dp"
        android:layout_height="10dp"
        android:layout_gravity="center_horizontal"
        android:src="@color/selectable_select_pop_bg" />

</LinearLayout>

select_text_full_screen_windows.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
    android:layout_height="match_parent">

</androidx.constraintlayout.widget.ConstraintLayout>

使用案例

MainActivity.kt

package com.cc.selectabletexthelper

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View.OnLongClickListener
import android.view.View.OnTouchListener
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.cc.selectable_text_helper.java.SelectableTextHelper

class MainActivity : AppCompatActivity() {

    var tvSelect : TextView? = null
    var tvSelectable : TextView? = null
    var selectableTextHelper : SelectableTextHelper? = null
    var mTouchX = 0
    var mTouchY = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val operateView = LayoutInflater.from(this).inflate(R.layout.view_select_text_operate, null)
        selectableTextHelper = SelectableTextHelper(operateView, R.drawable.select_text_view_arrow)
        val itCopy = operateView.findViewById<TextView>(R.id.it_copy)
        itCopy.setOnClickListener {
            selectableTextHelper?.copyText()
            selectableTextHelper?.dismiss()
        }
        val itSelectAll = operateView.findViewById<TextView>(R.id.it_select_all)
        itSelectAll.setOnClickListener {
            selectableTextHelper?.selectAll()
        }
        val itCancel = operateView.findViewById<TextView>(R.id.it_cancel)
        itCancel.setOnClickListener {
            selectableTextHelper?.dismiss()
        }

        tvSelect = findViewById(R.id.tv_select)
        tvSelect?.setText(R.string.app_name)

        tvSelect?.setOnLongClickListener(OnLongClickListener {
            selectableTextHelper?.showSelectView(tvSelect, mTouchX, mTouchY)
            true
        })
        tvSelect?.setOnTouchListener(OnTouchListener { arg0, event ->
            mTouchX = event.x.toInt()
            mTouchY = event.y.toInt()
            false
        })

        tvSelect?.setOnClickListener {
            selectableTextHelper?.resetSelectionInfo()
            selectableTextHelper?.hideSelectView()
        }


        tvSelectable = findViewById(R.id.tv_selectable)
        tvSelectable?.setOnLongClickListener(OnLongClickListener {
            selectableTextHelper?.showSelectView(tvSelectable, mTouchX, mTouchY)
            true
        })
        tvSelectable?.setOnTouchListener(OnTouchListener { arg0, event ->
            mTouchX = event.x.toInt()
            mTouchY = event.y.toInt()
            false
        })

        tvSelectable?.setOnClickListener {
            selectableTextHelper?.resetSelectionInfo()
            selectableTextHelper?.hideSelectView()
        }
    }

}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/tv_select"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Hello World!"
            android:textSize="14sp"
            android:textColor="@color/black" />

        <TextView
            android:id="@+id/tv_selectable"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            android:text="Hello World!"
            android:textSize="14sp"
            android:textColor="@color/black" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

view_select_text_operate.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/ll_it_all"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/it_copy"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="1dp"
        android:background="@color/black"
        android:paddingLeft="17dp"
        android:paddingTop="10dp"
        android:paddingRight="17dp"
        android:paddingBottom="10dp"
        android:text="复制"
        android:textColor="@color/white"
        android:textSize="14sp" />

    <TextView
        android:id="@+id/it_select_all"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="1dp"
        android:background="@color/black"
        android:paddingLeft="17dp"
        android:paddingTop="10dp"
        android:paddingRight="17dp"
        android:paddingBottom="10dp"
        android:text="全选"
        android:textColor="@color/white"
        android:textSize="14sp" />

    <TextView
        android:id="@+id/it_cancel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/black"
        android:paddingLeft="17dp"
        android:paddingTop="10dp"
        android:paddingRight="17dp"
        android:paddingBottom="10dp"
        android:text="取消"
        android:textColor="@color/white"
        android:textSize="14sp"/>

</LinearLayout>

结束小语

文字选择本身并无太多难点,关键是要知道几个api,可以对角标进行计算,就可以实现出想要的效果。
最近我在使用其它app的时候,发现它的选择复制可以是多个TextView,这种实现我并没有通过代码写出来,我可以分享一下我的思路。
SelectableTextHelper维护一个TextView列表,在移动角标时将TextView在列表里添加或移除,通过手势的位置判断需要添加移除哪些,开始角标计算TextView列表中的第一个,结束角标计算TextView列表最后一个,在复制时将每个TextView选中的部分进行整合,中间加上回车符。
谢谢大家有耐心观看到最后。

进入我的CSDN戳这里(我的博客导航)

  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值