仿微信評論控制元件封裝

1004145468發表於2018-09-01

###1. 需求前提說明

越來越多的應用為了增強使用者粘性,選擇在應用內部整合類似微信朋友圈的模組。這樣的模組提供了各式各樣的圖文混排效果,在wifi情況下能對視訊進行自動預覽,有互動良好(開發複雜)的評論體驗。開發這一模組,對每個細節的效能要求更為嚴格,為保證列表滑動的流暢性。由於公司戰略調整,新增動態功能(朋友圈),樓主主要負責這一模組的開發,如果有相關問題可以留言討論,本篇幅主要講評論元件的封裝。

###2. 鎮樓圖

朋友圈不方便截圖,用映客動態截圖代替,o(* ̄︶ ̄*)o.png

功能互動點:

  • 點選使用者暱稱,僅暱稱文字區域展示按下背景圖,跳轉進入使用者個人主頁。
  • 點選使用者評論,評論+暱稱區域展示按下背景圖,進行回覆評論。
  • 長按使用者暱稱,同(1)效果。
  • 長按使用者評論,評論+暱稱區域展示按下背景圖,用於刪除評論。

###3. 思路分析

  • Step One: 採用TextView + ClickableSpan

  1. 程式碼實現
     <TextView
        android:id="@+id/commentText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/selectbg_dynamiccomment"  //設定按下效果
        android:textSize="25sp" />

        commentText.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(MainActivity.this, "回覆評論", Toast.LENGTH_SHORT).show();
            }
        });
        commentText.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                Toast.makeText(MainActivity.this, "刪除評論", Toast.LENGTH_SHORT).show();
                return true;
            }
        });
        commentText.setMovementMethod(LinkMovementMethod.getInstance());
        CharSequence text = commentText.getText();
        SpannableString spannableString = new SpannableString(text);
        spannableString.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                Toast.makeText(MainActivity.this, "進入個人主頁", Toast.LENGTH_SHORT).show();

            }
        }, 0, 3, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE);
        commentText.setText(spannableString);

複製程式碼
  1. 完成度: 完成控制元件評論區域的點選和長按,即功能互動點的第2點和第4點。
  2. 缺陷:點選暱稱區域會觸發TextView的OnClickListener事件,長按暱稱區域,會觸發TextView的OnLongClickListener事件。
  • Step Two: ClickableSpan對事件(Click、LongClick)進行消費

  1. 先檢視原始碼,TextView如何呼叫ClickableSpan中的OnClick方法:
public class TextView extends View {

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        ...
        final boolean touchIsFinished = (action == MotionEvent.ACTION_UP)
                && (mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();
             // 1. TextView 如果沒有呼叫setMovementMethod(xx) 設定ClickableSpan不會生效
        if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
                && mText instanceof Spannable && mLayout != null) {
            boolean handled = false;
            if (mMovement != null) {
            // 2. 由setMovementMethod()設定的MovementMethod處理此次點選。
                handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
            }
           
            ...   // 3. 交由TextView處理
            if (handled) {
                return true;
            }
        }
        return superResult;
    }
}
複製程式碼
  1. 原因: 從程式碼可以看出,TextView在OnTouchEvent方法中對ClickableSpan中的OnClick進行回撥處理,但並沒有消費掉此次事件直接返回,而是繼續交予TextView處理(可能觸發TextView的OnClick和OnLongClick)。
  1. 解決: 重寫TextView的OnTouchEvent方法,先判斷點選的區域是否是ClickableSpan,如果是,交由ClickableSpan處理後直接return true返回。
  1. 疑問: 那麼如何判斷點選的區域為ClickableSpan? 之前在分析TextView的OnTouchEvent方法中第2點註釋,可以知道mMovement.onTouchEvent必定隱藏了判斷邏輯。
public class LinkMovementMethod extends ScrollingMovementMethod {
 @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();  //獲取點選區域在TextView的橫向位置
            int y = (int) event.getY();

            x -= widget.getTotalPaddingLeft();   // 減去TextView左邊的padding值,獲取TextView文字的【可見】起始位置偏移
            y -= widget.getTotalPaddingTop();

