自定义View——仿腾讯TIM下拉刷新View

一 概述

自定义 View 是 Android 开发里面的一个大学问。偶然间看到 TIM 邮箱界面的刷新 View 还挺好玩的,于是就自己动手实现了一个,先看看 TIM 里边的效果图:


TIM_refresh.gif

二 需求分析

看到上面的动图,大概也知道我们需要实现的功能:

  • 根据拖动的进度来移动小球的位置
  • 小球移动过程的动画

三 功能实现

新建一个 RefreshView 类继承自 View ,然后我们再在 RefreshView 里面新建一个内部实体类: Circle
来看一下 Circle类的代码

#Cirlce

 class Circle {
        int x;
        int y;
        int r;
        int color;

        public Circle(int x, int y, int r, int color) {
            this.x = x;
            this.y = y;
            this.r = r;
            this.color = color;
        }
    }

这是一个实体类,里面提供了 x , y , r , color 属性分别代表圆心坐标的 x值,y值,圆的半径 r 跟颜色。
借助此类来存储小圆球的相关属性。

接下来就是我们平时自定义 View 经常要重写的三大方法了,先看 onMeasure()

#RefreshView

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(mWidth, heightSize);
        } else if (widthMeasureSpec == MeasureSpec.EXACTLY && heightMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, mHeight);
        } else if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize);
        } else {
            setMeasuredDimension(mWidth, mHeight);
        }
    }

为了适配布局文件中的 wrap_content 参数,我们需要重写此方法(此方法不是本文的研究重点,不明白的可以百度或者google一下,或者参考《Android开发艺术探索》里面的相关章节)。

接着看 onLayout() 方法:

#RefreshView

 @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        initContentAttr(getMeasuredWidth(), getMeasuredHeight());
        resetCircles();
    }

在此方法中调用了 initContentAttr() 方法来初始化内容大小与 resetCircles() 来初始化(重置)三个小球的属性。分别看下这两个方法:

#RefreshView

    private void initContentAttr(int width, int height) {
        mContentWidth = width - getPaddingLeft() - getPaddingRight();
        mContentHeight = height - getPaddingTop() - getPaddingBottom();
    }

这方法很简单,就是进行了 padding 的处理,得出真正的布局大小。如果不处理 padding 的话那么用户设置了 padding 将失效。再看 resetCircles()

#RefreshView

    public static final int STATE_ORIGIN = 0;
    public static final int STATE_PREPARED = 1;
    private int mOriginState = STATE_ORIGIN;

    private void resetCircles() {
        if (mCircles.isEmpty()) {
            int x = mContentWidth / 2;
            int y = mContentHeight / 2;
            mGap = x - mMinRadius;   //初始化相邻圆心间的最大间距
            Circle circleLeft = new Circle(x, y, mMinRadius, 0xffff7f0a);
            Circle circleCenter = new Circle(x, y, mMaxRadius, Color.RED);
            Circle circleRight = new Circle(x, y, mMinRadius, Color.GREEN);
            mCircles.add(LEFT, circleLeft);
            mCircles.add(RIGHT, circleRight);
            mCircles.add(CENTER, circleCenter);
        }
        if (mOriginState == STATE_ORIGIN) {
            int x = mContentWidth / 2;
            int y = mContentHeight / 2;
            for (int i = 0; i < mCircles.size(); i++) {
                Circle circle = mCircles.get(i);
                circle.x = x;
                circle.y = y;
                if (i == CENTER) {
                    circle.r = mMaxRadius;
                } else {
                    circle.r = mMinRadius;
                }
            }
        } else {
            prepareToStart();
        }
    }

此方法用于初始化和重置小球,方法里面进行的两个大的 if...else 语句判断,第一个 if 用于判断是否应该初始化小球,第二个语句则是用于判断小球的初始化时候的形态。可以在外部调用 setOriginState() 方法来指定小球的初始化形态,如不指定,则默认为 NOMAL,即三球重合。

#RefreshView

 /**
     * 设置圆球初始状态
     * {@link #STATE_ORIGIN}为原始状态(三个小球重合),
     * {@link #STATE_PREPARED}为准备好可以刷新的状态,三个小球间距最大
     */
    public void setOriginState(int state) {
        if (state == 0) {
            mOriginState = STATE_ORIGIN;
        } else {
            mOriginState = STATE_PREPARED;
        }
    }

最后就是最有趣的方法 onDraw() 了:

#RefreshView

    @Override
    protected void onDraw(Canvas canvas) {
        for (Circle circle : mCircles) {
            mPaint.setColor(circle.color);
            canvas.drawCircle(circle.x + getPaddingLeft(), circle.y + getPaddingTop(), circle.r, mPaint);
        }
    }

这方法很简单,就是将 mCircles 列表里面的圆画出来而已(里面进行了 padding 的处理)。

