Android自定义View仿腾讯TIM下拉刷新View
一 概述
自定义 view 是 android 开发里面的一个大学问。偶然间看到 tim 邮箱界面的刷新 view 还挺好玩的,于是就自己动手实现了一个,先看看 tim 里边的效果图:
二 需求分析
看到上面的动图,大概也知道我们需要实现的功能:
- 根据拖动的进度来移动小球的位置
- 小球移动过程的动画
三 功能实现
新建一个 refreshview 类继承自 view ,然后我们再在 refreshview 里面新建一个内部实体类: circle
来看一下 circle类的代码
#cirlce.java
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.java
@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.java
@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.java
private void initcontentattr(int width, int height) { mcontentwidth = width - getpaddingleft() - getpaddingright(); mcontentheight = height - getpaddingtop() - getpaddingbottom(); }
这方法很简单,就是进行了 padding 的处理,得出真正的布局大小。如果不处理 padding 的话那么用户设置了 padding 将失效。再看 resetcircles():
#refreshview.java
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.java
/** * 设置圆球初始状态 * {@link #state_origin}为原始状态(三个小球重合), * {@link #state_prepared}为准备好可以刷新的状态,三个小球间距最大 */ public void setoriginstate(int state) { if (state == 0) { moriginstate = state_origin; } else { moriginstate = state_prepared; } }
最后就是最有趣的方法 ondraw() 了:
#refreshview.java
@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.java
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)每个小球对于动画的进度的移动特点是不一样的。
听起来好像有点拗口,我们用人话来解释一下:
1)每个小球对于坐标系的移动特点是一样的:左边的小球在坐标的最左边是先出现,然后再向右移动,那么中间和右边的小球呢?其实是同样的,它们在坐标轴最左边的时候都是先出现,再向右移动,无论哪个小球,它们在坐标轴的同一点上的动作和形态应该是一致的。
2)每个小球对于动画的进度的移动特点是不一样的:左边的小球在动画刚开始时是处于最左端,而中间的小球却在中间位置,右边的则在最右端。当动画开始后,比如进行了一半,这时候左边的小球应该移动到了中点附近,而中间的确是在末端(消失),右边的小球就会出现在中间附近。
按照上面分析的逻辑,我把动画的总进度分为6份,为什么是6份呢?通过上面的动画分析,知道小球应该经历一下过程(不分时间先后):
- 出现 (从无渐变到初始大小)
- 从最左端移动到中点(期间变大)
- 从中点移动到末端(期间缩小)
- 消失 (从初始大小渐变到消失)
为了让小球之间的间隔保持一个优美的状态(动画开始后小球间不会重叠,相邻小球的间隔基本一致),就把1、4出现和消失阶段分别设为 1/6 的动画周期,中间2、3两个阶段分别占用 1/3 个动画周期。
这样一来,出现跟消失占用了 1/3 动画进度,其他两个部分分别占用了 1/3 动画进度。举个例子:刚开始动画时,设最左边的小球为 1,中间的小球为 2,最右端的小球为 3 。
当 小球1 移动到中点时,这时动画进行了 1/3 ,那么此时的 小球2 就应该移动到末端,小球3 则刚好经历消失和出现过程,于是应该出现于坐标轴的起点。
由此可以看到又恢复到了刚开始时候的情况(一个小球在最左,一个在中,一个在最右),只不过是颜色不同了而已。以此类推,无限循环,就可以形成优美的动画了。
分析出这些有什么用呢?我发现用坐标来确定小球的移动实现起来会有点小问题,所以就用动画的进度来实现,下面看具体实现。
需要实现小球的无限运动,最实用的就是用动画来实现,这里我用了属性动画。先初始化 animotor 类:
#refreshview.java
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; } }
效果图:
由于录制软件的问题,绿色的小球显示效果不太好,在手机或虚拟机上显示是正常的。再看个项目里的实际运用效果:
录屏软件对绿色好像过敏,将就看一下吧。
此文到此就结束了,感谢阅读,喜欢的动动小手点个赞。
demo 地址:https://github.com/gminibird/refreshviewtest (本地下载)
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对的支持。
上一篇: 架构杂谈《七》