博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
自定义View实战--实现一个清新美观的加载按钮
阅读量:3936 次
发布时间:2019-05-23

本文共 15095 字,大约阅读时间需要 50 分钟。

在 Dribble 上偶然看到了一组交互如下:

这里写图片描述

当时在心里问自己能不能做,答案肯定是能做的,不过我比较懒,觉得中间那个伸缩变化要编写很多代码,所以懒得理。后来,为了不让自己那么浮躁,也为了锻炼自己的耐心程度,还是坚持实现它了。这个过程,觉得自己还是有所收获,把握了一些想当然的细节,输理了对于自定义 View 的流程。

我将这个自定义 View,起了一个名字叫做 LoadButton。

这篇文章涉及到的知识点有如下:

  1. 自定义 View 时的基本流程,包含 attrs.xml 中属性的编写,构造方法中属性的获取,onMeasure() 中尺寸的测量。onDraw() 中界面的实现。
  2. 可以让 Android 初学者再次感受一次回调机制的美妙。
  3. 属性动画的基本使用。
    第一步,先确定尺寸

先观察 LoadView 的形态。

这里写图片描述

上面的显示的是两种形状,一个是圆角矩形,另外一个就是圆。两个形态尺寸区别是,高相同,宽度不一致。

我们再进一步分析形态 1。

这里写图片描述

形态 1 可以看成是左右两个半圆和中间一个矩形。再回顾下示例图片中的动画表现。

这里写图片描述

圆角矩形最终变成了一个圆。我们可以用线框图来渐进表现它。

这里写图片描述

当进行动画时,中间的矩形部分不停地缩小,当它缩小为 0 时,形态 1 就转变成了形态 2。

上面的能够说明什么呢?说明 LoadButton 由 3 个部分组成,左右的半圆和中间的矩形,即使是形态 2 也可以看做是左右半圆和中间宽度为 0 的矩形组成。

这里写图片描述
细化尺寸

我们进一步讨论尺寸相关的情况。

我们知道对于普通开发者而言,自定义一个 View 测量尺寸的时候我们通常要关注的测量模式是 MeasureSpec.EXACTLY 和 MeasureSpec.AT_MOST 两种。要了解更多详细的信息可以阅读我写的这篇博文《长谈:关于 View Measure 测量机制,让我一次把话说完》。接下来,我们详细讨论一下这两种情况。

MeasureSpec.EXACTLY

当一个 View 的 layout_width 或者 layout_height 的取值为 match_parent 或 30dp 这样具体的数值时,这就表明它的测量模式是 MeasureSpec.EXACTLY。它已经获得了精确的数值了,按照常理我们是不应该再去干涉它,parent 给出的建议尺寸是什么,我们就把尺寸设置成什么,但是结合开发的实际情况来看,我们有一个底线,为了保证 LoadView 的完整性,也就是再差的情况下,parent 给出来的建议尺寸也不能小于形态 2。否则如下图情况就不是我们想要的了

这里写图片描述
MeasureSpec.AT_MOST

当一个 View 的 layout_width 或者 layout_height 的取值为 wrap_content 时,它的测量模式就是 MeasureSpec.AT_MOST,这个时候我们需要自己根据内容计算尺寸。而 LoadButton 的内容是什么呢?它的内容有 text 还有 加载成功或者加载失败的图片。因为图片大小在形态 2 中的圆形内可以确认。所以问题的关键就在于 LoadButton 文字内容宽高的尺寸测量。

这里写图片描述

text 内容自然是居中显示,然后它距离中间的 rect 上下左右间距也要考虑。这个时候的 rect 尺寸就是相对应的文字尺寸加上相对应方向上的 padding 值,这些 padding 值通过在 attrs.xml 中自定义属性然后在布局文件中赋予。

最后整体 LoadButton 尺寸自然是中间 rect 加上左右两个半圆的半径,但是这还不是最终的尺寸,最终的尺寸还是要和 parent 给的建议尺寸比较,不能大于它。

