如果想学习更多进阶知识,可以关注我的微信公众号:Android小菜。
也可以直接扫描二维码关注:
转载本专栏文章,请注明出处,尊重原创 。文章博客地址:道龙的博客
很有幸能进入美团。本文就仿写一下美团客户端的下拉刷新效果,当然公司代码肯定比这要好几十倍...顺便加一句:吃喝玩乐,记得上美团外卖哦~
虽然本篇也属于简单的自定义View,但是要做的工作比较多,打算还是通过两篇文章完成。第一篇先完成准备工作,即自定义头布局View;第二篇就实现真正的下拉刷新加载数据。(代码会在两篇文章写完之后贴出来)。
看一下美团客户端的下拉刷新效果:
本系列文章自定义控件最终会实现如下效果:
首先说一下思路:
仿美团客户端下拉刷新分为三个状态:
第一个状态为下拉刷新状态(pull to refresh),在这个状态下是一个绿色的椭圆随着下拉的距离动态改变其大小。
第二个部分为放开刷新状态(release to refresh),在这个状态下是一个帧动画,效果为从躺着变为站起来的动画。
第三个部分为刷新状态(refreshing),在这个状态下也是一个帧动画,是摇头的动画。
其中第二和第三个状态很简单,就是两个帧动画,第一个状态我们可以用自定义View来实现。
我们这里通过快速点击执行帧动画的图片模拟一下下拉刷新和正在刷新状态:
下拉刷新:
正在刷新:
第一个状态的实现:
思路是:当前这个椭圆形有一个进度值,这个进度值从0变为1,然后对这个椭圆形进行缩放,可以使用自定义View来实现这个效果,我们先来用一个SeekBar来模拟一下下拉距离的进度这个效果是下面这个样子:
为了看得更清楚,在这个控件下面加个一层粉红背景。
那么就先自定义第一个控件:
public class RefreshFirstStepView extends View {
private static final String TAG = "MainActivity";
private floatmProgress;
public RefreshFirstStepView(Context context) {
this(context,null);
}
public RefreshFirstStepView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e(TAG,mProgress+"");
}
public void setProgress(float progress) {
mProgress = progress;
}
}
这个控件基本没做任何处理,只是提供了一个setProgress方法,把一个进度值传进来了。
然后在MainActivity的布局文件中使用:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:orientation="vertical"
tools:context="com.itydl.a05.MainActivity">
<SeekBar
android:id="@+id/sb"
android:layout_marginBottom="50dp"
android:layout_marginTop="20dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<com.itydl.a05.view.RefreshFirstStepView
android:id="@+id/lv_first"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
</com.itydl.a05.view.RefreshFirstStepView>
</LinearLayout>
这里很简单,线性布局上面一个SeekBar用来控制进度,下面就是自定义的RefreshFirstStepView
然后在MainActivity中:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private SeekBar mSeekBar;
private RefreshFirstStepView mStepView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mSeekBar = (SeekBar) findViewById(R.id.sb);
mStepView = (RefreshFirstStepView) findViewById(R.id.lv_first);
mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
// Log.e(TAG,progress+"");
float currentProgress = (float) progress / (float) seekBar.getMax();//转换为0.0--->1.0的进度值
mStepView.setProgress(currentProgress);
mStepView.postInvalidate();//重绘-->该控件的onDraw就会被重新调用
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});
}
}
通过监听SeekBar的进度状态,把进度值传给刚才的RefreshFirstStepView,然后调用RefreshFirstStepView的重绘方法,这样RefreshFirstStepView的onDraw方法就会得到执行。就把进度设置到了控件当中了。拖拽SeekBar,看Log打印情况:
进度拿到了,待会我们会使用这个进度值来对下拉刷新状态的view做动画处理。
然后就是自定义View的代码了:(代码后面有详细的解析)
public class RefreshFirstStepView extends View {
private static final String TAG = "RefreshFirstStepView";
private float mProgress;//模拟进度
private Bitmap mInitialBitmap;//第一个状态的图片
private Bitmap mEndBitmap;//第二三状态的图片
private int mWidth;//第二三状态的宽度
private int mHeight;//第二三状态的高度
private int mMeasuredWidth;//当前控件的宽度
private int mMeasuredHeight;//当前控件的高度
private Bitmap scaledBitmap;//进行缩放后的图片
public RefreshFirstStepView(Context context) {
this(context,null);
}
public RefreshFirstStepView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
/**
* 初始化
*/
private void init() {
//这个就是那个椭圆形图片
mInitialBitmap = Bitmap.createBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.pull_image));
//这个是第二个状态娃娃的图片,之所以要这张图片,是因为第二个状态和第三个状态的图片的大小是一致的,而第一阶段
//椭圆形图片的大小与第二阶段和第三阶段不一致,因此我们需要根据这张图片来决定第一张图片的宽高,来保证
//第一阶段和第二、三阶段的View的宽高一致。
mEndBitmap = Bitmap.createBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.pull_end_image_frame_05));
mWidth = mEndBitmap.getWidth();
mHeight = mEndBitmap.getHeight();
// Log.e(TAG,width+"--===---"+mHeight);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureWidth(widthMeasureSpec),measureWidth(widthMeasureSpec)*mHeight/mWidth);
mMeasuredWidth = getMeasuredWidth();
mMeasuredHeight = getMeasuredHeight();
// Log.e(TAG,mMeasuredWidth+"-----"+mMeasuredHeight);
}
/**
* 获取控件的宽度,如果布局文件中为match_parent或者写死则大小就为这个值,如果是wrap_content,则测量第二张图片的宽度值设置给当前的控件宽度
* @return
* @param widthMeasureSpec
*/
public int measureWidth(int widthMeasureSpec) {
int result = 0;
int size = MeasureSpec.getSize(widthMeasureSpec);
int mode = MeasureSpec.getMode(widthMeasureSpec);
if(mode == MeasureSpec.EXACTLY){//精确地
result = size;
}else{
result = mWidth;//
if(mode == MeasureSpec.AT_MOST){//如果是wrap_content,取最小值
result = Math.min(result,size);
}
}
return result;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
//根据第二阶段娃娃宽高 给椭圆形图片进行等比例的缩放
/**
* ①src 对资源src进行缩放
②dstWidth 缩放成宽dstWidth
③dstHeight 缩放成高dstHeight
④filter 过滤
如果是放大图片,filter决定是否平滑,如果是缩小图片,filter无影响.一般设置为true即可
*/
scaledBitmap = Bitmap.createScaledBitmap(mInitialBitmap, mMeasuredWidth,mMeasuredWidth*mInitialBitmap.getHeight()/mInitialBitmap.getWidth(), true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//将等比例缩放后的椭圆形画在画布上面,第四个参数为null,表示图片不使用Paint
canvas.drawBitmap(scaledBitmap,0,mMeasuredHeight/4,null);
}
/**
* 设置缩放比例,从0到1 0为最小 1为最大
* @param progress
*/
public void setProgress(float progress) {
mProgress = progress;
}
}
代码有点多,一点点分析实现过程:
要知道这里总共三张图片:
图片1:第一个状态的图片:文中定义为mInitialBitmap,它是最原始的图片BitMap实例,对图片压缩也是使用这个实例。
图片2:第二三状态的图片:由于图片1的宽高跟第二三状态的宽高不一致,为了保持三个状态的view高度一致,就引入了这个图片,他的作用就是为了测量自己的宽高,从而去设置咱们本自定义View的宽高的(待会给代码再来解释)。这里定义为mEndBitmap
图片3:通过对图片一mInitialBitmap进行等比例压缩后的图片,最终绘制以及根据手势缩放的图片也是它,定义为scaledBitmap。
然后看一下初始化的方法:
private void init() {
//这个就是那个椭圆形图片
mInitialBitmap = Bitmap.createBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.pull_image));
//这个是第二个状态娃娃的图片,之所以要这张图片,是因为第二个状态和第三个状态的图片的大小是一致的,而第一阶段
//椭圆形图片的大小与第二阶段和第三阶段不一致,因此我们需要根据这张图片来决定第一张图片的宽高,来保证
//第一阶段和第二、三阶段的View的宽高一致。
mEndBitmap = Bitmap.createBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.pull_end_image_frame_05));
mWidth = mEndBitmap.getWidth();
mHeight = mEndBitmap.getHeight();
// Log.e(TAG,width+"--===---"+mHeight);
}
这里就是通过Bitmap.createBitmap()方法得到资源文件中的图片转为Bitmap对象。分别拿到第一二状态图,并直接获取第二状态图片的宽高。
然后是测量方法:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureWidth(widthMeasureSpec),measureWidth(widthMeasureSpec)*mHeight/mWidth);
mMeasuredWidth = getMeasuredWidth();
mMeasuredHeight = getMeasuredHeight();
// Log.e(TAG,mMeasuredWidth+"-----"+mMeasuredHeight);
}
测量方法就是测量自己控件RefreshFirstStepView 的大小的。这里抽取了一个方法进行直接测量:
public int measureWidth(int widthMeasureSpec) {
int result = 0;
int size = MeasureSpec.getSize(widthMeasureSpec);
int mode = MeasureSpec.getMode(widthMeasureSpec);
if(mode == MeasureSpec.EXACTLY){//精确地
result = size;
}else{
result = mWidth;//
if(mode == MeasureSpec.AT_MOST){//如果是wrap_content,取最小值
result = Math.min(result,size);
}
}
return result;
}
这里注释解释的也很清楚:获取控件的宽度,如果布局文件中为match_parent或者写死则大小就为咱写死的这个值,如果是wrap_content,则测量第二张图片的宽度值大小设置给当前的控件的宽度。再回到测量方法,高度是通过宽高成比例来设置的高度大小。(始终记得,这里是通过状态二的图片来设置当前控件,所以比例计算也是需要通过状态二图片的宽高参与计算的)。
然后进入onLayout方法:
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
//根据第二阶段娃娃宽高 给椭圆形图片进行等比例的缩放
/**
* ①src 对资源src进行缩放
②dstWidth 缩放成宽dstWidth
③dstHeight 缩放成高dstHeight
④filter 过滤
如果是放大图片,filter决定是否平滑,如果是缩小图片,filter无影响.一般设置为true即可
*/
scaledBitmap = Bitmap.createScaledBitmap(mInitialBitmap, mMeasuredWidth,mMeasuredWidth*mInitialBitmap.getHeight()/mInitialBitmap.getWidth(), true);
}
这里就一行代码,其实这行代码不写在这里也可以的,这就话就是对状态一图片进行等比例缩放的,使用到了Bitmap.createScaledBitmap()方法,这里缩放是针对状态一图片的,缩放成的宽度就设置为咱们自定义 RefreshFirstStepView 的宽度(其实也就是状态二的宽度),而高度,就不是咱们自定义控件RefreshFirstStepView 的高度了,是通过等比例计算,计算得到状态一图片的高度。由于这里是针对状态一图片做等比例缩放,所以在根据宽度计算高度的时候,参与运算的是状态一图片即mInitialBitmap来计算的,最终得到scaledBitmap。
最后,在onDraw里面:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//将等比例缩放后的椭圆形画在画布上面,第四个参数为null,表示图片不使用Paint
canvas.drawBitmap(scaledBitmap,0,mMeasuredHeight/4,null);
}
这里也只是一行代码,通过drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint)来绘制图片,要绘制的图片是scaledBitmap,然后参数2和3表示距离父控件的left和top值,这里让它紧贴父亲左边眼,上面距离父控件为父控件的1/4,不需要画笔传入null。
然后运行程序:
这样就把缩放的图片绘制上去了。
那么接下来就是通过拖拽seekBar,模拟美团下拉效果了,其实一行代码就搞定了,在onDraw里面加入如下:
//对图片的绘制
//这个方法是对画布进行缩放,从而达到椭圆形图片的缩放。第一个参数为宽度缩放比例,第二个参数为高度缩放比例,参数3和4都是坐标,表示沿着中心点缩放
canvas.scale(mProgress, mProgress, mMeasuredWidth/2, mMeasuredHeight/2);
canvas.scale()方法专门对目标图片进行缩放的,参数一二都是当前图片的比例大小,参数三四就是缩放点,这里设置围绕中心点缩放。那么运行程序:
第二个状态的实现:
public class RefreshSecondStepView extends View {
private Bitmap mEndBitmap;
private int mWidth;//第二三状态的宽度
private int mHeight;//第二三状态的高度
public RefreshSecondStepView(Context context) {
this(context,null);
}
public RefreshSecondStepView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
/**
* 初始化
*/
private void init() {
mEndBitmap = Bitmap.createBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.pull_end_image_frame_05));
mWidth = mEndBitmap.getWidth();
mHeight = mEndBitmap.getHeight();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureWidth(widthMeasureSpec),measureWidth(widthMeasureSpec)*mHeight/mWidth);
}
/**
* 获取控件的宽度,如果布局文件中为match_parent或者写死则大小就为这个值,如果是wrap_content,则测量第二张图片的宽度值设置给当前的控件宽度
* @return
* @param widthMeasureSpec
*/
public int measureWidth(int widthMeasureSpec) {
int result = 0;
int size = MeasureSpec.getSize(widthMeasureSpec);
int mode = MeasureSpec.getMode(widthMeasureSpec);
if(mode == MeasureSpec.EXACTLY){//精确地
result = size;
}else{
result = mWidth;//
if(mode == MeasureSpec.AT_MOST){//如果是wrap_content,取最小值
result = Math.min(result,size);//取size和第二张图片的最小值
}
}
return result;
}
}
第三个状态的实现:
只需要把RefreshSecondStepView 修改为RefreshThirdStepView其他没有任何变化。
这两个状态开始也说了,是最最简单的状态。这两个控件的宽高跟第一个控件的宽高都是一致的。代码十分简单,而且明白了第一个状态View,这两个也没有必要再去讲了。
给第二三状态加入动画:
这里只针对第二状态去讲解:
定义一组帧动画,由于动画是当做某个图片的背景的,因此应该定义在res/drawable目录下面:
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="true">
<item android:drawable="@drawable/pull_end_image_frame_01" android:duration="100"/>
<item android:drawable="@drawable/pull_end_image_frame_02" android:duration="100"/>
<item android:drawable="@drawable/pull_end_image_frame_03" android:duration="100"/>
<item android:drawable="@drawable/pull_end_image_frame_04" android:duration="100"/>
<item android:drawable="@drawable/pull_end_image_frame_05" android:duration="100"/>
</animation-list>
帧布局没什么可说的,注意oneshot设置为true表示只播放一次动画就结束,设置为false表示动画没有手动停止,就一直循环播放。然后在代码中使用这个帧布局通过如下方式:
mSecondView = (RefreshSecondStepView) findViewById(R.id.lv_second);
mSecondView.setBackgroundResource(R.drawable.pull_to_refresh_second_anim);
secondAnim = (AnimationDrawable) mSecondView.getBackground();
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//启动
secondAnim.start();
}
});
如果想要关闭动画,直接调用stop()方法即可。
效果如下:
对于第三个状态跟第二个状态几乎一模一样,就不再去浪费大家时间了。
好了,本篇文章总共实现了三种状态,以及对应状态的动画处理。下一篇也就是仿美团下拉刷新效果的完结篇。
如果觉得对你有帮助,加个关注呗。也可以关注微信公众号的哈,内容更丰富。