Android自定义ViewGroup第十二式之年年有鱼

前言

先来看两张效果图:

哈哈,就是这样了。

前段时间在鸿神的群里看到有群友截了一张QQ空间的图,问它那个是怎么实现的: 在好友动态的列表中多了个Header,这个Header有一叠卡片的效果,上面的卡片都可以跟随手指移动,还可以扔走,拖拽时,还有一个有趣的效果,就是卡片像是被一种无形的东西吸住一样,扔出去的时候,卡片还会根据当前前进的方向来调整角度。。。然后我自己打开QQ空间想看下这个效果,却死活刷不出来,本来想放弃了,有一天无聊打开了QQ空间,却发现这个效果出来了,于是我赶紧录了几个gif,果不其然,过了几天后就一直没出现过。


初步分析

来看看它原来的样子:

emmmm,从效果上来看呢,其实也只是基本的Translation和Rotation组合而已,难点是在于惯性移动时,那个角度的变化(好像它QQ空间的还有bug: 向右上角扔的时候那卡片还会闪一下,哈哈哈),接下来我们就一步步分析,从而打造出属于我们的自己的效果。

再仔细观察下,有没有发现:

  • 在开始拖动的时候,如果手指是偏向View的左边按下,那么向上移动是顺时针旋转,向下则逆时针。反之,如果手指是在偏右边的位置按下的话,那么向上移动就是逆时针,向下则顺时针;

  • 在水平拖动的时候,可以看到View的旋转角度是基本没有变化的(小变化是因为Y轴的偏差),那么我们可以断定,X轴变化的时候,是不影响旋转角度的,只有Y轴变化才有效;

  • 在手指松开的时候,如果有滑动速率的话,会惯性移动一段距离。相反,如果没有滑动速率的话(或低于某个阀值),那么这个View会播放一个位移动画,动画的目标位置是根据上下左右四个方向的已滑动距离的多少来决定的;

那么,View在旋转时,需要一个旋转基点,也就是PivotX和PivotY,这个点默认情况下在View的中心。但很明显,它这个就不是在中心了,至于在哪里,先看下这张图:

可以看到,无论View怎么旋转,手指按下的点在View上的位置都基本是不变的,也就说明旋转基点就在触摸点的位置上了。

好,现在我们基本分析的差不多了,下面开始构思代码。


构思代码

大多数情况下,当我们要做一个View跟随手指移动的效果时,都是直接setOnTouchListener或者直接重写onTouchEvent去实现的,但这种方式用在我们即将要做的这个效果上,就很不合适了,因为我们是要做到可以作用在任意一个View上的,这样一来,如果目标View本来就已经重写了OnTouchEvent或者设置了OnTouchListener,就很可能会滑动冲突,而且还非常不灵活,这个时候,使用自定义ViewGroup的方式是最佳选择:

  1. 自定义ViewGroup的话,能直接套在任意一个View上,使用起来非常方便,而且不需要再做任何操作,就能正常运行。

  2. 我们应该控制一下直接子View的数量为1,这样的话,就不用考虑如何排版的问题:因为只有一个子View,在布局的时候只需要处理一下子View的margin,宽高可以直接参照子View的尺寸(如果没有指定宽高的话)

  3. 接下来到触摸事件的处理,这个只需要注意一点,就是在开始了拖动之后,要防止父布局拦截事件。

  4. 至于子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度:

Android自定义ViewGroup第十二式之年年有鱼_第1张图片
很明显,现在这个Bitmap在屏幕上所占据的宽高跟使用getWidth(),getHeight()方法获取到的宽高值是不同的,那要怎样才能得到这个旋转后的尺寸呢?
还记得上次我们分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件的吗?

在ViewGroup中的transformPointToViewLocal方法内可以看到这段代码:

    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }

如果child所对应的矩阵发生过旋转、缩放等变化的话(补间动画不算,因为是临时的),会通过矩阵的mapPoints方法来将触摸点转换到矩阵变换后的坐标。

没错,我们也可以用矩阵的mapRect方法来将内容Bitmap的坐标及尺寸转换一下,就像这样:

哈哈,转换之后,我们就可以准确地判断内容是否超出屏幕边界了。