三大方法都讲完了,可是这只是画出了几个小圆球而已,我们需求分析里的需求还没实现呢,上面的方法已经把 View 的基础搭起来了,要实现这个也就不难了。接下来就是大家期待的需求实现了:

  • 根据拖动的进度来移动小球的位置

实现代码如下:

#RefreshView

    public void drag(float fraction) {
        if (mOriginState == STATE_PREPARED) {
            return;
        }
        if (mAnimator != null && mAnimator.isRunning()) {
            return;
        }
        if (fraction > 1) {
            return;
        }
        mCircles.get(LEFT).x = (int) (mMinRadius + mGap * (1f - fraction));
        mCircles.get(RIGHT).x = (int) (mContentWidth / 2 + mGap * fraction);
        postInvalidate();
    }

在方法里面进行三次判断,如果初始状态是 STATE_PREPARED (三小球距离最大,没必要再变动了)、动画正在进行或者进度大于1 都不进行移动。然后修改小球的属性,再重绘。

  • 小球移动过程的动画

这个是这个自定义 View 最难的部分了,需要一些数学的小运算,有点繁琐。

我们先来理清实现动画的逻辑,看了开篇的gif,应该可以了解到,刚准备开始动画时,左边的小球应该是处于最左端,中间的小球处于中间,右边的处于最右端。我们一个个小球来分析。

  1. 左边小球:动画开始后,左边的小球向右移动,并且逐渐变大,直到小球运动到中点,过了中点后小球继续往右移动,不过却逐渐变小,到了终点后小球将消失(消失过程为先缩小再消失,下同),接着又从左边出现(出现过程也是从小到大的渐变,下同),然后重复上述过程。

  2. 中间小球:中间的小球先向右移动,逐渐缩小,然后消失,后来再从左边出现,最后移动到中间,其间逐渐变大。后面就是重复的上述动作。

  3. 右边小球:右边的小球则是先消失,再从左边出现,接着移动到中间,其间逐渐变大,然后再从中点移动到末端,其间逐渐缩小。

理清小球的移动过程对代码的实现很有帮助,我们可以分析出:

1)每个小球对于坐标系的移动特点是一样的。
2)每个小球对于动画的进度的移动特点是不一样的。

听起来好像有点拗口,我们用人话来解释一下:
1)每个小球对于坐标系的移动特点是一样的:左边的小球在坐标的最左边是先出现,然后再向右移动,那么中间和右边的小球呢?其实是同样的,它们在坐标轴最左边的时候都是先出现,再向右移动,无论哪个小球,它们在坐标轴的同一点上的动作和形态应该是一致的。
2)每个小球对于动画的进度的移动特点是不一样的:左边的小球在动画刚开始时是处于最左端,而中间的小球却在中间位置,右边的则在最右端。当动画开始后,比如进行了一半,这时候左边的小球应该移动到了中点附近,而中间的确是在末端(消失),右边的小球就会出现在中间附近。

按照上面分析的逻辑,我把动画的总进度分为6份,为什么是6份呢?通过上面的动画分析,知道小球应该经历一下过程(不分时间先后):

  1. 出现 (从无渐变到初始大小)
  2. 从最左端移动到中点(期间变大)
  3. 从中点移动到末端(期间缩小)
  4. 消失 (从初始大小渐变到消失)

为了让小球之间的间隔保持一个优美的状态(动画开始后小球间不会重叠,相邻小球的间隔基本一致),就把1、4出现和消失阶段分别设为 1/6 的动画周期,中间2、3两个阶段分别占用 1/3 个动画周期。


坐标.png

这样一来,出现跟消失占用了 1/3 动画进度,其他两个部分分别占用了 1/3 动画进度。举个例子:刚开始动画时,设最左边的小球为 1,中间的小球为 2,最右端的小球为 3 。

小球1 移动到中点时,这时动画进行了 1/3 ,那么此时的 小球2 就应该移动到末端,小球3 则刚好经历消失和出现过程,于是应该出现于坐标轴的起点。

由此可以看到又恢复到了刚开始时候的情况(一个小球在最左,一个在中,一个在最右),只不过是颜色不同了而已。以此类推,无限循环,就可以形成优美的动画了。

分析出这些有什么用呢?我发现用坐标来确定小球的移动实现起来会有点小问题,所以就用动画的进度来实现,下面看具体实现。

需要实现小球的无限运动,最实用的就是用动画来实现,这里我用了属性动画。先初始化 Animotor 类:

