欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

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

程序员文章站 2022-07-04 09:38:23
一 概述 自定义 view 是 android 开发里面的一个大学问。偶然间看到 tim 邮箱界面的刷新 view 还挺好玩的,于是就自己动手实现了一个,先看看 ti...

一 概述

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

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

二 需求分析

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

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

三 功能实现

新建一个 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 个动画周期。

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

这样一来,出现跟消失占用了 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;
  }
 }

效果图:

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

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

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

录屏软件对绿色好像过敏,将就看一下吧。

此文到此就结束了,感谢阅读,喜欢的动动小手点个赞。

demo 地址:https://github.com/gminibird/refreshviewtest (本地下载

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对的支持。