好了,接下来我们看看它那个旋转的角度是如何计算的,有什么规律:
仔细看看开头那段分析:在开始拖动的时候,如果手指是偏向View的左边按下,那么向上移动是顺时针旋转,向下则逆时针。反之,如果手指是在偏右边的位置按下的话,那么向上移动就是逆时针,向下则顺时针;
哈哈,这个是不是有点像我们上次做的那个圆弧滑动的行为?

没错,在拖动时,我们可以从View原始位置的中心点(起始点) 连一条线到当前触摸点(结束点) 并计算出角度,这个角度就刚好是View需要旋转的角度。

手指松开之后(有滑动速率),每次位置更新时,都跟着去更新这个起始点,也就是上面经过矩阵mapRect方法转换坐标后的矩形的中心点。
看这张图:

emmm,整篇文章的重点就在这里了,可以看到,在手指还没松开的时候,蓝色点(起始点)的位置是不变的(所在就是View原始位置的中心点),当手指松开后,这个蓝色的点就移动到了蓝色矩形的中心,并一直跟随着更新位置。正是因为这样,我们在甩出去的一瞬间,才能看到一个像圆弧滑动的效果。
当移动了一段距离之后,可以看到不再旋转了,是因为后面这段距离的移动是在同一个方向上一直走,哈哈,希望大家也能像这个View一样,在确定好方向后一直走下去~

好,现在从手指按下到手指松开的思路都已经有了,来总结一下整个过程:

  1. 当触发了拖动事件时: 把一个透明的View添加到Activity的最顶层视图中,然后把对应的子View内容draw上去,再隐藏该子View;

  2. 当手指继续拖动时: 根据当前触摸点位置和View的原始位置中心点计算出对应的旋转角度,并应用到最顶层View的Canvas中;

  3. 当手指松开: 借助VelocityTracker获得滑动速率,如果速率大于指定值,则判定为 “甩”,并通过Scroller来进行惯性移动,每次坐标位置更新时,顺便更新计算旋转角度的起始点位置;

  4. 如手指松开后滑动速率低于指定值,则视为 “放手”,这时候需要通过ValueAnimator来配合位移,动画目标落点的计算方式为:当前触摸点在上下左右四个方向中,偏移得最大的一方 + 随机的偏移量;

  5. 当动画播放完毕或Scroller滚动完成或者View内容超出屏幕时: 移除最顶层View,并回调监听器,更新状态;

嗯,整个过程的大致行为就是这样了。

开工写代码咯~


起名字

在开始写代码之前,要先给这个自定义ViewGroup起一个接地气的名字,
就叫:任意拖布局(RandomDragLayout) 吧。
还要自定义一下那个被添加在最顶层的View(绘制和角度计算等任务都是在这个View里面去处理),名字就叫GhostView好了。


编写代码

计算旋转角度

先来看看怎么正确计算两个点的旋转角度(顺时针):
Android自定义ViewGroup第十二式之年年有鱼_第2张图片
我们把蓝点作为起始点,红点作为结束点,将起始点和结束点连线(把矩形切分成了两个直角三角形),因为计算的是顺时针的角度,那就要找逆时针方向上的那一个三角形,可以看到,图中的四个红点(结束点)分别在四个不同的象限上:

  • 当结束点在第四象限或第二象限时,我们要计算的角是斜边和水平辅助线的夹角;
  • 当结束点在第一或第三象限时,要计算的角则是斜边与垂直辅助线的夹角;

好,我们可以把水平辅助线当作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;
        //1 or 4
        if (endX > startX) {
            if (endY > startY) {
                //4
                needAppendAngle = 0;
            } else {
                //1
                needAppendAngle = 270;
            }
        }
        //2 or 3
        else {
            if (endY > startY) {
                //3
                needAppendAngle = 90;
            } else {
                //2
                needAppendAngle = 180;
            }
        }
        return needAppendAngle;
//        return (endX > startX) ? (endY > startY ? 0 : 270) : (endY > startY ? 90 : 180);
    }

看看效果:
Android自定义ViewGroup第十二式之年年有鱼_第3张图片
哈哈,可以看到,无论手指在哪个位置按下,在拖动时,都能准确地计算出旋转角度。

创建GhostView

好,那我们来看看GhostView应该怎么写:
先是成员变量:

    private Bitmap mBitmap;//内容Bitmap
    private 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();
    }

