自定義選擇復制功能的實現

剛工作時遇到一個特別難搞定的需求,當時沒做出來,感到很羞恥。過了幾年,再一次遇到這個需求,還是沒做出來,只是不再感到羞恥了。

在我剛開始工作的時候,也有過一次這樣的經歷。當時項目中有個需求,讓 TextView 中的文本可以選擇復制,正常來講,應該是很容易實現的,直接按照下面的設置就可以了:

mTextView.setTextIsSelectable(true);

但是,這個簡單的實現并不是完美的,主要有幾個問題:

  • 不同版本選擇復制樣式不統一:在原生系統上 6.0 之前和之后的操作樣式是不同的,這里不得不說,6.0 以下的這個選擇復制操作交互很不合理,且對應用的界面侵入太多。

  • 萬惡的國產 ROM 問題:當時公司測試同事提 bug 反饋,在 vivo 手機上這么設置,長按之后并沒有效果。(再一次吐槽亂改系統的國產 ROM,這也是為什么 Android 開發比起 iOS 費事費力的原因之一)

  • 可定制性不高:如果僅僅是一個選擇復制的功能,不考慮以上兩個問題,還能湊合搞定,但是假如多個需求,選中文字之后直接進行某個操作,比如收藏、發送給好友,此時原生的選擇復制功能可能就不足以勝任了。

以上說了這么多,問題的解決辦法就是:自己寫一個選擇復制的功能,這樣以上三個問題都能很好地解決了。

看起來很容易,但是對于當時剛剛入門的我來說,這是個完全沒頭緒的任務。

時隔一年之后,再遇到這個需求,這次通過 Google、GitHub ,以及參考 API 23 中 TextView 源碼,基本上實現了自定義選擇復制的功能,效果如下:

保證所有的平臺上顯示效果一致,彈出的操作菜單可以自己定制,并設置相應的操作。

實現要求和要點

在開始具體的實現之前,先確定下實現的要求:

  • 盡可能保證和 Android 6.0 原生選擇復制一樣的交互和基礎功能
  • 盡可能不需要侵入太多,為了實現選擇復制功能,重新自定義 TextView 的方式是不夠優雅的,特別是考慮到項目中本來就已經使用了自定義的 TextView ,一旦需求變更,改動成本很大
  • 可用的自定義配置

本文最終實現的使用方式如下所示,均滿足以上的實現要求:

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

整個自定義的選擇復制功能視圖上主要有三個部分:

  • 選擇游標
  • 選中的文本
  • 操作框

在具體實現中有以下要點:

  • 自定義選擇游標,可以拖動定位選中文本
  • 文本的選中狀態
  • 操作框的顯示,以及對應操作的處理
  • 在可滑動布局中的特殊處理,例如在 ScrollView 中,當視圖滾動時隱藏或者移動選擇游標,隱藏操作框,停止滑動時重新顯示選擇游標和操作框
  • 選中文本后,點擊 TextView 取消選擇

實現思路

在開始實踐之前,查找資料是少不了的,首先找到了 記劃詞模塊重構感受|開源實驗室-張濤 這篇文章,但是這篇文章中更多是提供了一個改進某個開源項目的思路,并沒有給出具體的代碼,而且連那個開源項目也沒給出地址。

后來通過搜索關鍵字,找到了那個開源項目: zhouray/SelectableTextView

如張濤吐槽的那樣,這個項目的實現確實不夠優雅,主要存在兩個問題:

  • 自定義 TextView 實現的,侵入太多
  • 解決嵌套在滑動布局中的處理太簡單粗暴,竟然自定義了一個 ScrollView 來處理,應用到實際場景中是存在問題的

如果你有時間可以看一下這個項目的代碼,在本文后面的實現中,也部分參考了該項目。

參考上面提到的文章和開源項目,實現思路基本確定了:

  • 選擇游標使用 PopupWindow 實現,并重寫 Touch 事件處理邏輯,實現拖動定位選擇文本
  • 選中文本使用 BackgroundColorSpan 來顯示,比較簡單
  • 操作框同樣使用 PopupWindow 實現,重點是處理好顯示的位置

大致的思路確定,接下來就是具體的實現了。

具體實現過程

自定義的選擇復制類取名為 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();
        }
    });
  • 其中 onTouch() 記錄了觸摸點坐標,用于后面的選擇文本的位置定位以及選擇游標的顯示,即傳遞給 showSelectView() 方法。
  • onClick() 中的處理比較簡單,重置選中文本信息、隱藏選擇相關的 View 。

直接看一下 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();
}
  • 在 show 方法開始,因為之前可能已經顯示了選擇相關的 View ,比如先長按 TextView 的 A 點,然后彈出選擇游標、操作框,此時再長按 B 點,此時再次彈出選擇游標和操作框時,就需要先隱藏之前的相關 View 了,這里就這樣簡單粗暴地處理了下。
  • int startOffset = TextLayoutUtil.getPreciseOffset(mTextView, x, y); 是一個很有意思的地方,這里參考了前面提到的開源項目里面的實現,這個方法通過傳入 TextView 中一個點的坐標,就可以計算出來對應的最接近的那個文字的索引,簡單說明如下:

通過傳入『種』那個字附近的某個點的坐標 (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 ,雖然看過這塊的部分源碼,但是這里的處理還是有點懵,本文就不多深入了,有興趣的話可以自行了解下這塊的源碼。

  • 文本的選中顯示是在 selectText() 方法中處理的,重點是設置 Span 和記錄選中的文本信息:

    private void selectText(int startPos, int endPos) {
        if (startPos != -1) {
            mSelectionInfo.mStart = startPos;
        }
        if (endPos != -1) {
            mSelectionInfo.mEnd = endPos;
        }
        if (mSelectionInfo.mStart > mSelectionInfo.mEnd) {
            int temp = mSelectionInfo.mStart;
            mSelectionInfo.mStart = mSelectionInfo.mEnd;
            mSelectionInfo.mEnd = temp;
        }
        if (mSpannable != null) {
            if (mSpan == null) {
                mSpan = new BackgroundColorSpan(mSelectedColor);
            }
            mSelectionInfo.mSelectionContent = mSpannable.subSequence(mSelectionInfo.mStart, mSelectionInfo.mEnd).toString();
            mSpannable.setSpan(mSpan, mSelectionInfo.mStart, mSelectionInfo.mEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
            if (mSelectListener != null) {
                mSelectListener.onTextSelected(mSelectionInfo.mSelectionContent);
            }
        }
    }

    其中處理了下可能存在的 endPos 小于 startPos 的情況,進行了一次交換,后面就是設置 BackgroundColorSpan 已經記錄下選中文本的信息,已經設置了選中監聽時的回調。

    其中 mSelectionInfo 是 SelectionInfo 類的一個簡單實例,該類就三個字段,選中文字的開始位置、結束位置和選中的文本:

    public class SelectionInfo {
        public int mStart;
        public int mEnd;
        public String mSelectionContent;
    }
  • showCursorHandle() 方法顧名思義就是顯示選擇游標,因為是 PopupWindow 實現的,重點就是顯示位置的確定,這里再次涉及到 Layout 相關的 API :

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

    這里和之前的是反的,通過文本中的文字索引,來獲取到對應的點的坐標。然后顯示 PopupWindow 即可。

  • 最后是顯示操作框,同樣是一個 PopupWindow ,這里的細節后面再展開。

隱藏選擇相關組件

這里沒啥好說的,就是判空下左右選擇游標和操作框,如果非空,則調用對應的 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;
}
  • 在游標移動時,隱藏操作框,停止移動時,再顯示操作框。

  • 在觸摸發生移動時,即 MotionEvent.ACTION_MOVE 時,更新游標位置和選中的文本, update() 方法如下:

    private int[] mTempCoors = new int[2];
    public void update(int x, int y) {
        mTextView.getLocationInWindow(mTempCoors);
        int oldOffset;
        if (isLeft) {
            oldOffset = mSelectionInfo.mStart;
        } else {
            oldOffset = mSelectionInfo.mEnd;
        }
        y -= mTempCoors[1];
        int offset = TextLayoutUtil.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();
            }
        }
    }

    在一開始的實現中, update() 方法沒這么復雜,但是考慮到左邊的游標在移動到右邊游標的右邊時,如下面的動圖所示:

    此時就需要多一點處理,左邊的右邊變右邊,右邊的游標變左邊,同時選中的文本也需要重新變換起點位置,原來是 end ,現在則變成了 start 。

    具體的邏輯實現就是根據之前選中的文本的前后位置信息,進行前后位置的交換。同時調整游標的方向,更新視圖,這個邏輯在 changeDirection() 方法中:

    private void changeDirection() {
        isLeft = !isLeft;
        invalidate();
    }
  • 更新選擇游標位置:由于游標的位置處理成只和選中的文本有關,因而處理起來較為簡單,在上面的反轉變化中,只要選中的文本正確變化了,那么這里的游標位置更新就是正確的。

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

操作框

操作框的實現則簡單的多,就是自定義布局的 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(0, View.MeasureSpec.UNSPECIFIED),
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
        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 也移除,同時隱藏響應的視圖并置空。

寫在最后

至此,自定義的選擇復制功能完成,效果如下:

在開發之初,通過簡單的查閱資料,梳理了個大概的實現思路,并考慮到實現中需要注意到的點,保證在開發中保持足夠的警惕,不給自己挖坑。在整個開發過程中,通過閱讀他人的源碼,以及直接看官方的源碼,一點點解決所遇到的問題,以及一點點地嘗試,都是一次不錯的開發經歷,也算是彌補了當初沒做出來這個任務的缺憾。

當然,這個項目還是有很多值得優化的地方,比如一些邊界狀態的處理,多個 TextView 的選擇復制的場景等等,代碼上的內部類的使用也是不夠優雅的,不能夠做到足夠的解耦,都是有優化空間的,歡迎溝通交流。

 

來自:http://jaeger.itscoder.com/android/2016/11/21/selectable-text-helper.html

 

 本文由用戶 tigerluo88 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!