上面分析了尺寸测量相关,所以顺着思路进行的话,编码也只是水到渠成的事情了。

public class LoadButton extends View {

@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);

int heightSize = MeasureSpec.getSize(heightMeasureSpec);int heightMode = MeasureSpec.getMode(heightMeasureSpec);//用于保存最终尺寸int resultW = widthSize;int resultH = heightSize;// contentW contentH 用于确定中间矩形的尺寸int contentW = 0;int contentH = 0;if ( widthMode == MeasureSpec.AT_MOST ) {    mTextWidth = (int) mTextPaint.measureText(mText);    contentW += mTextWidth + mLeftRightPadding * 2 + mRadiu * 2;    resultW = contentW < widthSize ? contentW : widthSize;}if ( heightMode == MeasureSpec.AT_MOST ) {    contentH += mTopBottomPadding * 2 + mTextSize;    resultH = contentH < heightSize ? contentH : heightSize;}resultW = resultW < 2 * mRadiu ? 2 * mRadiu : resultW;resultH = resultH < 2 * mRadiu ? 2 * mRadiu : resultH;// 修整圆形的半径mRadiu = resultH / 2;// 记录中间矩形的宽度值rectWidth = resultW - 2 * mRadiu;setMeasuredDimension(resultW,resultH);Log.d(TAG,"onMeasure: w:"+resultW+" h:"+resultH);

}

}

第二步,绘制

测量是在 onMeasure() 方法中进行,而绘制就是在 onDraw() 方法中进行的,这是 Android 开发者都知道的事情。所以这一节的重点在于 onDraw() 这个方法。

为了不给读者造成困扰,我先张贴自定的属性,及在构造方法中获取属性值的代码。其它的细节应该看名字就大概知道了。
attrs.xml

<?xml version="1.0" encoding="utf-8"?>

然后在 LoadButton 的构造方法中获取这些值。

public LoadButton(Context context, AttributeSet attrs, int defStyleAttr) {

super(context, attrs, defStyleAttr);

mDefaultRadiu = 40;    mDefaultTextSize = 24;    TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.LoadButton);    mTextSize = typedArray.getDimensionPixelSize(R.styleable.LoadButton_android_textSize,            mDefaultTextSize);    mStrokeColor = typedArray.getColor(R.styleable.LoadButton_stroke_color, Color.RED);    mTextColor = typedArray.getColor(R.styleable.LoadButton_content_color, Color.WHITE);    mText = typedArray.getString(R.styleable.LoadButton_android_text);    mRadiu = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_radiu,mDefaultRadiu);    mTopBottomPadding = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_contentPaddingTB,10);    mLeftRightPadding = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_contentPaddingLR,10);    mBackgroundColor = typedArray.getColor(R.styleable.LoadButton_backColor,Color.WHITE);    mProgressColor = typedArray.getColor(R.styleable.LoadButton_progressColor,Color.WHITE);    mProgressSecondColor = typedArray.getColor(R.styleable.LoadButton_progressSecondColor,Color.parseColor("#c3c3c3"));    mProgressWidth = typedArray.getDimensionPixelOffset(R.styleable.LoadButton_progressedWidth,2);    mSuccessedDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadSuccessDrawable);    mErrorDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadErrorDrawable);    mPauseDrawable = typedArray.getDrawable(R.styleable.LoadButton_loadPauseDrawable);    typedArray.recycle();    ......

}

形态 1 的绘制,借助于 Path 的力量

Android 绘制图形离不开 Canvas,Canvas 可以直接绘制 直线、矩形、圆、椭圆,但是 LoadButton 的形态 1 怎么绘制呢?它是一个不规则的闭合图形,直接用 Canvas 的话肯定不行,所以得借助另外一个类 Path,Path 中文译做路径,可以专门处理这种情况,而且可以处理比这复杂的情况,具体情况请读者们自己查阅相应资料与教程。

我们再来观察 形态 1 到形态 2 的转变过程。

