自定义 View 实现数据加载动效

前言:你的问题在于读书不多而想得太多 。 -------杨绛

没想到2019年的第一篇文章是在情人节这天更新了,回顾2018年,觉得自己花在健身房的时间太多了,反而在专业上面没有那么用心,2019年还是保持初心,一步一个脚印按时更新专业方面的技能点,做到劳逸结合,厚积薄发,与你们共勉。

属性动画的知识大家可以看看郭霖的三篇属性动画理论知识,已经属于非常全面的了。所以属性动画这块打算举一些例子,并结合设计模式分析一下属性动画的源码。本文实现一个数据加载动效,先看 gif 实现效果图:


自定义 View 实现数据加载动效_第1张图片
image

那么就一点点带领大家实现这个效果吧。


一、实现“红、黄、蓝”三个图形的切换效果

1、实现基本自定义View

由于很简单,就直接把代码贴在下面了:
首先自定义 View 代码:

public class ShapeView extends View {

    private static String TAG = ShapeView.class.getSimpleName();

    public enum ShapeType{
        Circular,//圆形
        Square,//正方形
        Triangle//三角形
    }
    //默认图形
    private ShapeType mCurrentShape = Circular;

    private Paint mPaint;
    private Path mPath;

    public ShapeView(Context context) {
        this(context,null);
    }

    public ShapeView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public ShapeView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);

        //设置控件的大小就为手动设置的大小
        setMeasuredDimension(Math.min(width,height),Math.min(width,height));
    }

    /**
     * 根据当前枚举类型绘制对应图形
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        switch (mCurrentShape) {
            case Circular:
                    //绘制圆形
                int center = getWidth() / 2;
                mPaint.setColor(Color.RED);
                canvas.drawCircle(center,center,center,mPaint);
                break;
            case Square:
                    //绘制正方形
                mPaint.setColor(Color.BLUE);
                canvas.drawRect(0, 0, getWidth(), getWidth(), mPaint);//直接构造
                break;
            case Triangle:
                    //绘制三角形
                mPaint.setColor(Color.YELLOW);
                if (mPath == null) {
                    // 画路径
                    mPath = new Path();
                    mPath.moveTo(getWidth() / 2, 0);
                    mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
                    mPath.lineTo(getWidth(), (float) ((getWidth()/2)*Math.sqrt(3)));
                    // path.lineTo(getWidth()/2,0);
                    mPath.close();// 把路径闭合
                }
                canvas.drawPath(mPath, mPaint);
                break;
            }
    }

    /**
     * 改变形状
     */
    public void changeShape() {
        switch (mCurrentShape) {
            case Circular:
                mCurrentShape = Square;
                break;
            case Square:
                mCurrentShape = Triangle;
                break;
            case Triangle:
                mCurrentShape = Circular;
                break;
        }
        invalidate();
    }

}

上面是自定义 ShapeView,动画里面的图片是绘制上去的。代码很简单,首先,测量出控件的大小,这里仅仅支持布局写死的大小,并且设置为正方形大小。然后是 onDraw() 方法,在这里使用枚举定义了三种状态。分别是:圆形、矩形、方形状态。且在对应状态绘制对应的图形就好了。我们看到有一个改变行状的方法:

    /**
     * 改变形状
     */
    public void changeShape() {
        switch (mCurrentShape) {
            case Circular:
                mCurrentShape = Square;
                break;
            case Square:
                mCurrentShape = Triangle;
                break;
            case Triangle:
                mCurrentShape = Circular;
                break;
        }
        invalidate();
    }

这个方法中没有在 View 内部调用,是一个公共的方法给外面调用的。然后判定当前状态,而且修改为别的状态,比如:当前圆形,下一个就是矩形;当前矩形,下一个就是三角......最后调用重绘,系统就会去调用 onDraw 方法再走其中的逻辑。这样就实现了图形的改变。
可能一个地方稍微有一点点“卡壳”的地方就是绘制三角形,我们单独拿出来分析一下:

    //绘制三角形
 mPaint.setColor(Color.YELLOW);
 if (mPath == null) {
     // 画路径
     mPath = new Path();
     mPath.moveTo(getWidth() / 2, 0);
     mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));
     mPath.lineTo(getWidth(), (float) ((getWidth()/2)*Math.sqrt(3)));
     // path.lineTo(getWidth()/2,0);
     mPath.close();// 把路径闭合
 }
 canvas.drawPath(mPath, mPaint);

使用 path 来进行化画路线操作,讲一个:

mPath.lineTo(0, (float) ((getWidth()/2)*Math.sqrt(3)));

x表示相对坐标为0,y=((getWidth()/2)*Math.sqrt(3))
看图解:


自定义 View 实现数据加载动效_第2张图片
image