#RefreshView

    private void initAnimator() {
        ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
        animator.setDuration(1500);
        animator.setRepeatCount(-1);
        animator.setRepeatMode(ValueAnimator.RESTART);
        animator.setInterpolator(new LinearInterpolator());
        animator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
                prepareToStart();  //确保View达到可以刷新的状态
            }

            @Override
            public void onAnimationEnd(Animator animation) {

            }

            @Override
            public void onAnimationCancel(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }
        });
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                for (Circle circle : mCircles) {
                    updateCircle(circle, mCircles.indexOf(circle), animation.getAnimatedFraction());
                }
                postInvalidate();
            }
        });
        mAnimator = animator;
    }

可以看到,这是一个无限循环的动画,如果不手动停止,它就会一直循环下去。对于 mAnimator ,还添加了一个监听器,当开始动画是就调用 prepareToStart() 方法,这个方法看起来是不是有点眼熟,没错,它就是我们上面 resetCircles() 里面判断小球形态为 STATE_PREPARED 是调用过,此方法将确保小球达到刷新的临界点。我们主要看看 UpdateLisener 中的 onAnimationUpdate() 方法里面的 updateCircle() 方法:

#RefreshView

    private void updateCircle(Circle circle, int index, float fraction) {
        float progress = fraction;  //真实进度
        float virtualFraction;      //每个小球内部的虚拟进度
        switch (index) {
            case LEFT:
                if (fraction < 5f / 6f) {
                    progress = progress + 1f / 6f;
                } else {
                    progress = progress - 5f / 6f;
                }
                break;
            case CENTER:
                if (fraction < 0.5f) {
                    progress = progress + 0.5f;
                } else {
                    progress = progress - 0.5f;
                }
                break;
            case RIGHT:
                if (fraction < 1f / 6f) {
                    progress += 5f / 6f;
                } else {
                    progress -= 1f / 6f;
                }
                break;
        }
        if (progress <= 1f / 6f) {
            virtualFraction = progress * 6;
            appear(circle, virtualFraction);
            return;
        }
        if (progress >= 5f / 6f) {
            virtualFraction = (progress - 5f / 6f) * 6;
            disappear(circle, virtualFraction);
            return;
        }
        virtualFraction = (progress - 1f / 6f) * 3f / 2f;
        move(circle, virtualFraction);
    }

我用了一个 virtualFraction 来表示每个小球的虚拟进度(相当于上面坐标图中的下值,即坐标百分比),例如当动画的总进度为 0 时,左小球的虚拟进度就应该是 1/6+0 (默认已经经过了出现过程,消耗了 1/6),中间小球的虚拟进度为 1/6+1/3+0 = 1/2 (默认经历了出现,移动到中间过程),最右边小球的虚拟进度为 1/6+1/3+1/3+0 = 5/6 。然后动画的总进度到 1/3 时,左小球的虚拟进度就为 1/2 (中间位置)......

下面再看下 move()appear()disapear() 方法:

#RefreshView

    private void appear(Circle circle, float fraction) {
        circle.r = (int) (mMinRadius * fraction);
        circle.x = mMinRadius;
    }

    private void disappear(Circle circle, float fraction) {
        circle.r = (int) (mMinRadius * (1 - fraction));
    }

    private void move(Circle circle, float fraction) {
        int difference = mMaxRadius - mMinRadius;
        if (fraction < 0.5) {
            circle.r = (int) (mMinRadius + difference * fraction * 2);
        } else {
            circle.r = (int) (mMaxRadius - difference * (fraction - 0.5) * 2);
        }
        circle.x = (int) (mMinRadius + mGap * 2 * fraction);
    }

这个三个方法都很简单,根据坐标的占比来计算出小球的坐标跟大小。

以上就是整个 RefershView 的实现了,如果需要看源码的可以拉到文末。

四 使用及效果

看下怎么使用:

#MainActivity

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mRefreshView = findViewById(R.id.refresh_view);
//        mRefreshView.setOriginState(RefreshView.STATE_PREPARED);
        Button start = findViewById(R.id.start);
        Button stop = findViewById(R.id.stop);
        SeekBar seekBar = findViewById(R.id.seek_bar);
        seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            @Override
            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
                mRefreshView.drag(progress / 100f);
            }

            @Override
            public void onStartTrackingTouch(SeekBar seekBar) {

            }

            @Override
            public void onStopTrackingTouch(SeekBar seekBar) {

            }
        });
        start.setOnClickListener(this);
        stop.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.start:
                mRefreshView.start();
                break;
            case R.id.stop:
                mRefreshView.stop();
                break;
        }
    }

效果图:


RefreshViewDemo.gif

由于录制软件的问题,绿色的小球显示效果不太好,在手机或虚拟机上显示是正常的。再看个项目里的实际运用效果:


BirdNewsDemo.gif

录屏软件对绿色好像过敏,将就看一下吧。
此文到此就结束了,感谢阅读,喜欢的动动小手点个赞。

Demo地址:https://github.com/gminibird/RefreshViewTest

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

推荐阅读更多精彩内容