这里写图片描述

这是个中间矩形从初始值变为 0 的过程,我们用 rectWidth 表示这个矩形的宽度值,因为在 onDraw() 方法中,LoadButton 尺寸确定,所以我们很容易得到它的中心点,所以我们可以中心点坐标为参考坐标,然后以 rectWidth 为变量创建一个 path,这个 path 实现了 LoadButton 的轮廓。

@Override

protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int cx = getWidth() / 2;int cy = getHeight() / 2;drawPath(canvas,cx,cy);.....

}

private void drawPath(Canvas canvas,int cx,int cy) {

if (mPath == null) {
mPath = new Path();
}

mPath.reset();left = cx - rectWidth / 2 - mRadiu;top = 0;right = cx + rectWidth / 2 + mRadiu;bottom = getHeight();leftRect.set(left,top,left + mRadiu * 2,bottom);rightRect.set(right - mRadiu * 2,top,right,bottom);contentRect.set(cx-rectWidth/2,top,cx + rectWidth/2,bottom);//path 起始位置mPath.moveTo(cx - rectWidth /2,bottom);// 左边半圆mPath.arcTo(leftRect,        90.0f,180f);//连接到右边半圆mPath.lineTo(cx + rectWidth/2,top);// 右边半圆mPath.arcTo(rightRect,        270.0f,180f);// path 闭合mPath.close();// 以填充的方向将图形填充为指定的背景色mPaint.setStyle(Paint.Style.FILL);mPaint.setColor(mBackgroundColor);canvas.drawPath(mPath,mPaint);mPaint.setStyle(Paint.Style.STROKE);mPaint.setColor(mStrokeColor);

}

50

以 rectWidth 为变量建立 path 的好处时,当从形态 1 到 形态 2 转变的过程,肯定是 rectWidth 数值变化的过程,而对于其它数值是不变的,所以重绘的时候 LoadButton 能够很轻松地处理这种情况。

我们到这一步的时候已经能够准确地绘制了 LoadButton 的轮廓。现在需要精确地绘制它的内容,只有这样才是完整的 LoadButton。

我们先需要给 LoadButton 定义一些状态。

LoadButton 的状态

enum State {

INITIAL,// 初始状态
FOLDING,// 正在伸缩
LOADING, // 正在加载
ERROR,// 加载失败
SUCCESSED,// 加载成功
PAUSED // 加载暂停
}

这里写图片描述

它们的状态转换如下:

这里写图片描述

LoadButton 的状态转换由用户点击按钮触发。所以 LoadButton 需要在内部设置一个 OnClickListenner。

  1. 当在 Initial 状态下点击时,它会转换到 Folding 状态下。
  2. Foding 状态结束后,由形态 1 转变成形态 2。自然就进入了 Loading 状态。
  3. Loading 状态有 3 个走向,加载成功后,用户通过相应 API 设置状态为 Successed。加载失败后,用户可以设置状态为 Error。如果在 Loading 状态下点击按钮,会进入 Paused 状态。
  4. 在 Paused 状态下点击按钮,LoadButton 重新进入 Loading 状态。
  5. 在 Successed 或者 Error 状态下点击按钮,将通过回调对象,通知调用者点击事件的发生。

我们在 LoadButton 的构造方法中设置这样的内部的 OnClickListenner。

public LoadButton(Context context, AttributeSet attrs, int defStyleAttr) {

super(context, attrs, defStyleAttr);

......isUnfold = true;mListenner = new OnClickListener() {    @Override    public void onClick(View v) {        if ( mCurrentState == State.FODDING) {            return;        }        if ( mCurrentState == State.INITIAL ) {            if ( isUnfold ) {                shringk();            }        } else if ( mCurrentState == State.ERROR) {            if (mLoadListenner != null ) {                mLoadListenner.onClick(false);            }        } else if ( mCurrentState == State.SUCCESSED ) {            if (mLoadListenner != null ) {                mLoadListenner.onClick(true);            }        } else if ( mCurrentState == State.PAUSED) {            if (mLoadListenner != null ) {                mLoadListenner.needLoading();                load();            }        } else if ( mCurrentState == State.LOADDING) {            mCurrentState = State.PAUSED;            cancelAnimation();            invaidateSelft();        }    }};setOnClickListener(mListenner);mCurrentState = State.INITIAL;......

}

