Android自定义View实现炫酷的主题切换动画(仿酷安客户端)

前两日偶然看到了一个很炫酷的动画效果:

Android自定义View实现炫酷的主题切换动画(仿酷安客户端)_第1张图片

于是就想知道它是怎么实现的,因为有了上一次分析动画效果的经验(Android仿bilibili弹幕聊天室后面的线条动画):

  • 判断它是不是用的ValueAnimator, 如果是的话, 我们可以在设置-开发者选项里面设置 “动画时长缩放”来改变动画时长.

所以这次我们通过设置这个选项, 把动画速度降低之后, 很快就看出了其中的奥妙。


初步分析

我们先降低一下它的速度:

Android自定义View实现炫酷的主题切换动画(仿酷安客户端)_第2张图片

我们把动画时长缩放调成10x,看看效果:

哈哈,有没有发现,当动画在播放的时候,那个列表是不能滑动的,可能有以下三个原因:

  1. 被它所在的ViewGroup打断或消费了;
  2. 被其他View先消费了;
  3. 列表根据动画是否正在播放决定能不能滑动;

我们再来仔细看一遍动画。。。

那个动画会不会是一个View呢?如果它是一个View的话,那列表不能滑动的原因就可能是2: 被这个View先消费了。

好,我们就先假设动画是一个独立的View,那这个View究竟做了什么呢?
动画看上去就像是新的颜色把旧的颜色擦掉了一样。咦?等等,擦掉,哈哈哈,把旧的擦掉,这下是不是有种豁然开朗的感觉?我们就按着这个思路继续想下去。

构思代码

  1. 还记不记得PorterDuff.Mode.CLEAR这个模式?用这个可以实现橡皮擦的效果,我们等下肯定是要用到它了。
  2. 既然是擦掉旧的东西,那就必须要先得到这个旧东西,哈哈,这个可以用getDrawingCache来获取了。
  3. 我们刚刚假设它是一个View,也就是自定义的View了,那么我们这个View也要先添加到对应视图里面才能显示。



准备好上面的东西,我们这个动画的流程也就很清晰了:
这次我们不打算做成写死在布局中那种,因为这样不够灵活,应该要做成动态添加和移除。

  1. 获取根布局的截图;
  2. 添加自定义View到根布局中;
  3. 把截图draw出来;
  4. 把paint的Xfermode设置成PorterDuff.Mode.CLEAR;
  5. 以按钮被点击的位置为起点,画圆,并且不断扩大这个圆的半径;
  6. 直至这个圆足以覆盖屏幕,停止动画、从根布局中移除、回调播放完成的接口;



为了代码的美观,我们可以将构造方法私有,然后公开一个静态的create(View onClickView)方法,哈哈,只要传入一个被点击的View就可以满足我们播放动画所需的条件了。


那么我们要怎么计算出这个圆究竟需要多大的半径才能刚好覆盖屏幕呢?因为按钮的位置不可能是固定的,所以要动态地去计算这个所需要的半径。


我们来看看下面这张图:
Android自定义View实现炫酷的主题切换动画(仿酷安客户端)_第3张图片
这个是开启了开发者选项中的指针位置之后的效果,可以看到我们手指按下之后,屏幕被蓝色线条分成了4个小矩形,我们可以分别计算出这些小矩形的对角线长度,再从中取最长的那条作为我们绘制的圆的半径。那么计算圆形半径这个问题算是解决了。下面基本可以一路畅通地写代码了。


动手写代码

先是构造方法:

    private RippleAnimation(Context context, float startX, float startY, int radius) {
        super(context);
        //获取activity的根视图,用来添加本View
        mRootView = (ViewGroup) ((Activity) getContext()).getWindow().getDecorView();
        mStartX = startX;
        mStartY = startY;
        mStartRadius = radius;
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        //设置为擦除模式
        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        updateMaxRadius();
        initListener();
    }

既然构造方法被私有了,那么我们就要公开一个静态的create方法:

    public static RippleAnimation create(View onClickView) {
        Context context = onClickView.getContext();
        int newWidth = onClickView.getWidth() / 2;
        int newHeight = onClickView.getHeight() / 2;
        //计算起点位置
        float startX = getAbsoluteX(onClickView) + newWidth;
        float startY = getAbsoluteY(onClickView) + newHeight;
        //起始半径
        //因为我们要避免遮挡按钮
        int radius = Math.max(newWidth, newHeight);
        return new RippleAnimation(context, startX, startY, radius);
    }

我们来看看获取圆形半径的代码怎么写:

    /**
     * 根据起始点将屏幕分成4个小矩形,mMaxRadius就是取它们中最大的矩形的对角线长度
     * 这样的话, 无论起始点在屏幕中的哪一个位置上, 我们绘制的圆形总是能覆盖屏幕
     */
    private void updateMaxRadius() {
        //将屏幕分成4个小矩形
        RectF leftTop = new RectF(0, 0, mStartX + mStartRadius, mStartY + mStartRadius);
        RectF rightTop = new RectF(leftTop.right, 0, mRootView.getRight(), leftTop.bottom);
        RectF leftBottom = new RectF(0, leftTop.bottom, leftTop.right, mRootView.getBottom());
        RectF rightBottom = new RectF(leftBottom.right, leftTop.bottom, mRootView.getRight(), leftBottom.bottom);
        //分别获取对角线长度
        double leftTopHypotenuse = Math.sqrt(Math.pow(leftTop.width(), 2) + Math.pow(leftTop.height(), 2));
        double rightTopHypotenuse = Math.sqrt(Math.pow(rightTop.width(), 2) + Math.pow(rightTop.height(), 2));
        double leftBottomHypotenuse = Math.sqrt(Math.pow(leftBottom.width(), 2) + Math.pow(leftBottom.height(), 2));
        double rightBottomHypotenuse = Math.sqrt(Math.pow(rightBottom.width(), 2) + Math.pow(rightBottom.height(), 2));
        //取最大值
        mMaxRadius = (int) Math.max(
                Math.max(leftTopHypotenuse, rightTopHypotenuse),
                Math.max(leftBottomHypotenuse, rightBottomHypotenuse));
    }

