上个星期更新了网易云音乐之后,在发现->歌单页面中看到一个挺炫酷的效果,介系我没有见过的船新版本,看图:
对,一眼看上去就像是在ViewPager的基础上改造过,但仔细看,又不太像ViewPager的行为,因为它固定只有三个子View(我特意观察了几天),而且,在滑动的时候,除了尺寸和透明度的渐变,跟ViewPager有一个明显的区别就是,最前面的子View会向相反方向移动,这就像六一儿童节孩子们排队领糖果一样:最前面的领到了糖果,还想再领一次,于是就到最后面重新排队。哈哈
还有一个比较细节的效果就是,在手指滑动到屏幕宽度的一半左右,本来在中间的子View跟即将来到中间的子View他们会交换层级顺序,看:
emmmm,这样的话,基本不用考虑改造ViewPager了,直接自定义ViewGroup吧,而且ViewPager使用起来还要定义Adapter,很繁琐。
先来观察一下它的行为:
再根据它的行为来捋一下大致思路:
那这个交换子View层级顺序的效果,这个应该怎么做呢?
很多同学第一时间可能会想到:先remove,再add回去。
嗯,这样做虽然也能实现交换顺序,但是还是重量级了点,在一些低端机上面还可能会出现闪一下的效果(因为移除了之后不能及时地add回去)。我们还有更高效和更轻量的方法:
了解过RecyclerView回收机制的同学,应该对LayoutManager中的detachView
和attachView
方法很有印象:在进行滚动的时候,LayoutManager会不断地detach无效的item,重新绑定数据之后,又会立即attach回去,那么,我们做交换层级顺序的,也可以用这种方式,来试试。
在ViewGroup中,这两个方法分别对应detachViewFromParent
和attachViewToParent
,但都是用protected修饰的,我们要在外面调用它就要创建一个类继承现有的ViewGroup然后重写这两个方法并把protected改为public:
@Override
public void detachViewFromParent(View child) {
super.detachViewFromParent(child);
}
@Override
public void attachViewToParent(View child, int index, ViewGroup.LayoutParams params) {
super.attachViewToParent(child, index, params);
}
接着我们在Activity中监听子View点击事件:哪个子View被点击,就置顶哪个:
public void moveToTop(View target) {
//先确定现在在哪个位置
int startIndex = mViewGroup.indexOfChild(target);
//计算一共需要几次交换,就可到达最上面
int count = mViewGroup.getChildCount() - 1 - startIndex;
for (int i = 0; i < count; i++) {
//更新索引
int fromIndex = mViewGroup.indexOfChild(target);
//目标是它的上层
int toIndex = fromIndex + 1;
//获取需要交换位置的两个子View
View from = target;
View to = mViewGroup.getChildAt(toIndex);
//先把它们拿出来
mViewGroup.detachViewFromParent(toIndex);
mViewGroup.detachViewFromParent(fromIndex);
//再放回去,但是放回去的位置(索引)互换了
mViewGroup.attachViewToParent(to, fromIndex, to.getLayoutParams());
mViewGroup.attachViewToParent(from, toIndex, from.getLayoutParams());
}
//刷新
mViewGroup.invalidate();
}
好,来看看效果:
哈哈,可以了。
上面我们观察到,当那个ViewGroup静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间。
网易云的做这个就舒服些,因为它可以在子View接收到onClick事件的时候,先跟这个ViewGroup互动一下,但因为我们做的是一个库,不可能叫大家自己去监听onClick后,还通知一下ViewGroup的,所以我们要在内部处理好这个逻辑,也就是要拦截子View的点击事件了。
到这里有同学可能会问:拦截子View点击事件?直接重写onInterceptTouchEvent方法,在里面判断并return true不就行了?
没错,大致流程是这样,但现在的问题是:如何找到这个被点击的View,来进行选择性的拦截?
这时候同学就会说:判断是否在子View[left, top, right, bottom]
内不就行了。
emmmm,这个方法在一般情况下是可以,但是,如果子View应用过scale,translation,rotation之类的变换,就有问题了,再加上我们等下处理滑动的时候,还要将子View应用scale变换呢。
同学又会说:这种效果,在绝大多数情况下,子View都不会单独应用那些变换的,那我们也不用scale,要缩放就直接layout成子View的目标尺寸不就行了?这样的话,直接判断是否在边界内就行啦
不,直接layout成缩放后的大小是不行的,因为如果有子View在xml里面就已经写死了长宽,那么它在测量完之后,getMeasuredWidth
和getMeasuredHeight
方法通常会返回这个写死的值(这里为什么是用通常,而不用总是呢? 因为这个数值完全取决于那个子View,有的自定义View可能根本不会理会这个设置的值),这样一来,我们就控制不了它实际的尺寸了(除非是用wrap_content或match_parent),这种情况,在进行布局时,如果不按照它实际大小去layout,那么就会出现好像子View被裁剪了一样(如果layout的尺寸比实际的尺寸小的话),所以我认为网易云的效果,它子View是定制过的,也就是说那几个子View会根据最终layout出来的尺寸去调整View的内容。但是如果作为一个库的话,不可能让大家都像网易云这样做的,所以我们就选择用scale的方式来做了。
好了,来想想应该怎么拦截吧:
本来的思路是通过反射拿到onClickListener,然后在这个listener上面再套一层我们自己的listener的,结果看源码的时候发现:View.ListenerInfo(全部监听器都是由这个静态内部类来保管)里面的mOnClickListener是标记了@hide的!!!,又因为9.0系统禁止调用Hidden API的缘故(这里暂不讨论要怎么调用Hidden API),所以只能绕路走了,我们想一下其他方法。
还记不记得我们上次分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件?
我们这次也刚好可以用的上,因为子View在移动过程中会进行scale操作,像刚刚那位同学说的直接判断是否在子View[left, top, right, bottom]
内是不行的,正确的做法应该要像ViewGroup那样:先检查一下这个子View所对应的矩阵有没有应用过变换,如果有的话,还要先把触摸坐标映射到矩阵变换之前的对应位置,再来判断是否在View内。
那我们现在就来模拟一下,如何判断手指当前在哪个子View上:
先看ViewGroup的源码:
public void transformPointToViewLocal(float[] point, View child) {
...
if (!child.hasIdentityMatrix()) {
child.getInverseMatrix().mapPoints(point);
}
}
它先是调用子View的hasIdentityMatrix
方法来判断是否应用过变换,如果有的话,会接着调用getInverseMatrix
方法。。。。
咦??等等,为什么我们平时写代码的时候,AS没有出现过这几个方法的提示呢?点进去看看先:
final boolean hasIdentityMatrix() {
return mRenderNode.hasIdentityMatrix();
}
看。。看看getInverseMatrix
和pointInView
方法:
/**
* Utility method to retrieve the inverse of the current mMatrix property.
* We cache the matrix to avoid recalculating it when transform properties
* have not changed.
*
* @return The inverse of the current matrix of this view.
* @hide
*/
public final Matrix getInverseMatrix() {
ensureTransformationInfo();
if (mTransformationInfo.mInverseMatrix == null) {
mTransformationInfo.mInverseMatrix = new Matrix();
}
final Matrix matrix = mTransformationInfo.mInverseMatrix;
mRenderNode.getInverseMatrix(matrix);
return matrix;
}
/**
* Utility method to determine whether the given point, in local coordinates,
* is inside the view, where the area of the view is expanded by the slop factor.
* This method is called while processing touch-move events to determine if the event
* is still within the view.
*
* @hide
*/
public boolean pointInView(float localX, float localY, float slop) {
return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
localY < ((mBottom - mTop) + slop);
}
没事!来仔细看一下它们各自方法内的实现,
mRenderNode
的hasIdentityMatrix;mRenderNode
的;[mLeft, mRight, mTop, mBottom]
我们也完全可以在外面使用它们的get方法来获取,这就代表着我们可以自己在外面定义方法,来代替它这个pointInView;那么,我们现在来看mRenderNode了,先检查下它的声明:
/**
* RenderNode holding View properties, potentially holding a DisplayList of View content.
*
* When non-null and valid, this is expected to contain an up-to-date copy
* of the View content. Its DisplayList content is cleared on temporary detach and reset on
* cleanup.
*/
final RenderNode mRenderNode;
太好了,没有被标记@hide,接下来看看它里面的hasIdentityMatrix和getInverseMatrix方法:
public boolean hasIdentityMatrix() {
return nHasIdentityMatrix(mNativeRenderNode);
}
public void getInverseMatrix(@NonNull Matrix outMatrix) {
nGetInverseTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
}
哇,这么好!居然是public的,也就是说,我们连反射都省了。
那再看看它这个类本身是不是也是public的:
/**
* ...
* ...
* @hide
*/
public class RenderNode {
啊,绝望,RenderNode这个类被标记了@hide。。。
不得不说,Google爸爸在这方面确实做得很绝考虑得很周到。
其实,还有一个不是很装逼优雅的方法也可以正确判断到当前手指在哪个View上:
我们刚刚的思路,不是有记录每个子View的scale的吗?
[left, top, right, bottom]
,并保存在自定义的LayoutParams中;pointInView
方法,在里面判断[x, y]
是否在刚刚保存的边界范围内就行;emmmm,在没有其他更好的办法的情况下,用这种方法也是挺好的,起码能解决问题。
细心的同学会发现,View里面也有个getMatrix
方法,这个方法可以在外部调用,即没有被标记@hide的:
public Matrix getMatrix() {
ensureTransformationInfo();
final Matrix matrix = mTransformationInfo.mMatrix;
mRenderNode.getMatrix(matrix);
return matrix;
}
可以看到,它最终也是通过RenderNode
的getMatrix
方法来实现的,来看看RenderNode的实现:
public void getMatrix(@NonNull Matrix outMatrix) {
nGetTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
}
有没有发现,这个getMatrix
,跟我们刚刚在上面贴出来的hasIdentityMatrix
和getInverseMatrix
方法一样,都是直接调用对应的native方法的
而熟悉Matrix的同学会知道,在Matrix里面也有个isIdentity方法,那么,
emmmm,源码会给我们答案。
在系统源码 /frameworks/base/core/jni/ 目录下,会看到一个叫android_view_RenderNode.cpp的文件
先来看看nHasIdentityMatrix
,nGetTransformMatrix
,nGetInverseTransformMatrix
分别对应哪三个方法:
static const JNINativeMethod gMethods[] = {
...
{ "nHasIdentityMatrix", "(J)Z", (void*) android_view_RenderNode_hasIdentityMatrix },
{ "nGetTransformMatrix", "(JJ)V", (void*) android_view_RenderNode_getTransformMatrix },
{ "nGetInverseTransformMatrix","(JJ)V", (void*) android_view_RenderNode_getInverseTransformMatrix },
...
};
可以看到nHasIdentityMatrix
是对应android_view_RenderNode_hasIdentityMatrix
方法,来看看它的实现:
static jboolean android_view_RenderNode_hasIdentityMatrix(jlong renderNodePtr) {
RenderNode* renderNode = reinterpret_cast<RenderNode*>(renderNodePtr);
return !renderNode->stagingProperties().hasTransformMatrix();
}
它拿到RenderNode实例后(RenderNode可以在系统源码/frameworks/base/libs/hwui/目录下找到),先是通过stagingProperties
方法拿到RenderProperties
这个属性,接着调用它的hasTransformMatrix
方法
打开RenderNode.h,可以分别看到以下声明:
RenderProperties mStagingProperties;
const RenderProperties& stagingProperties() {
return mStagingProperties;
}
那么我们接下来,就要看RenderProperties了,打开RenderProperties.h,找一下hasTransformMatrix
方法:
bool hasTransformMatrix() const {
// getTransformMatrix方法返回的SkMatrix不为空,
// 并且SkMatrix的isIdentity方法返回false,就表示这个矩阵应用过变换
return getTransformMatrix() && !getTransformMatrix()->isIdentity();
}
const SkMatrix* getTransformMatrix() const {
return mComputedFields.mTransformMatrix;
}
看到了吗?hasTransformMatrix
里面最终也会调用SkMatrix的isIdentity
方法!
那SkMatrix的isIdentity
跟Matrix的isIdentity
又有什么关系呢?
我们来看Matrix.cpp (/frameworks/base/core/jni/android/graphics/)
static jboolean isIdentity(jlong objHandle) {
SkMatrix* obj = reinterpret_cast<SkMatrix*>(objHandle);
return obj->isIdentity() ? JNI_TRUE : JNI_FALSE;
}
哈哈哈,可以看到,它里面也是调用SkMatrix的isIdentity
方法,也就是说,RenderNode的hasIdentityMatrix
和Matrix的isIdentity
最终都是会调用SkMatrix的isIdentity
方法,它们的效果是一样的!
我们刚刚分析过,View的getMatrix
方法,里面是调用RenderNode的getMatrix
,View的getInverseMatrix
,里面是调用RenderNode的getInverseMatrix
那么我们现在就来看看getTransformMatrix
(RenderNode的getMatrix)和getInverseTransformMatrix
(RenderNode的getInverseMatrix)这两个方法:
static void android_view_RenderNode_getTransformMatrix(jlong renderNodePtr, jlong outMatrixPtr) {
// 通过强制类型转换得到renderNode
RenderNode* renderNode = reinterpret_cast<RenderNode*>(renderNodePtr);
// 通过强制类型转换得到outMatrix
SkMatrix* outMatrix = reinterpret_cast<SkMatrix*>(outMatrixPtr);
// 通过renderNode中的StagingProperties属性的getTransformMatrix方法来获取变换矩阵
const SkMatrix* transformMatrix = renderNode->stagingProperties().getTransformMatrix();
// 如果这个变换矩阵不为空的话,
if (transformMatrix) {
// 把指针transformMatrix所指的内容,覆盖到outMatrix所指的内容上
*outMatrix = *transformMatrix;
} else {
// 如果变换矩阵为空,那就表示没有应用过变换,所以isIdentity要=true
outMatrix->setIdentity();
}
}
static void android_view_RenderNode_getInverseTransformMatrix(jlong renderNodePtr, jlong outMatrixPtr) {
// 先获取变换矩阵,如果有的话
android_view_RenderNode_getTransformMatrix(renderNodePtr, outMatrixPtr);
// 通过强制类型转换得到outMatrix
SkMatrix* outMatrix = reinterpret_cast<SkMatrix*>(outMatrixPtr);
// 将outMatrix反转(即获取逆矩阵),并把反转之后的矩阵重新赋给outMatrix
if (!outMatrix->invert(outMatrix)) {
// 如果反转失败,证明此矩阵没有应用过变换
outMatrix->setIdentity();
}
}
啊!看到没有?!RenderNode中getInverseMatrix
(即getInverseTransformMatrix)的具体实现,就是先调用它的getMatrix
(即getTransformMatrix)方法得到变换矩阵之后,再调用这个矩阵的invert方法! 这样就没了!
矩阵的invert方法,就是把当前矩阵反转,得到它的逆矩阵,逆矩阵是什么? 即:
setTranslate(50, 0)
),那么这个矩阵的逆矩阵就是水平平移了-50;setScale(0.8F, 0.8F)
),那么这个矩阵的逆矩阵就是放大了20%;setRotate(90)
),那么这个矩阵的逆矩阵就是逆时针旋转90°;那么,我们现在就不难理解,为什么ViewGroup要获取逆矩阵来映射触摸点了。
还有就是,现在我们完全可以不使用反射,来做到像ViewGroup那样,判断触摸点是否在子View范围内了。
现在来试一下:
/**
* @param view 目标view
* @param points 坐标点(x, y)
* @return 坐标点是否在view范围内
*/
private boolean pointInView(View view, float[] points) {
// 像ViewGroup那样,先对齐一下Left和Top
points[0] -= view.getLeft();
points[1] -= view.getTop();
// 获取View所对应的矩阵
Matrix matrix = view.getMatrix();
// 如果矩阵有应用过变换
if (!matrix.isIdentity()) {
// 反转矩阵
matrix.invert(matrix);
// 映射坐标点
matrix.mapPoints(points);
}
//判断坐标点是否在view范围内
return points[0] >= 0 && points[1] >= 0 && points[0] < view.getWidth() && points[1] < view.getHeight();
}
哈哈哈,成功了!完美避开使用反射。
那么等下写代码的时候,就可以将这个方法应用到我们的ViewGroup当中,用来拦截子View原有的点击事件。
好啦,那么现在我们总体的思路也有了,是时候开始写代码了。
在开始之前,先给我们的ViewGroup起个名字吧,因为它的行为比较像ViewPager,不过它的Item又不能像ViewPager那样不固定,在可扩展性这方面是不及ViewPager。但是,ViewPager使用起来的流程比较繁琐,还要定义Adapter之类的,而我们这个就不用,所以在易用性方面,我们的ViewGroup更胜一筹。在日常开发中,我们所使用的数据库通常都是SQLite,寓意是轻量级的数据库,那么我们的ViewGroup也可以叫LitePager,寓意是轻量级的ViewPager,挺洋气的名字,哈哈哈哈哈,就叫LitePager吧。
好,开始吧,首先是onMeasure
,那宽高应该怎么确定呢? 如果宽高设置了wrap_content
的话:
来看看代码怎么写:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 先测量子View
measureChildren(widthMeasureSpec, heightMeasureSpec)
val width = measureWidth(widthMeasureSpec)
val height = measureHeight(heightMeasureSpec)
setMeasuredDimension(width, height)
}
private fun measureHeight(heightMeasureSpec: Int): Int {
var height = 0
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize
} else {
//如果高度设置了wrap_content,则取最大的子View高
var maxChildHeight = 0
for (i in 0 until childCount) {
val child = this[i]
val layoutParams = child.layoutParams as LayoutParams
maxChildHeight = Math.max(maxChildHeight, child.measuredHeight
+ layoutParams.topMargin + layoutParams.bottomMargin)
}
height = maxChildHeight
}
return height
}
private fun measureWidth(widthMeasureSpec: Int): Int {
var width = 0
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize
} else {
//如果宽度设置了wrap_content,则取全部子View的宽度和
for (i in 0 until childCount) {
val child = this[i]
val layoutParams = child.layoutParams as LayoutParams
width += child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
}
}
return width
}
虽然是Kotlin代码,但是逻辑挺清晰的,就算不熟悉Kotlin的同学也能很轻易的看懂(还没开始学Kotlin的同学赶快跟上大家的脚步啦~)。
细心的同学可能会发现这个:
val child = this[i]
这个像访问数组元素一样的操作方式,在Kotlin中叫下标运算符,想要支持这个运算符的话,只需要在类中声明一个get方法,并标记operator:
operator fun get(index: Int) = getChildAt(index)
我们现在的实现是调用getChildAt方法。在java中可没有这种特性的哦~
好,接下来到布局了,上面我们分析过,可以用三条基准线来辅助定位,来看看代码怎么写:
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (index in 0 until childCount) {
val child = this[index]
//获取基准线
val baseLine = getBaselineByChild(child)
//布局子View
layoutChild(child, baseLine)
}
}
private fun getBaselineByChild(child: View) =
//根据子View在ViewGroup中的索引计算基准线
when (indexOfChild(child)) {
0 -> width / 4 //左边的线 (最底的子View放在左边)
1 -> width / 2 + width / 4 //右边的线 (接着放在右边)
2 -> width / 2 //中间的线 (最顶的子View放在中间)
else -> 0
}
private fun layoutChild(child: View, baseLine: Int) {
//获取子View测量宽高
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
//垂直的中心位置,即高度的一半
val baseLineCenterY = height / 2
//根据基准线来定位水平上的位置
val left = baseLine - childWidth / 2
val right = left + childWidth
//垂直居中
val top = baseLineCenterY - childHeight / 2
val bottom = top + childHeight
val lp = child.layoutParams as LayoutParams
child.layout(left + lp.leftMargin + paddingLeft,
top + lp.topMargin + paddingTop,
right + lp.leftMargin - paddingRight,
bottom + lp.topMargin - paddingBottom)
}
可以看到,获取基准线被定义成了一个单独的方法getBaselineByChild
,为什么呢,因为等下处理滑动手势的时候,这个基准线是需要动态计算的。
有同学可能会问:这个方法里面的width
是从哪里来的呢?没看到有在哪里声明啊
这个也是Kotlin的特性之一,我们看到的width
,其实是访问getter方法,在这里也就是getWidth()
了,还有layoutChild
方法里面的height
也是同理,调用的是getHeight()
。
好,来看看初步的效果(为了更容易理解,加上了辅助线):
emmmm,挺好。
上面我们看到网易云的效果是两边的子View会变小和变透明,那么这些属性(缩放比例、透明度)肯定要用变量保存起来的,保存在哪里好呢?用一个集合来装吗?当然不是了,我们可以扩展LayoutParams,把这些属性都放在LayoutParams里面:
因为我们的ViewGroup需要支持Margin,所以继承自MarginLayoutParams:
class LayoutParams : MarginLayoutParams {
var scale = 0F
var alpha = 0F
constructor(c: Context, attrs: AttributeSet) : super(c, attrs)
constructor(width: Int, height: Int) : super(width, height)
constructor(source: ViewGroup.LayoutParams) : super(source)
}
定义好之后,还要重写三个生成LayoutParams的方法,并在里面返回我们自己的LayoutParams:
override fun generateLayoutParams(attrs: AttributeSet) = LayoutParams(context, attrs)
override fun generateLayoutParams(p: ViewGroup.LayoutParams) = LayoutParams(p)
override fun generateDefaultLayoutParams() = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
那么,LayoutParams中的scale和alpha在哪里初始化好呢?
当然是在addView
方法里了,在这里,我们还可以顺便限制一下子View的个数(因为超过三个的话,就不知道应该怎么布局了。。。网易云上也只有固定的三个):
override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
if (childCount > 2) {
//满座了就直接拋异常,提示不能超过三个子View
throw IllegalStateException("LitePager can only contain 3 child!")
}
//如果传进来的LayoutParams不是我们自定义的LayoutParams的话,就要创建一个
val lp = if (params is LayoutParams) params else LayoutParams(params)
if (childCount < 2) {
lp.alpha = mMinAlpha
lp.scale = mMinScale
} else {
lp.alpha = 1F
lp.scale = 1F
}
super.addView(child, index, params)
}
可以看到,我们重写的addView方法,在限制子View个数之后,会进行判断:如果是最后一个,也就是第三个添加进来的子View,alpha
和scale
才是"正常"的(缩放比例和不透明度都是1,即100%)
mMinAlpha
和mMinScale
用来保存最小的不透明度和缩放比例,当然了,这两个值是可以给外部修改的,默认值分别是0.4
和0.8
。
接下来,还需要修改一下刚刚的layoutChild
方法:在进行layout之前,先更新一下不透明度和缩放比例:
private fun layoutChild(child: View, baseLine: Int) {
val lp = child.layoutParams as LayoutParams
//更新不透明度
child.alpha = lp.alpha
//更新缩放比例
child.scaleX = lp.scale
child.scaleY = lp.scale
//其他地方不变
...
...
}
这个child.xxx = xxx
,其实是调用setter方法啦,child.alpha = lp.alpha
等于java中的 child.setAlpha(lp.alpha);
emmmm,可以看到,现在的效果已经跟网易云的差不多了,下面我们来添加手势滑动的效果。
网易云的处理方式是:子View从当前基准点移动到下一个基准点时,偏移量刚好等于ViewGroup的宽度,也就是说,当手指的水平滑动距离=ViewGroup宽度时,这个ViewGroup也刚好切换页面了(即进行了一次完整的滑动)。
这样的话,我们就需要计算手指水平滑动的百分比,然后转换成基准线之间的百分比,计算出偏移的距离后,还需要进行以下处理:
index=0
),要向右偏移。反之,如果是向右滑动的话,最右边的子View(index=1
)要向左偏移;第一个没什么难度,先用indexOfChild
方法获取到子View在ViewGroup中的索引然后根据这个索引来判断就行了。
第二个,我们在开头的时候就已经分析过要怎么交换顺序了,所以等下可以直接把那个方法应用到这里来;
第三,如果当前向右滑动的距离=ViewGroup的宽度,也就是说这时候已经进行了一次完整的滑动了:本来在右边的子View,现在已经到了左边、本来在中间的,现在在右边、本来在左边的,现在到了中间。那么,如果现在继续向右滑动的话,一开始在右边的子View(现在在左边),就要向右移动而不是上一次的向左了,其他两个子View同理。
这种情况的话,怎么去正确的判断哪个子View是要往左,哪个要往右呢?想一下
有没有发现,这种问题可以用我们生活中的场景来辅助解决:我们平时叫滴滴,你告诉师傅你在哪,要去哪里,只要你的出发地和目的地正确,师傅就能顺利的把你送到目的地。哈哈哈
那么,我们也可以在LayoutParams里面记录子View的from
和to
,在每一次完整的滑动之后,更新每一个子View的这两个值。
嗯,就这么决定了,开始写代码:
首先是重新onInterceptTouchEvent
方法,我们要在这里去判断并拦截触摸事件:
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN -> {
//更新上一次的触摸坐标
mLastX = x
mLastY = y
}
MotionEvent.ACTION_MOVE -> {
val offsetX = x - mLastX
val offsetY = y - mLastY
//判断是否触发拖动事件
if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) {
//更新上一次的触摸坐标
mLastX = x
mLastY = y
//标记已开始拖拽
isBeingDragged = true
}
}
MotionEvent.ACTION_UP -> {
//标记没有在拖拽
isBeingDragged = false
}
}
return isBeingDragged
}
这里跟一般的ViewGroup没什么不同,大致逻辑就是:判断手指的移动距离是否>指定值,如果是,就拦截并标记正在拖拽。
接下来到onTouchEvent
,我们在里面要处理的逻辑有:
来看看代码:
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val offsetX = x - mLastX
mOffsetX += offsetX
onItemMove()
}
MotionEvent.ACTION_UP -> {
isBeingDragged = false
}
}
mLastX = x
mLastY = y
return true
}
可以看到我们把处理ACTION_MOVE
单独定义了一个方法,把刚刚说的要做的事情都放在onItemMove
里面:
private fun onItemMove() {
//更新滑动百分比
mOffsetPercent = mOffsetX / width
//更新子View的出发点和目的地
updateChildrenFromAndTo()
//更新子View的层级顺序
updateChildrenOrder()
//更新子View的不透明度和缩放比例
updateChildrenAlphaAndScale()
//请求重新布局
requestLayout()
}
先来看一下如何更新出发点和目的地(updateChildrenFromAndTo()
),逻辑稍微有点复杂,要仔细看一下注释。:
private fun updateChildrenFromAndTo() {
//如果滑动的距离>=ViewGroup宽度
if (Math.abs(mOffsetPercent) >= 1) {
//遍历子View,标记已经到达目的地
for (i in 0 until childCount) {
val lp = this[i].layoutParams as LayoutParams
lp.from = lp.to
}
//处理溢出: 比如总宽度是100,现在是120,那么处理之后会变成20
mOffsetX %= width.toFloat()
//同理,这个是百分比
mOffsetPercent %= 1F
} else {
//遍历子View,并根据当前滑动的百分比来更新子View的目的地
for (i in 0 until childCount) {
val lp = this[i].layoutParams as LayoutParams
lp.to = when (lp.from) {
//最左边的子View,如果是向右滑动的话,那么它的目的地是中间,也就是2了
//如果是向左滑动的话,目的地是最右边的位置,也是1了,下面同理
0 -> if (mOffsetPercent > 0) 2 else 1
//最右边的子View,如果是向右滑动,那么目的地就是最左边(0),反之,在中间(2)
1 -> if (mOffsetPercent > 0) 0 else 2
//中间的子View,如果向右滑动,目的地是右边(1),向左就是左边(0)
2 -> if (mOffsetPercent > 0) 1 else 0
else -> return
}
}
}
}
可以看到有一句是lp.to = when (lp.from)
,还没开始学习Kotlin的同学可能会不了解
这个when,有点像java的switch,但是跟switch最大的区别就是,when是一个表达式,也就是可以有返回值,所以上面那句lp.to = when (lp.from)
,这个lp.to
,最终接收的时候when里面的if else的返回值。
好,接下来看看updateChildrenOrder
方法,要注意的是,每次滑动距离超过50%的时候只会交换一次顺序,除了这个还要处理回退的问题,也就是滑动超过一半后(这时已经交换过顺序),又反方向滑动,这时候也要交换一次顺序:
private fun updateChildrenOrder() {
//如果滑动距离超过了ViewGroup宽度的一半,
//就把索引为1,2的子View交换顺序,并标记已经交换过
if (Math.abs(mOffsetPercent) > .5F) {
if (!isReordered) {
exchangeOrder(1, 2)
isReordered = true
}
} else {
//滑动距离没有超过宽度一半,即有可能是滑动超过一半然后滑动回来
//如果isReordered=true,就表示本次滑动已经交换过顺序
//所以要再次交换一下
if (isReordered) {
exchangeOrder(1, 2)
isReordered = false
}
}
}
现在还不行,还要在刚刚的updateChildrenFromAndTo
方法内判断滑动完成那里,重置这个isReordered
,因为如果不在那里重置的话,在下一次该交换顺序的时候就会出问题了,我们来修改一下updateChildrenFromAndTo
方法:
private fun updateChildrenFromAndTo() {
if (Math.abs(mOffsetPercent) >= 1) {
//在这里要重置一下标记
isReordered = false
//其他地方不变
...
} else {
//其他地方不变
...
}
}
好,回到updateChildrenOrder
方法中,可以看到交换顺序的exchangeOrder
方法,也就是我们一开始分析的那段:
private fun exchangeOrder(fromIndex: Int, toIndex: Int) {
//一样的就不用换了
if (fromIndex == toIndex) {
return
}
//先获取引用
val from = this[fromIndex]
val to = this[toIndex]
//分离出来
detachViewFromParent(from)
detachViewFromParent(to)
//重新放回去,但是index互换了
attachViewToParent(from, if (toIndex > childCount) childCount else toIndex, from.layoutParams)
attachViewToParent(to, if (fromIndex > childCount) childCount else fromIndex, to.layoutParams)
//通知重绘,刷新视图
invalidate()
}
还有最后的updateChildrenAlphaAndScale
,这个逻辑也有点复杂,要仔细看注释:
private fun updateChildrenAlphaAndScale() {
//遍历子View
for (i in 0 until childCount) {
updateAlphaAndScale(this[i])
}
}
private fun updateAlphaAndScale(child: View) {
val lp = child.layoutParams as LayoutParams
//用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
when (lp.from) {
//最左边的子View
0 -> when (lp.to) {
//如果它目的地是最右边的话
1 -> {
//要把它放在最底,为了避免在移动过程中遮挡其他子View
setAsBottom(child)
//透明度和缩放比例都不用变,因为现在就已经满足条件了
}
//如果它要移动到中间
2 -> {
//根据滑动比例来计算出当前的透明度和缩放比例
lp.alpha = mMinAlpha + (1F - mMinAlpha) * mOffsetPercent
lp.scale = mMinScale + (1F - mMinScale) * mOffsetPercent
}
}
//最右边的子View
1 -> when (lp.to) {
0 -> {
//把它放在最底,避免在移动过程中遮挡其他子View
setAsBottom(child)
//透明度和缩放比例都不用变
}
2 -> {
//这里跟上面唯一不同的地方就是mOffsetPercent要取负的
//因为它向中间移动的时候,mOffsetPercent是负数,这样做就刚好抵消
lp.alpha = mMinAlpha + (1F - mMinAlpha) * -mOffsetPercent
lp.scale = mMinScale + (1F - mMinScale) * -mOffsetPercent
}
}
//中间的子View
2 -> {
//这里不用判断to了,因为无论向哪一边滑动,不透明度和缩放比例都是减少
lp.alpha = 1F - (1F - mMinAlpha) * Math.abs(mOffsetPercent)
lp.scale = 1F - (1F - mMinScale) * Math.abs(mOffsetPercent)
}
}
}
setAsBottom
方法也就是把目标子View的索引跟索引0交换顺序:
private fun setAsBottom(child: View) {
//获取child索引后跟0交换层级顺序
exchangeOrder(indexOfChild(child), 0)
}
还记不记得我们刚刚重写onLayout
方法的时候,有个获取基准线的方法(getBaselineByChild
),里面返回的数值是写死的?
我们现在还要改一下它,改成根据滑动百分比(mOffsetPercent
)来动态计算基准线:
private fun getBaselineByChild(child: View): Int {
//左边View的初始基准线
val baseLineLeft = width / 4
//中间的
val baseLineCenter = width / 2
//右边的
val baseLineRight = width - baseLineLeft
var baseLine = 0
val lp = child.layoutParams as LayoutParams
//用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
when (lp.from) {
//左边的子View
0 -> baseLine = when (lp.to) {
//目的地是1,证明手指正在向左滑动,所以下面的mOffsetPercent是用负的
//当前基准线 = 初始基准线 + 与目标基准线(现在是右边)的距离 * 滑动百分比
1 -> baseLineLeft + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
//如果目的地是中间(2),那目标基准线就是ViewGroup宽度的一半了(baseLineCenter),计算方法同上
2 -> baseLineLeft + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
else -> baseLineLeft
}
//右边的子View
1 -> baseLine = when (lp.to) {
//原理同上
0 -> baseLineRight + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
2 -> baseLineRight + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
else -> baseLineRight
}
//中间的子View
2 -> baseLine = when (lp.to) {
//原理同上
0 -> baseLineCenter + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
1 -> baseLineCenter + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
else -> baseLineCenter
}
}
return baseLine
}
好啦,来看看现在的效果是怎么样的:
哈哈哈,差不多了,接下来我们处理一下手指松开的事件:当手指松开后,要播放选中动画。
终于来到简单的部分了,我们先改一下onTouchEvent
和onInterceptTouchEvent
方法,在ACTION_UP
里面加上一个handleActionUp
方法:
override fun onTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_UP -> {
...
handleActionUp(x, y)
}
}
...
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_UP -> {
...
handleActionUp(x, y)
}
}
...
}
private fun handleActionUp(x: Float, y: Float) {
playFixingAnimation()
}
可以看到handleActionUp
里面先是直接调用了playFixingAnimation
方法:
private fun playFixingAnimation() {
//没有子View还播放什么动画,出去
if (childCount == 0) {
return
}
//起始点,就是当前的滑动距离
val start = mOffsetX
//结束点
val end = when {
//如果滑动的距离超过了宽度的一半,那么就顺势往那边走下去
//如果滑动百分比是正数,表示是向右滑了>50%,所以目的地就是宽度
mOffsetPercent > .5F -> width.toFloat()
//相反,如果是负数,那就拿负的宽度
mOffsetPercent < -.5F -> -width.toFloat()
//如果滑动没超过50%,那就把距离变成0,也就是回退了
else -> 0F
}
startValueAnimator(start, end)
}
private fun startValueAnimator(start: Float, end: Float) {
if (start == end) {
//起始点和结束点一样,那还播放什么动画,出去
return
}
//先打断之前的动画,如果正在播放的话
abortAnimation()
//创建动画对象
mAnimator = with(ValueAnimator.ofFloat(start, end)){
//指定动画时长
duration = mFlingDuration
//监听动画更新
addUpdateListener { animation ->
val currentValue = animation.animatedValue as Float
//更新滑动距离
mOffsetX = currentValue
//处理子View的移动行为
onItemMove()
}
//开始动画
start()
this
}
}
private fun abortAnimation() {
mAnimator?.let { if (it.isRunning) it.cancel() }
}
创建动画对象那里,对于还没开始学习Kotlin的同学可能会觉得很陌生,那个with
看起来好像是跟java中的switch、if、else这些关键字一样
其实不是,它也是一个方法:接收一个对象,然后返回一个对象,不难看出,我们传进去的是一个ValueAnimator对象,后面紧跟的{ }先忽略,直接看里面的内容:它一开始的赋值,和接下来的调用addUpdateListener
和start
方法,都是ValueAnimator的,也就是说,在Kotlin中,使用这个with
方法之后,后面的lambda可以直接访问with
参数里面的属性和方法,而不用指定对象(xxx.),最后的this,就是返回传进去的这个对象。
那个abortAnimation
方法,其实等于java的:
private void abortAnimation() {
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
}
好,接着,当动画未播放完成时,接收到手指按下的事件,也应该要停止播放动画,所以我们应该在onTouchEvent
和onInterceptTouchEvent
方法也加上abortAnimation
:
override fun onTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_DOWN -> {
//开始滑动前先打断动画
abortAnimation()
...
}
}
...
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
...
when (event.action) {
...
MotionEvent.ACTION_DOWN -> {
//开始滑动前先打断动画
abortAnimation()
...
}
}
...
}
好啦,来看看效果:
可以可以,接下来到最后一个功能了。
一开始我们分析的时候,发现网易云的点击两边的子View会切换位置,而不是触发它们的点击事件,为了不使用反射也是费了一番功夫,那么现在就把那个解决方案应用到这里来吧。
既然是点击事件,那么还要判断是不是点击事件,要怎么判断呢?
mDownX, mDownY
);mTouchSlop
),就认为是一次点击事件;ACTION_UP
,即播放选中动画;好,首先来改造一下handleActionUp
方法:
private fun handleActionUp(x: Float, y: Float): Boolean {
val offsetX = x - mDownX
val offsetY = y - mDownY
//判断是否点击手势
if (Math.abs(offsetX) < mTouchSlop && Math.abs(offsetY) < mTouchSlop) {
//查找被点击的子View
val hitView = findHitView(x, y)
if (hitView != null) {
return if (indexOfChild(hitView) == 2) {
//点击第一个子view不用播放动画,直接不拦截
false
} else {
val lp = hitView.layoutParams as LayoutParams
setSelection(lp.from)
//拦截ACTION_UP事件,内部消费
true
}
}
}
//手指在空白地方松开
playFixingAnimation()
return false
}
接下来看看findHitView
如何找到当前手指所在的子View:
private fun findHitView(x: Float, y: Float): View? {
//从顶层的子View开始递减遍历
for (index in childCount - 1 downTo 0) {
val child = this[index]
//判断触摸点是否在这个子View内
if (pointInView(child, floatArrayOf(x, y))) {
//如果在就直接返回它
return child
}
}
//没有找到,返回null
return null
}
最后是一个setSelection
方法:
fun setSelection(index: Int) {
//目标index已被选中、无子View、正在播放动画,这几种情况下,都直接忽略,即不播放本次动画
if (indexOfChild(this[childCount - 1]) == index || childCount == 0 || mAnimator?.isRunning) {
return
}
//起始点就是当前滑动距离
val start = mOffsetX
//结束点
val end = when (index) {
//如果要选中0,即左边的子View,那么就要向右移动
0 -> width.toFloat()
//反之,如果是1(右边的子View),则向左移动,也就是负数了
1 -> -width.toFloat()
else -> return
}
//开始播放动画
startValueAnimator(start, end)
}
看看效果:
哈哈哈,可以啦~
发下最终的效果图:
那个切换方向,还有指定最大缩放比例、最大不透明度就当留给同学们的作业啦,在Github上有源码。