状态的绘制

Initial 状态下其实就是中间一个 text 文本居中显示,相关代码如下:

@Override

protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

int textDescent = (int) mTextPaint.getFontMetrics().descent;int textAscent = (int) mTextPaint.getFontMetrics().ascent;int delta = Math.abs(textAscent) - textDescent;if ( mCurrentState == State.INITIAL) {    canvas.drawText(mText,cx,cy + delta / 2,mTextPaint);} .....

}

Folding 状态其实就是不显示文字的 Inital 状态,不同的还有它的 rectwidth 每次重绘时会变小,最终会由 Initial 的形态 1 过渡到 Loading 状态下的形态 2。在 Initial 状态下点击按钮会调用一个动画,这个动画用于展示形态 1 到形态 2 的过程。

if ( mCurrentState == State.INITIAL ) {

if ( isUnfold ) {
shringk();
}
}

public void shringk() {

if (shrinkAnim == null) {
shrinkAnim = ObjectAnimator.ofInt(this,“rectWidth”, rectWidth,0);
}
shrinkAnim.addListener(this);

shrinkAnim.setDuration(500);shrinkAnim.start();mCurrentState = State.FOLDING;

}

public void setRectWidth (int width) {

rectWidth = width;
invaidateSelft();
}

private void invaidateSelft() {

if (Looper.myLooper() == Looper.getMainLooper()) {
invalidate();
} else {
postInvalidate();
}
}
这里是一个典型的属性动画应用场景,通过不断改变属性 rectWidth 的值来进行重绘,而对于绘制这一方面,文章前面部分有说过 LoadButton 通过以中心坐标为参考,以 mRectWidth 为变量建立了一个 Path 来绘制轮廓。

另外,大家可以注意到,shrinkAnim 有一个监听器,我设置为了 LoadButton 本身。

public class LoadButton extends View implements Animator.AnimatorListener {

@Override
public void onAnimationStart(Animator animation) {

}@Overridepublic void onAnimationEnd(Animator animation) {    isUnfold = false;    load();}@Overridepublic void onAnimationCancel(Animator animation) {}@Overridepublic void onAnimationRepeat(Animator animation) {}

}

在收缩动画结束的时候,我调用了 load() 方法用来将状态设置为 Loading,并进行加载动画。

我们先看看 Loading 状态下的绘制,它是形态 2 ,也就是在一个圆形内有一个正在加载无限循环的动画。思路也很简单,用进度条的背景色画一个圆圈,然后用进度条的前景色绘制相应角度的弧,并且这个弧的半径和进度条的半径一样。

if ( mCurrentState == State.LOADING) {

if ( progressRect == null ) {    progressRect = new RectF();}progressRect.set(cx - circleR,cy - circleR,cx + circleR,cy + circleR);mPaint.setColor(mProgressSecondColor);//先绘制背景圆canvas.drawCircle(cx,cy,circleR,mPaint);mPaint.setColor(mProgressColor);Log.d(TAG,"onDraw() pro:"+progressReverse+" swpeep:"+circleSweep);if ( circleSweep != 360 ) {    mProgressStartAngel = progressReverse ? 270 : (int) (270 + circleSweep);    //绘制弧线    canvas.drawArc(progressRect    ,mProgressStartAngel,progressReverse ? circleSweep : (int) (360 - circleSweep),            false,mPaint);}mPaint.setColor(mBackgroundColor);

}

123456722

上面有两个关键的变量 progressReverse 和 circleSweep。progressReverse 用来表示动画是否需要翻转,circleSweep 表示每次绘制的时候从起始角度扫描的角度。

