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

自定义View实战--实现一个清新美观的加载按钮

程序员文章站 2022-07-14 20:54:33
...

在 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) {

}

@Override
public void onAnimationEnd(Animator animation) {
    isUnfold = false;
    load();
}

@Override
public void onAnimationCancel(Animator animation) {

}

@Override
public 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);

}

1
2
3
4
5
6
7

22

上面有两个关键的变量 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"?>

<com.frank.statusbuttondemo.LoadButton
    android:id="@+id/btn_status"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:backColor="#009966"
    app:contentPaddingLR="20dp"
    app:contentPaddingTB="20dp"
    app:content_color="@android:color/white"
    app:progressedWidth="4dp"
    android:textSize="36sp"
    android:text="点击加载" />
<Button
    android:id="@+id/btn_test_successed"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="20dp"
    android:text="加载成功"/>
<Button
    android:id="@+id/btn_test_error"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="加载失败"/>
<Button
    android:id="@+id/btn_reset"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="重置按钮"/>

MainActivity.java

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

LoadButton mLoadButton;
Button mBtnSuccessed,mBtnError,mBtnReset;

@Override
protected 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();
        }
    });


}

@Override
public 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地址https://github.com/frank909zhao/LoadButton