emmmm,从效果上来看呢,其实也只是基本的Translation和Rotation组合而已,难点是在于惯性移动时,那个角度的变化(好像它QQ空间的还有bug: 向右上角扔的时候那卡片还会闪一下,哈哈哈),接下来我们就一步步分析,从而打造出属于我们的自己的效果。
再仔细观察下,有没有发现:
在开始拖动的时候,如果手指是偏向View的左边按下,那么向上移动是顺时针旋转,向下则逆时针。反之,如果手指是在偏右边的位置按下的话,那么向上移动就是逆时针,向下则顺时针;
在水平拖动的时候,可以看到View的旋转角度是基本没有变化的(小变化是因为Y轴的偏差),那么我们可以断定,X轴变化的时候,是不影响旋转角度的,只有Y轴变化才有效;
在手指松开的时候,如果有滑动速率的话,会惯性移动一段距离。相反,如果没有滑动速率的话(或低于某个阀值),那么这个View会播放一个位移动画,动画的目标位置是根据上下左右四个方向的已滑动距离的多少来决定的;
那么,View在旋转时,需要一个旋转基点,也就是PivotX和PivotY,这个点默认情况下在View的中心。但很明显,它这个就不是在中心了,至于在哪里,先看下这张图:
可以看到,无论View怎么旋转,手指按下的点在View上的位置都基本是不变的,也就说明旋转基点就在触摸点的位置上了。
好,现在我们基本分析的差不多了,下面开始构思代码。
3构思代码
大多数情况下,当我们要做一个View跟随手指移动的效果时,都是直接setOnTouchListener或者直接重写onTouchEvent去实现的。
但这种方式用在我们即将要做的这个效果上,就很不合适了,因为我们是要做到可以作用在任意一个View上的,这样一来,如果目标View本来就已经重写了OnTouchEvent或者设置了OnTouchListener,就很可能会滑动冲突,而且还非常不灵活,这个时候,使用自定义ViewGroup的方式是最佳选择:
自定义ViewGroup的话,能直接套在任意一个View上,使用起来非常方便,而且不需要再做任何操作,就能正常运行。
我们应该控制一下直接子View的数量为1,这样的话,就不用考虑如何排版的问题:因为只有一个子View,在布局的时候只需要处理一下子View的margin,宽高可以直接参照子View的尺寸(如果没有指定宽高的话)
接下来到触摸事件的处理,这个只需要注意一点,就是在开始了拖动之后,要防止父布局拦截事件。
至于子View的旋转与移动,如果是直接通过setRotation、setTranslation、layout、offsetTopAndBottom等一系列方法直接改变View属性的话,考虑到视图层级关系,可能会出现被其他View遮挡的现象,还有,随着手指不断地移动,很大几率会移动超出了View自身的边界,导致内容显示不全。
这时候可能有同学会说:“咦,这个问题不是可以通过设置clipToPadding属性来解决吗?”
**不行,用这个方法不靠谱的,你怎么保证他滑动不会超出设置了clipToPadding属性的那个ViewGroup的范围? **
“我可以递归设置,一直到最顶级的ViewGroup”
好主意!那我们来试试吧:
setClipToPadding(false);
setClipChildren(false);
ViewParent parent = getParent();while (parent instanceof ViewGroup) {
ViewGroup vg = (ViewGroup) parent;
vg.setClipChildren(false);
vg.setClipToPadding(false); parent = vg.getParent();
}咦?怎么回事?我的RecyclerView怎么变成这样了?
哈哈哈,像现在这样,就伤及无辜了,所以这种方式是不可取的。
那现在用哪种方法,既可以解决层级关系的问题,又能避免超出父布局边界的情况呢?
还记不记得上次的主题切换动画的实现?
那个思路也能用到这里来:在动画开始前给DecorView添加一个View,并在这个View上去应用动画,这样就能覆盖到整个Activity甚至StatusBar和NavigationBar。
不错,现在思路也蛮清晰的了:
在拖动事件触发时,先把一个透明的View添加到DecorView上,在上面draw子View的内容,并隐藏真实的View,再根据当前触摸点的位置计算旋转角度。
当手指继续移动,这时只需要update坐标值以及重新计算旋转角度就行了。
当手指松开,我们可以借助VelocityTracker来计算滑动速率,然后配合Scroller进行惯性移动,或通过ValueAnimator直接播放位移动画。
当惯性移动结束,或者是位移动画播放完毕,这时候应该把刚添加的View从DecorView中移除掉。
其实在这个新的View每次draw的时候,我们都应该判断它所draw的内容是否已经完全超出了屏幕的范围,并立即作出反应,因为在屏幕范围之外去draw是没有意义的,还有一个理由就是:如果滑动速率很大时,内容可能在100毫秒内就已经滑到屏幕外面,看不见了,但是Scroller那边还没有滚动完成(因为我们刚刚的想法是: “在Scroller滚动结束后才移除那个View”)这样无疑会造成不必要的等待,正确的做法应该是:在内容完全超出屏幕边界时,也要移除掉那个View。
那么问题来了,怎么判断内容是否超出屏幕范围呢?
有的同学会说: “直接用内容Bitmap的left, top, right, bottom与View的left, top, right, bottom作比较”。
没错,大概就是这样,但是还不够,因为在上下滑动时会发生旋转,它一旦旋转了,原来的边界数据就不对了,举个例子,比如说旋转了60度:
image
很明显,现在这个Bitmap在屏幕上所占据的宽高跟使用getWidth(),getHeight()方法获取到的宽高值是不同的,那要怎样才能得到这个旋转后的尺寸呢?
还记得上次我们分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件的吗?
https://blog.csdn.net/u011387817/article/details/80313184
在ViewGroup中的transformPointToViewLocal方法内可以看到这段代码:
if (!child.hasIdentityMatrix()) {
child.getInverseMatrix().mapPoints(point);
}如果child所对应的矩阵发生过旋转、缩放等变化的话(补间动画不算,因为是临时的),会通过矩阵的mapPoints方法来将触摸点转换到矩阵变换后的坐标。
没错,我们也可以用矩阵的mapRect方法来将内容Bitmap的坐标及尺寸转换一下,就像这样:
哈哈,转换之后,我们就可以准确地判断内容是否超出屏幕边界了。
好了,接下来我们看看它那个旋转的角度是如何计算的,有什么规律:
仔细看看开头那段分析:在开始拖动的时候,如果手指是偏向View的左边按下,那么向上移动是顺时针旋转,向下则逆时针。反之,如果手指是在偏右边的位置按下的话,那么向上移动就是逆时针,向下则顺时针;
哈哈,这个是不是有点像我们上次做的那个圆弧滑动的行为?
没错,在拖动时,我们可以从View原始位置的中心点(起始点) 连一条线到当前触摸点(结束点) 并计算出角度,这个角度就刚好是View需要旋转的角度。
手指松开之后(有滑动速率),每次位置更新时,都跟着去更新这个起始点,也就是上面经过矩阵mapRect方法转换坐标后的矩形的中心点。
看这张图:
emmm,整篇文章的重点就在这里了,可以看到,在手指还没松开的时候,蓝色点(起始点)的位置是不变的(所在就是View原始位置的中心点),当手指松开后,这个蓝色的点就移动到了蓝色矩形的中心,并一直跟随着更新位置。正是因为这样,我们在甩出去的一瞬间,才能看到一个像圆弧滑动的效果。
当移动了一段距离之后,可以看到不再旋转了,是因为后面这段距离的移动是在同一个方向上一直走,哈哈,希望大家也能像这个View一样,在确定好方向后一直走下去~
好,现在从手指按下到手指松开的思路都已经有了,来总结一下整个过程:
当触发了拖动事件时: 把一个透明的View添加到Activity的最顶层视图中,然后把对应的子View内容draw上去,再隐藏该子View;
当手指继续拖动时: 根据当前触摸点位置和View的原始位置中心点计算出对应的旋转角度,并应用到最顶层View的Canvas中;
当手指松开: 借助VelocityTracker获得滑动速率,如果速率大于指定值,则判定为 “甩”,并通过Scroller来进行惯性移动,每次坐标位置更新时,顺便更新计算旋转角度的起始点位置;
如手指松开后滑动速率低于指定值,则视为 “放手”,这时候需要通过ValueAnimator来配合位移,动画目标落点的计算方式为:当前触摸点在上下左右四个方向中,偏移得最大的一方 + 随机的偏移量;
当动画播放完毕或Scroller滚动完成或者View内容超出屏幕时: 移除最顶层View,并回调监听器,更新状态;
嗯,整个过程的大致行为就是这样了。
开工写代码咯~
4起名字
在开始写代码之前,要先给这个自定义ViewGroup起一个接地气的名字,
就叫:任意拖布局(RandomDragLayout) 吧。
还要自定义一下那个被添加在最顶层的View(绘制和角度计算等任务都是在这个View里面去处理),名字就叫GhostView好了。
开始编写代码!
5计算旋转角度
先来看看怎么正确计算两个点的旋转角度(顺时针):
我们把蓝点作为起始点,红点作为结束点,将起始点和结束点连线(把矩形切分成了两个直角三角形),因为计算的是顺时针的角度,那就要找逆时针方向上的那一个三角形,可以看到,图中的四个红点(结束点)分别在四个不同的象限上:
当结束点在第四象限或第一象限时,我们要计算的角是斜边和水平辅助线的夹角;
当结束点在第二或第三象限时,要计算的角则是斜边与垂直辅助线的夹角;
好,我们可以把水平辅助线当作lineA,垂直辅助线作lineB,斜边当作lineC,因为lineA和lineB的长度都能直接算出来,那么根据勾股定理: a² + b² = c² 可得出lineC的长度。接着,求夹角,如果是在第四象限或第一象限,根据余弦定理,即cosB = lineA / lineC,如果是第二或第三象限则:cosA = lineB / lineC,接着用Math.acos函数得出反余弦值(弧度),再通过Math.toDegrees将弧度转为角度,当然了,最后别忘记加上基本角度(即: 第三象限要加上90,第一象限要加上180,第二象限+270),来看看代码怎么写:
/**
* 计算两个坐标点的顺时针角度,以第一个坐标点为圆心
*
* @param startX 起始点X轴的值
* @param startY 起始点Y轴的值
* @param endX 结束点X轴的值
* @param endY 结束点Y轴的值
* @return 以起始点为旋转中心计算的顺时针角度
*/private float computeClockwiseAngle(float startX, float startY, float endX, float endY) { //需要追加的角度
int appendAngle = computeNeedAppendAngle(startX, startY, endX, endY); //线条长度
float lineA = Math.abs(endX - startX); float lineB = Math.abs(endY - startY); //lineC = √ ̄ lineA² + lineB²
float lineC = (float) Math.sqrt(Math.pow(lineA, 2) + Math.pow(lineB, 2)); float angle; //如果是第一象限或第四象限,则计算斜边和水平线的夹角
if (appendAngle == 0 || appendAngle == 180) { //cosB = lineA / lineC
angle = (float) Math.toDegrees(Math.acos(lineA / lineC));
} else {//如果是第二,第三象限,则计算斜边和垂直线的夹角
//cosA = lineB / lineC
angle = (float) Math.toDegrees(Math.acos(lineB / lineC));
} //加上需要追加的角度
return angle + appendAngle;
}/**
* 根据两点的位置来判断从起始点到结束点连线后的象限,并返回对应的角度
*
* @param startX 起始点X轴的值
* @param startY 起始点Y轴的值
* @param endX 结束点X轴的值
* @param endY 结束点Y轴的值
* @return 对应象限的顺时针基础角度
*/private int computeNeedAppendAngle(float startX, float startY, float endX, float endY) { int needAppendAngle; //2 or 4
if (endX > startX) { if (endY > startY) { //4
needAppendAngle = 0;
} else { //2
needAppendAngle = 270;
}
} //1 or 3
else { if (endY > startY) { //3
needAppendAngle = 90;
} else { //1
needAppendAngle = 180;
}
} return needAppendAngle;// return (endX > startX) ? (endY > startY ? 0 : 270) : (endY > startY ? 90 : 180);}看看效果:
哈哈,可以看到,无论手指在哪个位置按下,在拖动时,都能准确地计算出旋转角度。
6创建GhostView
好,那我们来看看GhostView应该怎么写:
先是成员变量:
private Bitmap mBitmap;//内容Bitmapprivate float mDownX, mDownY;//手指按下时的坐标private float mDownRawX;//手指按下时,在屏幕上的绝对X值private float mBitmapCenterX, mBitmapCenterY;//Bitmap的中心点private float mCurrentRawX, mCurrentRawY;//当前手指在屏幕上的绝对坐标点private float mStartAngle;//手指按下的角度private float mCurrentAngle;//当前角度private boolean isLeanLeft;//手指按下时,是否偏向View的左边private Matrix mMatrix;//应用旋转的矩阵private RectF mBitmapRect;//Bitmap的边界(通过mapRect映射后的矩形)private OnOutOfScreenListener mOnOutOfScreenListener;
再到构造方法,方法参数的话,Context是不用说了,我们还要加一个内容超出屏幕的监听器:
GhostView(Context context, OnOutOfScreenListener listener) { super(context);
mOnOutOfScreenListener = listener;
mMatrix = new Matrix();
mBitmapRect = new RectF();
}interface OnOutOfScreenListener { /**
* 当Canvas的内容全部draw在View的边界外面时回调此方法
*
* @param view 发生事件所对应的View
*/
void onOutOfScreen(GhostView view);
}好了,在触发拖拽事件时,我们需要对一些数值进行初始化,比如说手指按下的坐标值,初始角度等等:
/**
* 当此方法被调用时,表示已经开始了拖动
*
* @param event 触摸事件
* @param bitmap View所对应的Bitmap
*/void onDown(MotionEvent event, Bitmap bitmap) { //当前手指在屏幕中的绝对坐标值
mCurrentRawX = mDownRawX = event.getRawX();
mCurrentRawY = event.getRawY(); //在View内的坐标值
mDownX = event.getX();
mDownY = event.getY(); //计算出Bitmap的Left值和Top值
float l = mCurrentRawX - mDownX, t = mCurrentRawY - mDownY; //根据Bitmap的Left和Top分别得出Bitmap的中心点位置
mBitmapCenterX = l + bitmap.getWidth() / 2F;
mBitmapCenterY = t + bitmap.getHeight() / 2F; //根据手指当前位置与Bitmap中心点位置计算出旋转角度
mStartAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY,
mCurrentRawX, mCurrentRawY); //Bitmap宽度的一半
float halfWidth = bitmap.getWidth() / 2F; //如果手指在View内的X值小于Bitmap宽度的一半,那么手指的位置就是在View的左边
isLeanLeft = mDownX < halfWidth;
mBitmap = bitmap; //通知draw一下
invalidate();
}
作者:Android开发架构
链接:https://www.jianshu.com/p/cf0223f3412d
共同学习,写下你的评论
评论加载中...
作者其他优质文章