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

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

在 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"?>
<resources>
    <declare-styleable name="LoadButton">
        <attr name="android:text" />
        <attr name="android:textSize" />
        <attr name="stroke_color" format="color|reference" />
        <attr name="content_color" format="color|reference" />
        <attr name="radiu" format="dimension|reference" />
        <attr name="rectwidth" format="dimension|reference" />
        <attr name="contentPaddingLR" format="dimension|reference" />
        <attr name="contentPaddingTB" format="dimension|reference" />
        <attr name="progressedWidth" format="dimension|reference" />
        <attr name="backColor" format="color|reference" />
        <attr name="progressColor" format="color|reference" />
        <attr name="progressSecondColor" format="color|reference" />
        <attr name="loadSuccessDrawable" format="reference" />
        <attr name="loadErrorDrawable" format="reference" />
        <attr name="loadPauseDrawable" format="reference" />
    </declare-styleable>
</resources>

然后在 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);
}

以 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);
}

上面有两个关键的变量 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"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="20dp"
    android:orientation="vertical"
    tools:context="com.frank.statusbuttondemo.MainActivity">


    <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="重置按钮"/>
</LinearLayout>

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地址

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券