正常情况下,起始角度是 270 度不变,如果动画翻转时,它是 270 + circleSweep 的值,具体为什么这样做,大家可以观看之前的图像来思考一下。
加载的动画自然也是属性动画控制的,这个动画让 circleSweep 从 0 到 360 之间不停地变化。并且在每次循环的时候,将 progressReverse 变量置反。

public void load() {

if (loadAnimator == null) {
loadAnimator = ObjectAnimator.ofFloat(this,“circleSweep”,0,360);
}

loadAnimator.setDuration(1000);loadAnimator.setRepeatMode(ValueAnimator.RESTART);loadAnimator.setRepeatCount(ValueAnimator.INFINITE);loadAnimator.removeAllListeners();loadAnimator.addListener(new Animator.AnimatorListener() {    @Override    public void onAnimationStart(Animator animation) {    }    @Override    public void onAnimationEnd(Animator animation) {    }    @Override    public void onAnimationCancel(Animator animation) {    }    @Override    public void onAnimationRepeat(Animator animation) {

// Log.d(TAG,“onAnimationRepeat:”+progressReverse);

progressReverse = !progressReverse;
}
});
loadAnimator.start();
mCurrentState = State.LOADING;
}

Paused 状态是当 LoadButton 在 Loading 状态下,用户点击了按钮,这个时候按钮会显示一个暂停图标。

if ( mCurrentState == State.LOADING) {

mCurrentState = State.PAUSED;
cancelAnimation();
invaidateSelft();
}

至于显示方面,非常简单就是给一个 drawable 设置好 bound 范围然后显示。稍后我会给出代码。

Successed 状态和 Error 状态实现过程基本上是一致的。但是它们被点击的时候,需要通知点击者。所以我们需要定义一个回调接口。

if ( mCurrentState == State.ERROR) {

if (mLoadListenner != null ) {    mLoadListenner.onClick(false);}

} else if ( mCurrentState == State.SUCCESSED ) {

if (mLoadListenner != null ) {
mLoadListenner.onClick(true);
}
} else if ( mCurrentState == State.PAUSED) {
if (mLoadListenner != null ) {
mLoadListenner.needLoading();
load();
}
}else if ( mCurrentState == State.PAUSED) {
if (mLoadListenner != null ) {
mLoadListenner.needLoading();
load();
}
}

public interface LoadListenner {

void onClick(boolean isSuccessed);void needLoading();

}

LoadListenner.onClick() 方法中的参数,isSuccessed 为真告诉点击者加载成功了的信息。否则提示加载失败。needLoading() 方法用来告诉点击者当在 Paused 状态下点击按钮时,调用者应该重新加载了。

它们的显示代码如下:

if ( mCurrentState == State.ERROR) {

mErrorDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mErrorDrawable.draw(canvas);
} else if (mCurrentState == State.SUCCESSED) {
mSuccessedDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mSuccessedDrawable.draw(canvas);
} else if (mCurrentState == State.PAUSED) {
mPauseDrawable.setBounds(cx - circleR,cy - circleR,cx + circleR,cy + circleR);
mPauseDrawable.draw(canvas);
}

另外,需要注意的是 Successed 和 Error 状态,需要开发者根据实际情况决定调用。

public void loadSuccessed() {

mCurrentState = State.SUCCESSED;
cancelAnimation();
invaidateSelft();
}

public void loadFailed() {

mCurrentState = State.ERROR;
cancelAnimation();
invaidateSelft();
}

将 LoadButton 重置为 Initial 状态用 reset() 方法。

public void reset(){

mCurrentState = State.INITIAL;
rectWidth = getWidth() - mRadiu * 2;
isUnfold = true;
cancelAnimation();
invaidateSelft();
}

到此,整个 LoadButton 实现逻辑已经完成。接下来我们可以编写代码测试。

测试