在触发拖动的第一时间,RandomDragLayout那边会调用这个onDown方法,bitmap就是子View的图像。
接着到onDraw方法了:

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap != null) {
            //得出原始的l,t,r,b边界值
            float l = mCurrentRawX - mDownX, t = mCurrentRawY - mDownY;
            float r = l + mBitmap.getWidth();
            float b = t + mBitmap.getHeight();

            //应用到这个矩形里面
            mBitmapRect.set(l, t, r, b);

            //旋转操作,旋转中心就是手指的当前位置
            mMatrix.setRotate(mCurrentAngle, mCurrentRawX, mCurrentRawY);
            //映射矩形
            mMatrix.mapRect(mBitmapRect);

            //将进行过旋转操作后的矩阵应用到Canvas里
            canvas.setMatrix(mMatrix);
            //画出内容
            canvas.drawBitmap(mBitmap, l, t, null);

            //检查是否超出边界,如果超出边界则回调监听器
            if (checkIsContentOutOfScreen()) {
                if (mOnOutOfScreenListener != null) {
                    mOnOutOfScreenListener.onOutOfScreen(this);
                }
            }
        }
    }

可以看到,我们先是根据当前点(最新)和起始点(最早)算出了映射前的Bitmap边界,然后应用到mBitmapRect里。在mMatrix旋转之后,像前面说的那样,把旋转后的Bitmap位置映射到了这个矩形上,接着把矩阵应用到Canvas里然后drawBitmap,这样draw出来的bitmap就是旋转后的样子了。
最后还调用了checkIsContentOutOfScreen方法,这个就是我们上面说的,根据映射后的矩形位置及尺寸,判断是否在屏幕外面:

    /**
     * 检查Bitmap是否完全draw在屏幕之外
     */
    private boolean checkIsContentOutOfScreen() {
        return mBitmapRect.bottom < 0
                || mBitmapRect.top > getBottom()
                || mBitmapRect.right < 0
                || mBitmapRect.left > getRight();
    }

emmmm,按下的事件处理完,接下来到移动了,这个其实也就更新一下当前触摸坐标值已及根据新的坐标值来重新计算下旋转角度而已:

    /**
     * 更新Bitmap的坐标和旋转角度
     *
     * @param offsetX X轴上新的位置(相对)
     * @param offsetY Y轴上新的位置(相对)
     */
    void updateOffset(float offsetX, float offsetY) {
        //更新坐标值
        mCurrentRawX += offsetX;
        mCurrentRawY += offsetY;
        //更新角度值: 为什么要减去起始角度呢?因为手指按下时的角度不可能每次都是0,
        //这时候移动的话,比如说从90度降到了85度,实际上只是旋转了5度,如果不减去起始角度,
        //在应用旋转时就是85度,这显然是错误的。
        mCurrentAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY,
                mDownRawX, mCurrentRawY) - mStartAngle;
        //通知重绘
        invalidate();
    }

哈哈,其实这里还有个小细节,不知道大家有没有看出来,就是在调用计算角度方法的时候,endX的值不是像endY那样传的mCurrentRawY(当前值),而是传的mDownRawX(初始值),这样的话,因为X轴的值始终不变,那么就能像我们上面说的那样:手指水平拖动不会改变旋转角度。看这张图:
Android自定义ViewGroup第十二式之年年有鱼_第4张图片

可以看到,当手指左右拖动的时候,蓝色线条的端点并不会跟着移动,计算出来的角度,自然也是不变了。

好,ACTION_MOVE处理完,到ACTION_UP了。如果有滑动速率的话,RandomDragLayout那边就要调用Scroller的fling方法来进行惯性移动了,我们也可以在GhostView中用一个isFlinging来记录一下是否已经开始了惯性移动,如果已经开始了的话,就应该更新起始点的位置了,即像上面说的那样: "每次坐标位置更新时,顺便更新计算旋转角度的起始点位置"
我们来改一下上面的updateOffset方法:

    /**
     * 标记已经开始惯性移动
     */
    void setFlinging() {
        isFlinging = true;
    }

    void updateOffset(float offsetX, float offsetY) {
        //更新坐标值
        mCurrentRawX += offsetX;
        mCurrentRawY += offsetY;
        if (isFlinging) {
            //如果已经开始了惯性移动,则每次更新中心点位置
            mBitmapCenterX = mBitmapRect.centerX();
            mBitmapCenterY = mBitmapRect.centerY();
            //解除左右移动不能旋转的束缚
            mDownRawX = mCurrentRawX;
        }
        //更新角度值
        mCurrentAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY,
                mDownRawX, mCurrentRawY) - mStartAngle;
        //通知重绘
        invalidate();
    }

