###1. 需求前提说明
越来越多的应用为了增强用户粘性,选择在应用内部整合类似微信朋友圈的模块。这样的模块提供了各式各样的图文混排效果,在wifi情况下能对视频进行自动预览,有交互良好(开发复杂)的评论体验。开发这一模块,对每个细节的性能要求更为严格,为保证列表滑动的流畅性。由于公司战略调整,添加动态功能(朋友圈),楼主主要负责这一模块的开发,如果有相关问题可以留言讨论,本篇幅主要讲评论组件的封装。
###2. 镇楼图
功能交互点:
- 点击用户昵称,仅昵称文字区域展示按下背景图,跳转进入用户个人主页。
- 点击用户评论,评论+昵称区域展示按下背景图,进行回复评论。
- 长按用户昵称,同(1)效果。
- 长按用户评论,评论+昵称区域展示按下背景图,用于删除评论。
###3. 思路分析
-
Step One: 采用TextView + ClickableSpan
- 代码实现
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);复制代码
- 完成度: 完成控件评论区域的点击和长按,即功能交互点的第2点和第4点。
- 缺陷:点击昵称区域会触发TextView的OnClickListener事件,长按昵称区域,会触发TextView的OnLongClickListener事件。
-
Step Two: ClickableSpan对事件(Click、LongClick)进行消费
- 先查看源码,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; }}复制代码
- 原因: 从代码可以看出,TextView在OnTouchEvent方法中对ClickableSpan中的OnClick进行回调处理,但并没有消费掉此次事件直接返回,而是继续交予TextView处理(可能触发TextView的OnClick和OnLongClick)。
- 解决: 重写TextView的OnTouchEvent方法,先判断点击的区域是否是ClickableSpan,如果是,交由ClickableSpan处理后直接return true返回。
- 疑问: 那么如何判断点击的区域为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();