我们添加一个 LoadButton 到布局文件,然后用 3 个 Button 来测试它成功、失败、重置的情况。

布局文件

<?xml version="1.0" encoding="utf-8"?>

MainActivity.java

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

LoadButton mLoadButton;Button mBtnSuccessed,mBtnError,mBtnReset;@Overrideprotected void onCreate(Bundle savedInstanceState) {    super.onCreate(savedInstanceState);    setContentView(R.layout.activity_main);    mLoadButton = (LoadButton) findViewById(R.id.btn_status);    mBtnSuccessed = (Button) findViewById(R.id.btn_test_successed);    mBtnError = (Button) findViewById(R.id.btn_test_error);    mBtnReset = (Button) findViewById(R.id.btn_reset);    mBtnError.setOnClickListener(this);    mBtnSuccessed.setOnClickListener(this);    mBtnReset.setOnClickListener(this);    mLoadButton.setListenner(new LoadButton.LoadListenner() {        @Override        public void onClick(boolean isSuccessed) {            if ( isSuccessed ) {                Toast.makeText(MainActivity.this,"加载成功",Toast.LENGTH_LONG).show();            } else {                Toast.makeText(MainActivity.this,"加载失败",Toast.LENGTH_LONG).show();            }        }        @Override        public void needLoading() {            Toast.makeText(MainActivity.this,"重新下载",Toast.LENGTH_LONG).show();        }    });}@Overridepublic void onClick(View v) {    switch (v.getId())    {        case R.id.btn_test_successed:            mLoadButton.loadSuccessed();            break;        case R.id.btn_test_error:            mLoadButton.loadFailed();            break;        case R.id.btn_reset:            mLoadButton.reset();            break;        default:            break;    }}

}

测试结果:

这里写图片描述
总结

本文的主题并不难,但是如果要实现它也需要细心。关键是编码的时候,要先设计分析,之后就是一气呵成、水到渠成的事情了。

通过演练这个项目,我觉得自己还是有些收获。

复习了自定义 View 的基本流程。特别是对 onMeasure() 这一块有更深的理解。复习了属性动画的使用。复习了 Canvas 和 Path 的基本用法。演练了状态模式下的编程。享受回调机制带来的美妙感受。

如果有人认为好用,我想把它上传到 jcenter 仓库,目的也是为了演练怎么上传 Android 模块到开源库。喜欢这篇文章就给我一个赞吧,需要你们的鼓励。哈哈。

完整代码github地址

转载地址:http://ubegn.baihongyu.com/

你可能感兴趣的文章
Linux(Ubuntu18)中启动ssh时的报错
查看>>
Java中的左移时的负数问题
查看>>
从数组形式创建一棵树(用于leetcode测试)
查看>>
线程进阶:多任务处理(17)——Java中的锁(Unsafe基础)
查看>>
Spring/Boot/Cloud系列知识(1)——开篇
查看>>
线程基础:多任务处理(15)——Fork/Join框架(要点2)
查看>>
线程基础:多任务处理(16)——Fork/Join框架(排序算法性能补充)
查看>>
线程基础:多任务处理(14)——Fork/Join框架(要点1)
查看>>
架构设计:系统存储(13)——MySQL横向拆分与业务透明化(1)
查看>>
架构设计:系统存储(14)——MySQL横向拆分与业务透明化(2)
查看>>
架构设计:系统存储(5)——MySQL数据库性能优化(1)
查看>>
架构设计:系统存储(2)——块存储方案(2)
查看>>
架构设计:系统间通信(45)——阶段性问题记录
查看>>
架构设计:系统间通信(44)——自己动手设计ESB(5)
查看>>
架构设计:系统存储(1)——块存储方案(1)
查看>>
架构设计:系统间通信(42)——自己动手设计ESB(3)
查看>>
在工作时走神的闪光
查看>>
Ajax使用注意事项
查看>>
使用FileChannel下载本地文件及扩展
查看>>
linux文件权限与目录配置问题与解答(整理篇)
查看>>