我们在更新角度前,还判断了当前是否处于惯性移动状态,如果是,则更新中心点位置。其次,因为在手指松开之前,endX是传手指按下的值,那么在手指松开后,那个 “左右移动不能旋转” 的限制应该要解除了,可以看到,我们在更新中心点位置的同时,还把当前的X值赋给了mDownRawX。
好,开始惯性移动之前,还需要在RandomDragLayout调用这个方法来更新状态

    /**
     * 标记已经开始惯性移动
     */
    void setFlinging() {
        isFlinging = true;
    }

这样的话,在手指做甩出去的动作时,内容Bitmap就能根据当前前进的方向不断调整角度啦!

那手指松开还有另一种情况,就是没有滑动速率的(低于指定值),那么,这种情况下就需要我们自己去确定落点位置,然后播放一个位移动画,位移的话,肯定需要一个起始点,和一个结束点,我们可以在这里提供给RandomDragLayout那边:
首先是起始点:

    /**
     * 获取位移动画的起点
     *
     * @return 起点位置
     */
    PointF getAnimationStartPoint() {
        return new PointF(mCurrentRawX, mCurrentRawY);
    }

没错,其实这个起始点也就是当前手指的位置了。
那现在来看看结束点怎么计算:

    /**
     * 获取位移动画的终点
     *
     * @return 终点位置
     */
    PointF getAnimationEndPoint() {
        //屏幕一半宽度
        float halfWidth = mBitmapCenterX;
        //屏幕一半高度
        float halfHeight = mBitmapCenterY;
        //以屏幕中心为起点,手指向左边移动相对于屏幕宽度一半的百分比距离
        float leftPercent = 1F - mCurrentRawX / halfWidth;
        //同上,此为向右
        float rightPercent = (mCurrentRawX - halfWidth) / halfWidth;
        //向上
        float topPercent = 1F - mCurrentRawY / halfHeight;
        //向下
        float bottomPercent = (mCurrentRawY - halfHeight) / halfHeight;
        //取其中最大值
        float max = Math.max(Math.max(leftPercent, rightPercent), Math.max(topPercent, bottomPercent));
        //反正一移动出屏幕就会移除View并中断动画,并且我们需要在任何地方的移动速度都不变,所以我们的距离可以指定为屏幕高度 + View高度
        int maxBitmapLength = (int) Math.max(mBitmapRect.width(), mBitmapRect.height());
        float distance = Math.max(getWidth(), getHeight()) + maxBitmapLength;

        //一个随机大小的偏移量,范围: -maxBitmapLength ~ maxBitmapLength
        int offset = -maxBitmapLength + new Random().nextInt(maxBitmapLength * 2);
        float toX, toY;
        //根据手指在四个方向上移动距离最长的那一方作为目标落点方向
        if (max == leftPercent) {
            toX = -distance;
            toY = offset;
            //记录当前方向
            mTargetOrientation = ORIENTATION_LEFT;
        } else if (max == rightPercent) {
            toX = mCurrentRawX + distance;
            toY = offset;
            //记录当前方向
            mTargetOrientation = ORIENTATION_RIGHT;
        } else if (max == topPercent) {
            toX = offset;
            toY = -distance;
            //记录当前方向
            mTargetOrientation = ORIENTATION_TOP;
        } else {
            toX = offset;
            toY = mCurrentRawY + distance;
            //记录当前方向
            mTargetOrientation = ORIENTATION_BOTTOM;
        }
        return new PointF(toX, toY);
    }

恩,就像我们前面构思的一样:“动画目标落点的计算方式为:当前触摸点在上下左右四个方向中,偏移得最大的一方 + 随机的偏移量;”,可以看到初始化toX,toY时,还记录了一下当前的方向。