            x += widget.getScrollX();  // 可見文字起始偏移 + 左邊因為滑動被隱藏的文字寬度 = 當前點選文字的排布位置
            y += widget.getScrollY();

            Layout layout = widget.getLayout();  // 獲取TextView上文字的排版
            int line = layout.getLineForVertical(y);  // 根據Y座標獲取點選位置的行數
            int off = layout.getOffsetForHorizontal(line, x);  // 根據行數和水平X量獲取當前點選位置距離第一個文字左邊的偏移量

            ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);  // 根據偏移量獲取ClickSpan

            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;
            } else {
                Selection.removeSelection(buffer);
            }
        }
        return super.onTouchEvent(widget, buffer, event);
    }
}
複製程式碼

###4. 控制元件封裝

@SuppressLint("AppCompatCustomView")
public class CommentTextView extends TextView {

    private int mSpanBackgroundColor = 0xFFE0E0E0;

    public CommentTextView(Context context) {
        super(context);
        init();
    }

    public CommentTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CommentTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setMovementMethod(LinkMovementMethod.getInstance());
    }

    public void setSpanClickBackground(int backgroundColor) {
        this.mSpanBackgroundColor = backgroundColor;
    }

    public void setSpan(int start, int end, int color, boolean textBold, OnClickListener listener) {
        CommentClickableSpan commentClickableSpan = new CommentClickableSpan(color, textBold, listener);
        setSpan(start, end, commentClickableSpan);
    }

    public void setSpan(int start, int end, CommentClickableSpan span) {
        CharSequence text = getText();
        if (TextUtils.isEmpty(text)) {
            return;
        }
        start = Math.max(0, start);
        end = Math.min(text.length(), end);
        Spannable buffer;
        if (text instanceof SpannableString) {
            buffer = (Spannable) text;
        } else {
            buffer = new SpannableString(text);
        }
        buffer.setSpan(span, start, end, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE);
        setText(buffer);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Object text = getText();
        if (text instanceof Spannable) {
            Spannable buffer = (Spannable) text;
            int action = event.getAction();
            if (action == MotionEvent.ACTION_UP
                    || action == MotionEvent.ACTION_DOWN) {
                int x = (int) event.getX();
                int y = (int) event.getY();
                x -= getTotalPaddingLeft();
                y -= getTotalPaddingTop();
                x += getScrollX();
                y += getScrollY();
                Layout layout = getLayout();
                int line = layout.getLineForVertical(y);
                int off = layout.getOffsetForHorizontal(line, x);
                ClickableSpan[] link = buffer.getSpans(off, off, CommentClickableSpan.class);
                if (link.length != 0) {
                    if (action == MotionEvent.ACTION_UP) {
                        buffer.setSpan(new BackgroundColorSpan(0x00000000), buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0]), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                        link[0].onClick(this);
                    } else if (action == MotionEvent.ACTION_DOWN) {
                        buffer.setSpan(new BackgroundColorSpan(mSpanBackgroundColor), buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0]), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                        Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0]));
                    }
                    return true;
                }
            }
        }
        return super.onTouchEvent(event);
    }

    public static class CommentClickableSpan extends ClickableSpan {
        private int mShowColor;
        private boolean mTextBold;
        private OnClickListener onClickListener;

        public CommentClickableSpan(int color, boolean textBold) {
            this.mShowColor = color;
            this.mTextBold = textBold;
        }

        public CommentClickableSpan(int color, boolean textBold, OnClickListener listener) {
            this.mShowColor = color;
            this.mTextBold = textBold;
            this.onClickListener = listener;
        }

        @Override
        public void onClick(View widget) {
            //建議使用不帶OnClickListener的構造,並新增帶自己的業務引數的構造  Router.gotoUserCenterActivity(uid);
            if (onClickListener != null) {
                onClickListener.onClick(widget);
            }
        }

        @Override
        public void updateDrawState(TextPaint ds) {
            ds.setColor(mShowColor);
            if (mTextBold) {
                ds.setFlags(TextPaint.FAKE_BOLD_TEXT_FLAG);
            }
            ds.setUnderlineText(false);
        }
    }
}
複製程式碼

設定區域性點選時的位置:setSpan()

設定區域性按壓時背景色:setSpanClickBackground();

相關文章