这里是需要画一个正三角形,因此 x 边和 y 边夹角是60°。利用正比关系,容易得到计算 y 的公式。

2、测试View改变效果:

(为了测试效果,以下代码不求规范)
在 xml 中:




    

然后在 Activity 中使用:

public class MainActivity extends AppCompatActivity {

    private ShapeView mShapeView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mShapeView = (ShapeView) findViewById(R.id.shapeView);
    }

    public void changeShape(View view){
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    SystemClock.sleep(1000);
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mShapeView.changeShape();
                        }
                    });
                }
            }
        }).start();
    }
}

这里重要的是按钮点击事件,让其不断循环,每隔1s就调用一次上述 View 的 changeShape 方法(还是注意,这里只是测试功能)。运行效果:


自定义 View 实现数据加载动效_第3张图片
image

上面动画有点掉帧,实际运行起来效果不是这样的。


二、动画的实现

2.1、先实现下落和回弹效果

代码如下:

public class LoadingView extends LinearLayout {

    private final int mTranslationDis;
    private View mShadowView;//阴影
    private ShapeView mShapeView;//图形View
    // 动画执行的时间
    private final long ANIMATOR_DURATION = 500;

    public LoadingView(Context context) {
        this(context,null);
    }

    public LoadingView(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public LoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mTranslationDis = dip2px(80);
        initLayout();
    }

    /**
     * 初始化组合控件布局
     */
    private void initLayout() {
        // 第三个参数为this,表示布局解析完毕直接添加到LoadingView中(它是一个扩展的LinearLayout)
        inflate(getContext(), R.layout.layout_loading_view, this);
        mShadowView = findViewById(R.id.shadowView);
        mShapeView = (ShapeView) findViewById(R.id.shapeView);

        /**---------  直接开启动画  ---------**/
        post(new Runnable() {
            @Override
            public void run() {
                //让开启动画逻辑在onResume()之后
                startPullDownAnimation();
            }
        });

    }

    /**
     * 开启下落动画
     */
    private void startPullDownAnimation() {
        ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",0,mTranslationDis);
        ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",1.0f,0.3f);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(ANIMATOR_DURATION);
        // 下落的速度应该是越来越快,使用加速度插值器
        animatorSet.setInterpolator(new AccelerateInterpolator());
        animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
        animatorSet.start();

        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mShapeView.changeShape();
                //开启回弹动画
                startSpringBackAnimation();
            }
        });
    }

    /**
     * 开启弹起动画
     */
    private void startSpringBackAnimation() {
        ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",mTranslationDis,0);
        ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",0.3f,1.0f);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(ANIMATOR_DURATION);
        // 下落的速度应该是越来越快,使用加速度插值器
        animatorSet.setInterpolator(new DecelerateInterpolator());
        animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);

        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //开启回弹动画
                startPullDownAnimation();
            }

            @Override
            public void onAnimationStart(Animator animation) {
                //动画开始,开启旋转动画
                startRotateAnimation();
            }
        });

        //开启动画要放在后面,否则onAnimationStart监听不到
        animatorSet.start();

    }

    /**
     * 旋转动画。
     */
    private void startRotateAnimation() {
        switch (mShapeView.getCurrentShape()) {
            case Circular:
                break;
            case Square:
                break;
            case Triangle:
                break;
            }
    }

    private int dip2px(int dip) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,dip,getResources().getDisplayMetrics());
    }
}

在这里又重新做了一个 View——LoadingView,这 View 是一个组合控件形式,即加载布局的方式然后把加载的布局放入这个 LoadingView 控件里面。
要加载的布局如下:




    
    
    

    
    

    
    



布局还是很简单的,就不细说了。咱们看看自定义 LoadingView 的逻辑:
1、初始化布局和孩子控件
2、同时直接开启下落动画:

 private void initLayout() {
        // 第三个参数为this,表示布局解析完毕直接添加到LoadingView中(它是一个扩展的LinearLayout)
        inflate(getContext(), R.layout.layout_loading_view, this);
        mShadowView = findViewById(R.id.shadowView);
        mShapeView = (ShapeView) findViewById(R.id.shapeView);

        /**---------  直接开启动画  ---------**/
        post(new Runnable() {
            @Override
            public void run() {
                //让开启动画逻辑在onResume()之后
                startPullDownAnimation();
            }
        });

    }

注意的是,使用 post 把动画开启在 Activity 的 onResume 之后执行。
3、具体下落动画:

/**
     * 开启下落动画
     */
    private void startPullDownAnimation() {
        ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",0,mTranslationDis);
        ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",1.0f,0.3f);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(ANIMATOR_DURATION);
        // 下落的速度应该是越来越快,使用加速度插值器
        animatorSet.setInterpolator(new AccelerateInterpolator());
        animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);
        animatorSet.start();

        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mShapeView.changeShape();
                //开启回弹动画
                startSpringBackAnimation();
            }
        });
    }