创建RandomDragLayout

好,是时候创建RandomDragLayout了,继承自ViewGroup,这个不用说。
初始化我们要做点什么呢?
因为等下要把GhostView添加到Activity最顶层视图中,那首先肯定要拿到对应Activity的根视图了:
我们要先根据Context来获取到对应的Activity:

    /**
     * 根据View的Context来获取对应的Activity
     *
     * @return 该View所在的Activity
     */
    private Activity getActivity() {
        Context context = getContext();
        while (context instanceof ContextWrapper) {
            if (context instanceof Activity) {
                return (Activity) context;
            }
            context = ((ContextWrapper) context).getBaseContext();
        }
        throw new RuntimeException("Activity not found!");
    }

再根据这个Activity来获取到DecorView:

    mRootView = (ViewGroup) getActivity().getWindow().getDecorView();

嗯,等下就直接调用mRootView的addView方法把GhostView添加到最顶层了。

好,接下我们需要重写addView方法,来控制子View的数量,使他只能存在一个直接子View:

   /**
     * 重写父类addView方法,仅允许拥有一个直接子View
     */
    @Override
    public void addView(View child, int index, LayoutParams params) {
        if (getChildCount() > 0) {
            throw new IllegalStateException("RandomDragLayout can only contain 1 child!");
        }
        super.addView(child, index, params);
        mChild = child;
    }

为方便接下来的代码编写,可以看到我们在addView的时候,还用了一个成员变量mChild来引用这个唯一的子View。

接下来到onMeasure

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mChild == null) {
            throw new IllegalStateException("RandomDragLayout at least one child is needed!");
        }
        //测量子View
        measureChild(mChild, widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int width, height;
        //如果RandomDragLayout有指定尺寸则使用指定的尺寸,没有指定的话,我们用子View的
        MarginLayoutParams layoutParams = (MarginLayoutParams) mChild.getLayoutParams();
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width = mChild.getMeasuredWidth() + layoutParams.leftMargin + layoutParams.rightMargin;
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            height = mChild.getMeasuredHeight() + layoutParams.topMargin + layoutParams.bottomMargin;
        }
        setMeasuredDimension(width, height);
    }

这个比较好理解,如果RandomDragLayout设置的是wrap_content,则使用子View的尺寸。
好,接下来到onLayout了:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        MarginLayoutParams layoutParams = (MarginLayoutParams) mChild.getLayoutParams();
        mChild.layout(getPaddingLeft() + layoutParams.leftMargin, getPaddingTop() + layoutParams.topMargin,
                mChild.getMeasuredWidth() - getPaddingRight() + layoutParams.leftMargin,
                mChild.getMeasuredHeight() - getPaddingBottom() + layoutParams.topMargin);
    }

onLayout方法非常简单,因为只有一个子View,我们不用多余的逻辑,就处理了一下Padding和Margin。

好了,现在开始处理触摸事件了,考虑到子View也有它自己的点击,长按之类的事件,所以我们还需要重写onInterceptTouchEvent方法,在手指按下并移动了一定的距离之后,才触发我们的拖拽效果:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        //不可用
        if (!isEnabled()) {
            return false;
        }
        if ((event.getAction() == MotionEvent.ACTION_MOVE && isBeingDragged) || super.onInterceptTouchEvent(event)) {
            //如果已经开始了拖动,则继续占用此次事件
            requestDisallowInterceptTouchEvent(true);
            return true;
        }
        float x = event.getX(), y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //记录手指按下的坐标
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                float offsetX = x - mLastX;
                float offsetY = y - mLastY;
                //判断是否触发拖动事件,垂直或水平移动距离大于mTouchSlop触发
                if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) {
                    mLastX = x;
                    mLastY = y;
                    //标记已经开始拖拽
                    isBeingDragged = true;
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_OUTSIDE:
                //手指松开后,要重置拖拽状态
                isBeingDragged = false;
                break;
        }
        //如果已经开始了拖拽,则禁止父布局拦截接下来的事件,反之,允许
        requestDisallowInterceptTouchEvent(isBeingDragged);
        return isBeingDragged;
    }

mTouchSlop是通过:

ViewConfiguration.get(context).getScaledTouchSlop();

来获得。