create方法里面有个getAbsoluteX和getAbsoluteY方法,这两个方法分别是获取view在屏幕中的x坐标和y坐标,为什么要有这两个方法呢,因为被点击的View所在的ViewGroup不一定top、left都是0的,所以如果我们直接获取这个View的xy坐标的话,是不够的,还要加上它父容器的xy坐标,我们要一直递归下去,这样就能真正获取到View在屏幕中的绝对坐标了:

    /**
     * 获取view在屏幕中的绝对x坐标
     */
    private static float getAbsoluteX(View view) {
        float x = view.getX();
        ViewParent parent = view.getParent();
        if (parent != null && parent instanceof View) {
            x += getAbsoluteX((View) parent);
        }
        return x;
    }

    /**
     * 获取view在屏幕中的绝对y坐标
     */
    private static float getAbsoluteY(View view) {
        float y = view.getY();
        ViewParent parent = view.getParent();
        if (parent != null && parent instanceof View) {
            y += getAbsoluteY((View) parent);
        }
        return y;
    }

我们还要定义一个start方法,用来启动动画:

    public void start() {
        if (!isStarted) {
            isStarted = true;
            updateBackground();
            attachToRootView();
            getAnimator().start();
        }
    }

updateBackground方法就是更新屏幕截图了,这个当然要从DecorView中获取了:

    /**
     * 更新屏幕截图
     */
    private void updateBackground() {
        if (mBackground != null && !mBackground.isRecycled()) {
            mBackground.recycle();
        }
        mRootView.setDrawingCacheEnabled(true);
        mBackground = mRootView.getDrawingCache();
        mBackground = Bitmap.createBitmap(mBackground);
        mRootView.setDrawingCacheEnabled(false);
    }

更新完截图后,我们就要添加自身到根布局中了:

    /**
     * 添加到根视图
     */
    private void attachToRootView() {
        setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        mRootView.addView(this);
    }

我们调用addView方法之前还set了一个宽高都是MATCH_PARENT的LayoutParams,这样我们的View就能遮挡住屏幕,然后把刚刚获取到的截图draw上去,以假乱真,哈哈:

    @Override
    protected void onDraw(Canvas canvas) {
        //在新的图层上面绘制
        int layer = canvas.saveLayer(0, 0, getWidth(), getHeight(), null);
        canvas.drawBitmap(mBackground, 0, 0, null);
        canvas.drawCircle(mStartX, mStartY, mCurrentRadius, mPaint);
        canvas.restoreToCount(layer);
    }

我们的start方法中最后是调用了getAnimator().start(); 看看getAnimator方法里面做了什么:

    private ValueAnimator getAnimator() {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mMaxRadius).setDuration(mDuration);
        valueAnimator.addUpdateListener(mAnimatorUpdateListener);
        valueAnimator.addListener(mAnimatorListener);
        return valueAnimator;
    }

就创建了一个ValueAnimator,动画的起始值是0,结束值是mMaxRadius,也就是圆的最大半径,我们的mCurrentRadius跟着动画的值更新,那么当我们的动画播放完之后,mCurrentRadius就刚好等于mMaxRadius,也就刚好覆盖屏幕了,这个时候我们也可以将自身从根布局中移除了:

    private void initListener() {
        mAnimatorListener = new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //动画播放完毕, 移除本View
                detachFromRootView();
                if (mOnAnimationEndListener != null) {
                    mOnAnimationEndListener.onAnimationEnd();
                }
                isStarted = false;
            }
        };
        mAnimatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //更新圆的半径
                mCurrentRadius = (int) (float) animation.getAnimatedValue() + mStartRadius;
                postInvalidate();
            }
        };
    }

    /**
     * 从根视图中移除
     */
    private void detachFromRootView() {
        mRootView.removeView(this);
    }

对了,还应该有一个setDuration方法来设置动画的时长:

    public RippleAnimation setDuration(long duration) {
        mDuration = duration;
        return this;
    }

哈哈,到这里我们的RippleAnimation就基本完成了.


说说酷安这个效果的实现原理:

  1. 切换主题前先获取当前屏幕截图;
  2. 开始播放动画;
  3. 切换主题;

哈哈, 不断扩大的圆形就会把旧的屏幕截图擦掉, 从而看到下面新的主题颜色, 这样我们的炫酷效果就出来了, 哈哈哈

因为只能从create方法中获取到RippleAnimation对象,所以我们的使用方法也是非常的简单,并且只需一行代码就能播放了:

    public void onClick(View view) {
        RippleAnimation.create(view).setDuration(200).start();
        //在这里切换主题
    }

我们写一个demo来测试一下我们的劳动成果:

哈哈哈,跟酷安客户端的效果对比下:
Android自定义View实现炫酷的主题切换动画(仿酷安客户端)_第4张图片
哈哈,基本就是这个效果了,其实我们的这个效果不一定只能用来做主题切换,还可以用来做其他界面切换的过渡,哈哈哈,这个大家可以发挥一下想象力,做出更多炫酷的动画效果。


本文到此结束,有错误的地方请指出,谢谢大家!

完整代码地址: https://github.com/wuyr/RippleAnimation

你可能感兴趣的:(RippleAnimation,主题切换动画,仿酷安)