本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
前言
先来一道趣味测试,后面的控件讲解会比较枯燥乏味,看一看你的数学老师是谁教的?
小明向两位朋友各自借了50元,用借来的钱,小明花费97元买了一件格子衫。这时候还剩3元钱,小明还给两个小伙伴各1元,自己还剩下1元。
那么问题来了:小明此时欠两位小伙伴各49元,再加上自己剩下的1元,49+49+1=99元。剩下的1元去哪了?
正文
近日产品提出了一个新需求,在首页列表中新增可以横向滑动的卡片类型
,效果类似豆瓣弹性滑动控件,看下最终效果图:
小编刚开始以为只要实现了 豆瓣弹性滑动控件
就万事大吉了,没想到这只是一个开始。滑动控件
只不过是一道开胃菜,事件冲突
才是重头戏。
首先分析下效果图中的布局,典型的 ViewPager + fragment + RecyclerView
布局方式,在垂直的 RecyclerView
中嵌入了 弹性滑动控件
字 item
,那么会有哪些事件冲突呢?
-
弹性滑动控件
会消费左右滑动事件,内部的卡片RecyclerView
同时也会消费左右滑动事件,左右滑动事件就会冲突。光是文字的描述,可能不大好理解,结合以下图片加以说明:手指向左滑动,是RecyclerView
消费左滑的事件呢?还是弹性滑动控件
消费左滑的事件?
- 垂直的
RecyclerView
会默认消费上下滑动事件,弹性滑动控件
在左右滑动的同时,y
轴方向的偏移量不会为0
,因为手指的滑动很难保持在一条水平线上,垂直的RecyclerView
就会消费y
方向的事件,导致界面抖动,滑动不灵敏。那么弹性滑动控件
在左右滑动的时候就需要拦截掉垂直的RecyclerView
的滑动事件消费。 -
弹性滑动控件
滑动到左右边缘的时候,最外层的ViewPager
会默认消费掉左右滑动事件,导致滑向上一个tab
或下一个tab
,无任何的弹性效果。处理方式, 在弹性滑动控件
左右滑动的时候,需要禁止掉ViewPager
的事件消费。
一个滑动控件需要解决这么多事件冲突,想一想,是时候使用抽屉里的菜刀了,但让我没想到的是,我拿着菜刀急冲冲找到产品,他却很淡定的从抽屉里拿出了手枪,拿出了手枪,我内心告诉自己不能怂,嘴上却不争气的说道:没问题,so easy
,给我2天时间,我真想给自己一大嘴巴,那么接下来就开整呗。
豆瓣弹性滑动控件
需要实现 豆瓣弹性滑动控件
的效果,先调研下豆瓣的布局方式:
从
uiautomatorviewer.bat
工具中可以分析出,豆瓣是通过自定义 LinearLayout
来实现的,包含了横向的 RecyclerView
与右侧的 释放查看TextView
文本子控件。那么 弹性滑动控件
实现的大概思路如下:RecyclerView
滑动到左右边缘,记录 x
轴方向的偏移量,通过方法 setTranslationX
设置 RecyclerView
的平移量,手指抬起则执行简单的平移动画,接下来会详细讲解,比较乏味,请系好安全带。
分解 弹性滑动
过程,新建HorizontalScrollView
继承RelativeLayout
,并没有继承LinearLayout
,后面会讲到:
-
RecyclerView
滑动到左边缘,继续向右滑动,HorizontalScrollView
拦截事件,同时记录x
方向的偏移量dx
,RecyclerView
调用setTranslationX
方法设置平移量RecyclerView.setTranslationX(dx)
,这里又分两种情况:第一种手指抬起执行平移动画;第二种向左滑动除了RecyclerView.setTranslationX(dx)
还需要判定RecyclerView.getTranslationX()
是否等于0
,如果等于0
则不拦截事件,返回super.dispatchTouchEvent(ev)
。 -
RecyclerView
滑动到右边缘,继续向左滑动,处理同1,还需根据偏移量来判定右侧的文本显示状态。 -
RecyclerView
未滑动到左右边缘,HorizontalScrollView
不拦截事件,RecyclerView
消费左右滑动事件。
请结合以下代码加以理解:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mHorizontalRecyclerView == null) {
return super.dispatchTouchEvent(ev);
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// 重置变量
mHintLeftMargin = 0;
mMoveIndex = 0;
mConsumeMoveEvent = false;
mLastX = ev.getRawX();
mLastY = ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
// 释放动画
if (ReboundAnim != null && ReboundAnim.isRunning()) {
break;
}
float mDeltaX = (ev.getRawX() - mLastX);
float mDeltaY = ev.getRawY() - mLastY;
mLastX = ev.getRawX();
mLastY = ev.getRawY();
mDeltaX = mDeltaX * RATIO;
// 右滑
if (mDeltaX > 0) {
// canScrollHorizontally 判定是否滑动到边缘
if (!mHorizontalRecyclerView.canScrollHorizontally(-1) || mHorizontalRecyclerView.getTranslationX() < 0) {
float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
if (mHorizontalRecyclerView.canScrollHorizontally(-1) && transX >= 0) {
transX = 0;
}
mHorizontalRecyclerView.setTranslationX(transX);
setHintTextTranslationX(mDeltaX);
}
} else if (mDeltaX < 0) { // 左滑
if (!mHorizontalRecyclerView.canScrollHorizontally(1) || mHorizontalRecyclerView.getTranslationX() > 0) {
float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
if (transX <= 0 && mHorizontalRecyclerView.canScrollHorizontally(1)) {
transX = 0;
}
mHorizontalRecyclerView.setTranslationX(transX);
setHintTextTranslationX(mDeltaX);
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// 释放动画
if (ReboundAnim != null && ReboundAnim.isRunning()) {
break;
}
if (mHintLeftMargin <= mOffsetWidth && mListener != null) {
// 松手看更多的事件监听
mListener.onRelease();
}
// 手指抬起动画
ReboundAnim = ValueAnimator.ofFloat(1.0f, 0);
ReboundAnim.setDuration(300);
ReboundAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
mHorizontalRecyclerView.setTranslationX(value * mHorizontalRecyclerView.getTranslationX());
mMoreTextView.setTranslationX(value * mMoreTextView.getTranslationX());
}
});
ReboundAnim.start();
break;
}
return mHorizontalRecyclerView.getTranslationX() != 0 ? true : super.dispatchTouchEvent(ev);
}
代码逻辑很清晰,有不理解的童鞋,请留言。弹性效果实现了,但右侧还有一个竖直的文本控件,ui
需要的效果如下图,需要实现的功能如下:
- 内容垂直排版
- 文字间的间距需要可控
- 可以设置图标
- 贝塞尔曲线阴影,根据手指偏移量来动态改变贝塞尔曲线的控制点
很遗憾,原生的 TextView
并不支持内容垂直排版,间距也不可控,但欣慰的是支持设置图标,那么重写 onDraw
方法,自己绘制垂直文本,可谓是一个不错的方案。
VerticalTextView
继承 AppCompatTextView
,通过 setVerticalText()
方法设置绘制文本:
public void setVerticalText(CharSequence text) {
mDefaultText = text;
invalidate();
}
通过获取基线 baseline
坐标,以及整个字符的高度,来调整文本居中对齐,然后根据每个字符的高度,累加绘制文本:
@Override
protected void onDraw(Canvas canvas) {
mPaint.setTextSize(getTextSize());
mPaint.setColor(getCurrentTextColor());
mPaint.setTypeface(getTypeface());
CharSequence text = mDefaultText;
if (getText() != null && !text.toString().trim().equals("")) {
Rect bounds = new Rect();
mPaint.getTextBounds(text.toString(), 0, text.length(), bounds);
// 最开始就忘记 + getPaddingLeft 导致绘制的文本偏左
float startX = getLayout().getLineLeft(0) + getPaddingLeft();
if (getCompoundDrawables()[0] != null) {
Rect drawRect = getCompoundDrawables()[0].getBounds();
// 减去图标的宽度
startX += (drawRect.right - drawRect.left);
}
startX += getCompoundDrawablePadding();
float startY = getBaseline();
int cHeight = (bounds.bottom - bounds.top + mCharSpacing);
// 居中对齐
startY -= (text.length() - 1) * cHeight / 2;
for (int i = 0; i < text.length(); i++) {
String c = String.valueOf(text.charAt(i));
canvas.drawText(c, startX, startY + i * cHeight, mPaint);
}
}
super.onDraw(canvas);
// 绘制贝塞尔阴影
if (mIsDrawShadow) {
mShadowPath.reset();
mShadowPath.moveTo(getWidth(), getHeight() / 4);
mShadowPath.quadTo(mShadowOffset, getHeight() / 2, getWidth(), getHeight() / 4 * 3);
canvas.drawPath(mShadowPath, mShadowPaint);
}
}
突然有个想法,如果以路径 Path
来绘制文本,岂不更棒,有兴趣的小伙伴可以下来试一试。弹性滑动控件
到这里就告一段落了,接下来主要处理集成到项目中的滑动事件冲突。
垂直RecyclerView滑动冲突
垂直 RecyclerView
会消费上下滑动事件,导致 弹性滑动控件
在水平方向滑动的时候,y
轴方向产生的偏移量被垂直 RecyclerView
消费,请看下图:
那么怎么来处理与垂直 RecyclerView
产生的事件冲突呢?处理事件冲突的方式有两种:
- 子
View
禁止父View
拦截Touch
事件,在分析ViewGroup
的dispatchTouchEvent()
源码时,我们知道:Touch
事件是由父View
分发的。如果一个Touch
事件是子View
需要的,但是被其父View
拦截了,子View
就无法处理该Touch
事件了。在此情形下,子View
可以调用requestDisallowInterceptTouchEvent( )
禁止父View
对Touch
的拦截 - 在父
View
中准确地进行事件分发和拦截 ,我们可以重写父View
中与Touch
事件分发相关的方法,比如onInterceptTouchEvent( )
。这些方法中摒弃系统默认的流程,结合自身的业务逻辑重写该部分代码,从而使父View
放行子View
需要的Touch
这里以第一种的方式解决与垂直方向的 RecyclerView
滑动冲突,第二种方式解决与 ViewPager
的滑动冲突。原理非常的简单,判定 x
方向的偏移量是否大于 y
方向的偏移量,大于则禁止父 View
拦截 Touch
事件,反之则不拦截,具体代码如下:
float mDeltaX = (ev.getRawX() - mLastX);
float mDeltaY = ev.getRawY() - mLastY;
if (!mConsumeMoveEvent) {
// 处理事件冲突
if (Math.abs(mDeltaX) > Math.abs(mDeltaY)) {
getParent().requestDisallowInterceptTouchEvent(true);
} else {
getParent().requestDisallowInterceptTouchEvent(false);
}
}
mMoveIndex++;
if (mMoveIndex > 2) {
mConsumeMoveEvent = true;
}
mLastX = ev.getRawX();
mLastY = ev.getRawY();
很多时候触摸屏幕会导致第一次 ACTION_MOVE
获取的 mDeltaX
与 mDeltaY
都为 0
,导致父 View
拦截了 Touch
事件,弹性效果失效,为了解决这个问题,这里用到了一个小技巧,多判定一次拦截条件。大家发现没有,代码中还有一处优化的地方,getParent()
方法获取的父控件不一定是列表控件,比较合理的方式使用递归去获取,相关代码如下:
private ViewParent getParentListView(ViewParent viewParent) {
if (viewParent == null) return null;
if (viewParent instanceof RecyclerView || viewParent instanceof ListView) {
return viewParent;
} else {
getParentListView(viewParent.getParent());
}
return null;
}
ViewPager滑动冲突
ViewPager
会默认消费左右滑动事件,当 弹性控件
滑动到左右边缘时,继续滑动会触发 ViewPager
的滑动,请看下图:
这里采用第二种方式处理滑动冲突,在父 View
中准确地进行事件分发和拦截,那么我们什么时候分发?又什么时候拦截呢?如果我们左右滑动的是非 弹性控件
区域,那么 ViewPager
应该拦截事件,反之则分发事件。
那么我们才能知道触摸的是 弹性控件
区域呢?可能在屏幕中的任何位置,我们知道 view
的层级是树形结构,那么针对 ViewPager
的子 view
进行遍历,拿到设有 弹性控件
的 tag
标记,来进行事件的分发和拦截,具体代码如下,不知道小伙伴又没更好的方案:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
switch (action) {
case MotionEvent.ACTION_DOWN:
mInterceptEvent = !childInterceptEvent(this, (int) ev.getRawX(), (int) ev.getRawY());
break;
}
// 拦截与分发
return mInterceptEvent ? super.onInterceptTouchEvent(ev) : false;
}
// 遍历树
private boolean childInterceptEvent(ViewGroup parentView, int touchX, int touchY) {
boolean isConsume = false;
for (int i = parentView.getChildCount() - 1; i >= 0; i--) {
View childView = parentView.getChildAt(i);
if (!childView.isShown()) {
continue;
}
boolean isTouchView = isTouchView(touchX, touchY, childView);
if (isTouchView && childView.getTag() != null && TAG_DISPATCH.equals(childView.getTag().toString())) {
isConsume = true;
break;
}
if (childView instanceof ViewGroup) {
ViewGroup itemView = (ViewGroup) childView;
if (!isTouchView) {
continue;
} else {
isConsume |= childInterceptEvent(itemView, touchX, touchY);
if (isConsume) {
break;
}
}
}
}
return isConsume;
}
// 是否在触摸区域内
private boolean isTouchView(int touchX, int touchY, View view) {
Rect rect = new Rect();
view.getGlobalVisibleRect(rect);
return rect.contains(touchX, touchY);
}
感兴趣的小伙伴的可以以第一种方式来解决滑动冲突。文中涉及的知识点都是个人的看法,如果你觉得有什么地方不妥,欢迎指出?每个人在开发当中的场景可能都不一样,有时候需要根据特定的规则去处理滑动冲突,但是处理冲突的基本原理和方式是相同的,希望本篇文章对大家有所帮助,想了解更多炫酷控件,别忘了关注小编。
结语
源码小编整理后会上传到 MeiWidgetView ,同时非常希望各位小伙伴能够动手点颗 star ,你的鼓励与支持才是让小编继续创作的源泉。