好了,拦截到了事件之后,开始处理了,我们来看onTouchEvent

    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX(), y = event.getY();
        mVelocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                handleActionMove(event, x, y);
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_OUTSIDE:
                handleActionUp();
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

细心的同学会发现,ACTION_DOWN居然跟ACTION_MOVE是同样的行为,为什么呢?
因为触摸事件在经过onInterceptTouchEvent方法之后,如果View有设置自己的点击事件时,那么需要移动一小段距离才会触发拦截,这时候,事件动作已经不是ACTION_DOWN,而是ACTION_MOVE了,所以我们还要在处理ACTION_MOVE的时候,判断GhostView是否已经添加了,这个可以用一个isGhostViewShown来记录:

    /**
     * 处理 ACTION_MOVE 事件
     */
    private void handleActionMove(MotionEvent event, float x, float y) {
        if (isGhostViewShown) {
            //如果GhostView已经在显示的话,直接更新坐标
            mGhostView.updateOffset(x - mLastX, y - mLastY);
        } else {
            //如果还没添加,先把子View显示的东西都draw到mBitmap上
            mChild.draw(mCanvas);
            //隐藏真实的View
            mChild.setVisibility(INVISIBLE);
            //初始化GhostView
            initializeGhostView();
            //添加到根视图,宽高=屏幕尺寸
            mRootView.addView(mGhostView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
            //回调GhostView的onDown方法,表示已经开始了拖拽
            mGhostView.onDown(event, mBitmap);
            //标记一下状态
            isGhostViewShown = true;
        }
    }

    /**
     * 初始化GhostView
     */
    private void initializeGhostView() {
        //防止重复添加
        if (mGhostView != null) {
            mRootView.removeView(mGhostView);
        }
        mGhostView = new GhostView(getContext(), new GhostView.OnOutOfScreenListener() {
            @Override
            public void onOutOfScreen(GhostView view) {
                //当GhostView内容超出屏幕后,打断惯性动画
                mScroller.abortAnimation();
            }
        });
    }

可以看到在初始化GhostView之前,调用了mChild的draw方法来将子View的图像保存在mBitmap上,而这个mCanvas和mBitmap的初始化,我们选择在onSizeChanged里面初始化:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (w > 0 && h > 0) {
            //更新画布尺寸
            mCanvas = new Canvas(mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888));
        }
    }

好,现在来看看ACTION_UP怎么处理的:

    /**
     * 处理 ACTION_UP 事件
     */
    private void handleActionUp() {
        //ACTION_UP时,可能GhostView没有被添加,
        //这个事件可能是从子View传回来的,所以这里需要做非空判断
        if (mGhostView != null) {
            //标记状态:已经不是在拖拽中了
            isBeingDragged = false;
            //计算当前滑动速率
            mVelocityTracker.computeCurrentVelocity(1000);
            float xVelocity = mVelocityTracker.getXVelocity();
            float yVelocity = mVelocityTracker.getYVelocity();
            //X轴和Y轴其中一个的滑动速率超过500,则视为有滑动速率,这时候要进行惯性移动
            if (Math.abs(xVelocity) > 500 || Math.abs(yVelocity) > 500){
                startFling(xVelocity, yVelocity);
            } else {
                //否则播放位移动画
                startAnimator();
            }
        }
    }

    /**
     * 开始惯性移动
     */
    private void startFling(float xVelocity, float yVelocity) {
        //先标记开始惯性移动
        mGhostView.setFlinging();
        //开始
        mScroller.fling(0, 0, (int) xVelocity, (int) yVelocity,
                Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        //使其回调computeScroll
        invalidate();
    }

emmmm,逻辑挺简单的,就是判断一下当前的滑动速率,超过500(当然了,这里不应该写死的),就开启惯性滚动,小于500则播放动画,这个做法跟前面的思路是一样的。
我们还需要重写computeScroll方法,并在这里面去处理惯性移动的逻辑:

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            float y = mScroller.getCurrY();
            float x = mScroller.getCurrX();
            //更新坐标
            mGhostView.updateOffset(x - mLastScrollOffsetX, y - mLastScrollOffsetY);
            //更新上一次的坐标值
            mLastScrollOffsetX = x;
            mLastScrollOffsetY = y;
            //继续通知回调
            invalidate();
        } else if (mScroller.isFinished()) {
            if (mRootView != null) {
                //防止报:Attempt to read from field 'int android.view.View.mViewFlags' on a null object reference
                post(new Runnable() {
                    @Override
                    public void run() {
                        //惯性移动完毕,从Activity顶层视图移除GhostView
                        mRootView.removeView(mGhostView);
                        mGhostView = null;
                    }
                });
            }
            //惯性移动完毕,重置偏移量
            mLastScrollOffsetX = 0;
            mLastScrollOffsetY = 0;
        }
    }

