本文共 15095 字,大约阅读时间需要 50 分钟。
在 Dribble 上偶然看到了一组交互如下:
这里写图片描述当时在心里问自己能不能做,答案肯定是能做的,不过我比较懒,觉得中间那个伸缩变化要编写很多代码,所以懒得理。后来,为了不让自己那么浮躁,也为了锻炼自己的耐心程度,还是坚持实现它了。这个过程,觉得自己还是有所收获,把握了一些想当然的细节,输理了对于自定义 View 的流程。
我将这个自定义 View,起了一个名字叫做 LoadButton。
这篇文章涉及到的知识点有如下:
先观察 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。
我们在 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/