下落动画使用到了属性动画,这里都是最最进本的使用方式。看到是分别对 mShapeView 做Y轴的平移动画,对 mShadowView 做缩放动画。下落的时候,让 mShadowView 缩小。使用了 animatorSet 让动画同时播放。
需要监听动画状态,当下落动画结束,立即改变当前 ShapeView 的图形效果,然后开启回弹效果:

 /**
     * 开启弹起动画
     */
    private void startSpringBackAnimation() {
        ObjectAnimator shapeViewDownAnimator = ObjectAnimator.ofFloat(mShapeView,"TranslationY",mTranslationDis,0);
        ObjectAnimator shadowViewDownAnimator = ObjectAnimator.ofFloat(mShadowView,"ScaleX",0.3f,1.0f);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.setDuration(ANIMATOR_DURATION);
        // 下落的速度应该是越来越快,使用加速度插值器
        animatorSet.setInterpolator(new DecelerateInterpolator());
        animatorSet.playTogether(shapeViewDownAnimator,shadowViewDownAnimator);

        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //开启回弹动画
                startPullDownAnimation();
            }

            @Override
            public void onAnimationStart(Animator animation) {
                //动画开始,开启旋转动画
                startRotateAnimation();
            }
        });

        //开启动画要放在后面,否则onAnimationStart监听不到
        animatorSet.start();

    }

这块代码跟下落基本很相似,注意点仍然是动画监听。当动画刚开启的时候开启旋转动画,看到旋转动画没有任何逻辑,我们会在下一节单独讲。然后动画结束的时候,在此开启下落动画。这里需要把 animatorSet.start(); 放在监听器的后面,否则动画开启监听拿不到。
此时运行程序看看效果吧:


自定义 View 实现数据加载动效_第4张图片
image

看到基本效果都快实现了,最后就是完成旋转动画了。

2.2旋转动画

/**
 * 旋转动画。
 */
private void startRotateAnimation() {
    ObjectAnimator rotationAnimator = null;
    switch (mShapeView.getCurrentShape()) {
        case Circular:
        case Square:
            //圆形和方形旋转-180度
            rotationAnimator = ofFloat(mShapeView,"rotation",0,180);
            break;
        case Triangle:
            //三角形旋转-120°
            rotationAnimator = ObjectAnimator.ofFloat(mShapeView,"rotation",0,-120);
            break;
        }
    rotationAnimator.setDuration(ANIMATOR_DURATION);
    rotationAnimator.setInterpolator(new DecelerateInterpolator());
    rotationAnimator.start();
}

当处于圆形和方型的时候让 ShapeView 旋转180°,当为三角形的时候旋转-120°。

2.3添加让动画消失的功能

为了模拟更真实的开发环境,在加载网络结束或者失败都要让正在加载的 View 消失,这里同样提供一个消失的方法:

/**
 * 清空动画,清空View
 * @param visibility
 */
@Override
public void setVisibility(int visibility) {
    super.setVisibility(View.INVISIBLE);
    mShapeView.clearAnimation();
    mShadowView.clearAnimation();

    ViewGroup parent = (ViewGroup) getParent();
    if(parent != null){
        //因为自己装到了父View中了
        parent.removeView(this);
        //移除自己的Views
        removeAllViews();
    }
}

发现主要是清空动画和 View 视图。1、清空自己在父 View(也就是 LinearLayout )中;2、清空自己的孩子控件。
然后这个控件如果在 Activity 中使用的话:

    mLoadingView = (LoadingView) findViewById(R.id.loadingView);
    new Thread(new Runnable() {
        @Override
        public void run() {
            SystemClock.sleep(5000);
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    mLoadingView.setVisibility(View.GONE);
                }
            });
        }
    }).start();

模拟数据加载5S钟后 gone 掉加载动画。
此时运行程序:


自定义 View 实现数据加载动效_第5张图片
image

三、一点点优化

可以看到上面已经完成了开始的功能,但是呢。即使是移除了动画,此时的监听仍然在跑,不信你可以在启动动画里面加一行 log,发现即使 Activity 退出了,仍然在打印 log。那么就会导致 Activity 的实例无法被回收从而导致内存泄漏。只需要加一行代码即可:
加一个标志位:


image

然后在启动动画开始加上一个判断:


自定义 View 实现数据加载动效_第6张图片
image

再运行程序,就不会随便打印 log 了。
到此为止,这个动效也就实现完毕了。

你可能感兴趣的:(自定义 View 实现数据加载动效)