其实也很简单,就是判断Scroller是否滚动完毕,如果还没滚动完,就继续调用GhostView的updateOffset来更新坐标值,然后通过调用invalidate方法来通知重绘。如果滚动完毕,则将GhostView从顶层视图中移除。当然了,在这里我们也可以加上一些状态回调的监听。

好,那现在就剩下最后一个:播放位移动画啦,坚持~
还记不记得我们刚刚在GhostView那边定义的获取位移动画起始点和结束点的方法?
它返回的是一个PointF对象,而我们要用的ValueAnimator并没有ofPoint之类的静态方法,所以只能ofObject了,然后自定义一个TypeEvaluator,先来看看这个TypeEvaluator怎么自定义:

    /**
     * 自定义Evaluator,使ValueAnimator支持PointF
     */
    TypeEvaluator<PointF> mEvaluator = new TypeEvaluator<PointF>() {

        //对象复用,小幅度提升运行效率和降低内存占用
        private final PointF temp = new PointF();

        @Override
        public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
            //X轴总距离
            float totalX = endValue.x - startValue.x;
            //Y轴总距离
            float totalY = endValue.y - startValue.y;
            //当前绝对坐标值 = 开始坐标值 + 相对坐标值
            float x = startValue.x + (totalX * fraction);
            float y = startValue.y + (totalY * fraction);
            //更新数值
            temp.set(x, y);
            return temp;
        }
    };

看,我们自定义的这个TypeEvaluator也是很简单的:在每次回调时,根据当前进度计算出X和Y各自的坐标值,然后赋值到temp里面并返回。

那现在回到startAnimator方法:

    /**
     * 播放位移动画
     */
    private void startAnimator() {
        mAnimator = ValueAnimator.ofObject(mEvaluator, mGhostView.getAnimationStartPoint(),
                mGhostView.getAnimationEndPoint()).setDuration(800);

        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //防止内容在已超出屏幕之后(这时候GhostView已移除和置空)还继续更新位置
                if (mGhostView != null) {
                    mGhostView.onAnimationUpdate((PointF) animation.getAnimatedValue());
                }
            }
        });
        mAnimator.start();
    }

emmmm,就是创建了一个ValueAnimator监听进度更新然后播放(但那个时长是不应该写死的这里只是为了方便阅读)。
可以看到在UpdateListener的回调里面,调用了GhostView的onAnimationUpdate方法,传了一个PointF进去,我们来看看这个方法做了什么:

    /**
     * 播放位移动画时的帧更新回调
     *
     * @param location 新的位置(绝对)
     */
    void onAnimationUpdate(PointF location) {
        //更新坐标值
        mCurrentRawX = location.x;
        mCurrentRawY = location.y;
        invalidate();
    }

哈哈,非常简单,因为传进来的location已经是绝对坐标值,我们可以直接赋值,然后通知重绘了。

好啦,现在我们已经处理完了从手指按下到手指松开的整个流程。
在使用的时候是非常简单的,只需要直接在目标View外面套一个RandomDragLayout就行了,比如说像这样:

<com.wuyr.randomdraglayout.RandomDragLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#88F"
        android:padding="16dp"
        android:text="RandomDragLayoutTest" />
com.wuyr.randomdraglayout.RandomDragLayout>

发一下跟QQ空间的效果对比:
Android自定义ViewGroup第十二式之年年有鱼_第5张图片

哈哈哈,效果还不错~


好了,本篇文章到此结束,在这里祝大家:新春快乐,年年有鱼!

有错误的地方请指出,多谢~

Github地址:https://github.com/wuyr/RandomDragLayout 欢迎Star

你可能感兴趣的:(Android自定义ViewGroup第十二式之年年有鱼)