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

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

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

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


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))
看图解:


image

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

2、测试View改变效果:

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

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.itydl.property.MainActivity">

    <Button
        android:onClick="changeShape"
        android:text="测试"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <com.itydl.property.view.ShapeView
        android:id="@+id/shapeView"
        android:layout_centerInParent="true"
        android:layout_height="45dp"
        android:layout_width="45dp">
    </com.itydl.property.view.ShapeView>

</RelativeLayout>

然后在 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 方法(还是注意,这里只是测试功能)。运行效果:


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 控件里面。
要加载的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:gravity="center"
              android:background="#ffffffff"
              android:layout_width="match_parent"
              android:layout_height="match_parent">

    <!--图形变换View-->
    <com.itydl.property.view.ShapeView
        android:layout_marginBottom="85dp"
        android:id="@+id/shapeView"
        android:layout_centerInParent="true"
        android:layout_height="30dp"
        android:layout_width="30dp">
    </com.itydl.property.view.ShapeView>

    <!--阴影-->
    <View
        android:id="@+id/shadowView"
        android:background="@drawable/shadow_bg"
        android:layout_width="40dp"
        android:layout_height="3dp"/>

    <!--文本-->
    <TextView
        android:layout_marginTop="10dp"
        android:text="正在加载中..."
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

</LinearLayout>

布局还是很简单的,就不细说了。咱们看看自定义 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(); 放在监听器的后面,否则动画开启监听拿不到。
此时运行程序看看效果吧:


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 掉加载动画。
此时运行程序:


image

三、一点点优化

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


image

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


image

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容