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 (本地下载)

总结

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

(0)

相关推荐

  • ObjectAnimator属性动画源码分析篇

    又和大家见面了,这几天一直在忙大创项目,所以没有更新博客,而且我发现看源码这个东西必须写个博客或者笔记啊,这之前一段时机笔者已经看了ValueAnimator和ObjectAnimator的源码了,但是这才过了几天,搞了会别的事情就忘得几乎一干二净了.现在又要重头看一遍很痛苦额-.+. 另外,笔者已经在简书写了关于属性动画的比较系统的详细的文章,之后会陆续在CSDN上重新写的(是重新写,不是复制过去哦,因为第一次写的实在是太烂了-.=) 好了不继续扯皮了,我们看来一下今天想要讲的东西--Obje

  • Android自定义View实现简单炫酷的球体进度球实例代码

    前言 最近一直在研究自定义view,正好项目中有一个根据下载进度来实现球体进度的需求,所以自己写了个进度球,代码非常简单.先看下效果: 效果还是非常不错的. 准备知识 要实现上面的效果我们只要掌握两个知识点就好了,一个是Handler机制,用于发消息刷新我们的进度球,一个是clipDrawable.网上关于Handler的教程很多,这里重点介绍一下clipDrawable,进度球的实现全靠clipDrawable. clipDrawable 如下图所示:ClipDrawable和InsertDr

  • Android中WindowManager与WMS的解析

    最近在改bug的时候发现在windowManager.addView的时候会发生莫名其妙的崩溃,那个崩溃真的是让你心态爆炸,潜心研究了两天window相关的东西,虽然不是很深奥的东西,本人也只是弄清楚了window的添加逻辑,在此分享给大家: 一.悬浮窗的概念 在android中,无论我们的app界面,还是系统桌面,再或者是手机下方的几个虚拟按键和最上方的状态栏,又或者是一个吐司...我们所看到的所有界面,都是由一个个悬浮窗口组成的. 但是这些窗口有不同的级别: 系统的是老大,是最高级别,你没见

  • Android自定义动态壁纸开发(时钟)

    看到有些手机酷炫的动态壁纸,有没有好奇过他们是如何实现的,其实我们自己也可以实现. 先看效果 上图是动态壁纸钟的一个时钟. 我们先来看看 Livewallpaper(即动态墙纸)的实现,Android的动态墙纸并不是GIF图片,而是一个标准的Android应用程序,也就是APK.既然是应用程序,当然意味着天生具有GIF图片不具备的功能--能与用户发生交互,而且动态的背景变化绝不仅仅局限于GIF图片那般只能是固定的几张图片的循环播放.但是我们在这里没有加入与用户交互的动作,只是加入一个时钟(当然时

  • Android实现百度地图两点画弧线

    本文实例为大家分享了Android实现百度地图两点画弧线的具体代码,供大家参考,具体内容如下 import android.support.annotation.NonNull; import com.baidu.mapapi.map.ArcOptions; import com.baidu.mapapi.map.OverlayOptions; import com.baidu.mapapi.model.LatLng; /** * * http://lbsyun.baidu.com/index.

  • Android动态修改应用图标与名称的方法实例

    遇到的坑 这里我把做这个功能中遇到的一些问题写在前面,是为了大家能先了解有什么问题存在,遇到这些问题的时候就不慌了,这里我把应用图标和名称先统一使用icon代替进行说明. 1.动态替换icon,只能替换内置的icon,无法从服务器端获取来更新icon: 2.动态替换icon以后,应用内更新的时候必须要切换到原始icon),否则可能导致更新安装失败(AS上表现为adb运行会失败),或者升级后应用图标出现多个甚至应用图标都不显示的情况(这些问题都可以通过下面我推荐的开发规则解决掉,所以这是一个坑点,

  • Android百度地图定位、显示用户当前位置

    本文实例为大家分享了Android百度地图定位.显示用户当前位置的工具类,供大家参考,具体内容如下 1.构建定位Option的工具类 import com.baidu.location.LocationClientOption; /** * 建造 LocationClientOption 项 * * @author peter 2018-12-21 10:58 */ public class LocationClientOptionBuilder { private LocationClient

  • Android获取其他应用中的assets资源

    最近有这样一个需求:A应用在一定条件下出发某个逻辑后,需要从B应用中获取一些资源(assets下的mp4视频.还有drawable下的一些图片用作背景),具体需求就不说啦哈哈,用一张图来表示应该更明白: A和B应用其实是1对多的关系,不同的B应用需要从他们自己的地方获取到资源给A. 一般我们获取app内的资源肯定是要获取到Resource这个类,而Resource是通过Context类的getResource获取到了,所以我们只需要获取到B应用的Context类就可以了. 可是其他App的Con

  • Android利用ObjectAnimator实现ArcMenu

    本文介绍利用ObjectAnimator简单地实现ArcMenu,直接使用本文的ArcMenu类即可快捷地实现菜单功能. 最终使用效果: 先看下最终的使用效果: private int[] imageRes = {R.id.img_menu, R.id.img_menu1, R.id.img_menu2, R.id.img_menu3, R.id.img_menu4, R.id.img_menu5}; private ArcMenu arcMenu; ... //初始化,参数为资源图片id ar

  • Android高性能日志写入方案的实现

    前言 公司目前在做一款企业级智能客服系统,对于系统稳定性要求很高,不过难保用户在使用中不会出现问题,而 Android SDK 集成在客户的 APP 中,同时由于 Android 碎片化的问题,对于 SDK 的问题排查就显得尤为困难,因此记录下用户的操作日志就显得极为重要. 初始方案 一开始,SDK 记录日志的方式是直接通过写文件,当有一条日志要写入的时候,首先,打开文件,然后写入日志,最后关闭文件.这样做的问题就在于频繁的IO操作,影响程序的性能,而且 SDK 为了保证消息的及时性,还维护了一

随机推荐