最近跟小伙伴一块儿讨论了一下,决定一块儿仿一个BiliBili的app(包括android端和iOS端),咱们并无打算把这个项目彻底作完,毕竟咱们的重点是掌握一些新框架的使用,并在实战过程当中发现并弥补自身的不足。java
本系列将记录我(android端)在开发过程当中的一些我以为有必要记录的功能实现而已,并非完整的从0到1的完整教程,若个别看官大爷以为很差请出门左拐谢谢。android
如下是该项目将会完成的功能。git
本系列文章,将会有记录以上功能的实现但不只仅只有这些,还会有一些其余,好比自定义控件、利用fiddler抓包等,接下来就进入本篇的主题——《仿bilibili刷新按钮的实现》。github
先来看看原版效果:canvas
该按钮由3部分组成,分别是圆角矩形、文字、旋转图标。在点击按钮后,开始加载数据,旋转图标发生旋转,数据加载完成后,旋转图标复位并中止旋转。话很少说,开始敲代码。app
这里,咱们要绘制的部分有3个,分别是上面提到的圆角矩形、文字、旋转图标。那么这里就为这3部分分别声明了一些属性。框架
要注意的一点是,这个类中有3个构造函数,由于有部分属性须要在构造函数中初始化(也为以后自定义属性作准备),因此,将第1个与第2个构造函数中的super修改成this。ide
public class LQRRefreshButton extends View { // 圆角矩形属性 private int borderColor = Color.parseColor("#fb7299"); private float borderWidth = 0; private float borderRadius = 120; // 文字属性 private String text = "点击换一批"; private int textColor = Color.parseColor("#fb7299"); private float textSize = 28; // 旋转图标属性 private int iconSrc = R.mipmap.tag_center_refresh_icon; private float iconSize = 28; private Bitmap iconBitmap; private float space4TextAndIcon = 20; // 画笔 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public LQRRefreshButton(Context context) { this(context, null); } public LQRRefreshButton(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public LQRRefreshButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 将图标资源实例化为Bitmap iconBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.tag_center_refresh_icon); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 一、画圆角矩形 // 二、画字 // 三、画刷新图标 } }
接下来着重完成onDraw()方法的实现:函数
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 一、画圆角矩形 mPaint.setStyle(Paint.Style.STROKE); mPaint.setColor(borderColor); mPaint.setStrokeWidth(borderWidth); canvas.drawRoundRect(new RectF(0, 0, getWidth(), getHeight()), borderRadius, borderRadius, mPaint); // 二、画字 mPaint.setTextSize(textSize); mPaint.setColor(textColor); mPaint.setStyle(Paint.Style.FILL); float measureText = mPaint.measureText(text); float measureAndIcon = measureText + space4TextAndIcon + iconSize; float textStartX = getWidth() / 2 - measureAndIcon / 2; float textBaseY = getHeight() / 2 + (Math.abs(mPaint.ascent()) - mPaint.descent()) / 2; canvas.drawText(text, textStartX, textBaseY, mPaint); // 三、画刷新图标 float iconStartX = textStartX + measureText + space4TextAndIcon; canvas.drawBitmap(iconBitmap, iconStartX, getHeight() / 2 - iconSize / 2, mPaint); }
先来看看效果:布局
我给该控件设置了宽为200dp,高为100dp。
能够看到效果还不错,但仍是有一点点问题的,下面就分别说说这3部分是怎么画的,及存在的小问题。
其实画圆角矩形很简单,设置好画笔的样式、颜色、线粗,再调用canvas的drawRoundRect()方法便可实现。
但你有没有发现,此时的 线粗为0(borderWidth=0),矩形线怎么还有?这是由于画笔的样式为Paint.Style.STROKE,当线粗为0时,还要画出1px的线,由于对画笔来讲,最小的线粗就是1px。因此,上面的代码须要作以下改动:
// 一、画圆角矩形 if (borderWidth > 0) { mPaint.setStyle(Paint.Style.STROKE); mPaint.setColor(borderColor); mPaint.setStrokeWidth(borderWidth); canvas.drawRoundRect(new RectF(0, 0, getWidth(), getHeight()), borderRadius, borderRadius, mPaint); }
画字的通常步骤是设置文字大小、文字颜色、画笔样式,绘制起点。其中后2个最为重要。
如上图中,如今要得到的就是文字左下角的点,这要怎么求呢?
先说x,通常须要让文字居中显示(跟文字的对齐方式也有关系,这里以默认的左对齐为例),因此计算公式通常为: x = 控件宽度/2 - 文字长度/2。但咱们这个控件有点不一样,它还须要考虑到旋转图标的位置问题,因此x应该这么求: x = 控件宽度/2 - (文字长度+空隙+旋转图标宽度)/2。
// 获得文字长度 float measureText = mPaint.measureText(text); // 获得 文字长度+空隙+旋转图标宽度 float measureAndIcon = measureText + space4TextAndIcon + iconSize; // 获得文字绘制起点 float textStartX = getWidth() / 2 - measureAndIcon / 2;
再说y,如图所示:
若是直接用控件的高度的一半做为文字绘制的基线,那么绘制出来的文字确定偏上,这是由于Ascent的高度比Descent的高度要高的多,咱们在计算Baseline时,须要在Ascent中减去Descent的高度获得二者高度差,再让控件中心y坐标加上(降低)这个高度差的一半。故:
float textBaseY = getHeight() / 2 + (Math.abs(mPaint.ascent()) - mPaint.descent()) / 2;
最后就是画刷新图标了,它是以左上角为起点的,经过canvas的drawBitmap()方法进行绘制便可。
可是,有一点须要注意,iconSize是我本身定的一个大小,并非图标的实际大小,因此在日后作旋转动画时获取到的旋转中心会有偏差,将致使图标旋转时不是按中心进行旋转。因此,这里须要对图标大小进行调整:
public class LQRRefreshButton extends View { ... public LQRRefreshButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // icon iconBitmap = BitmapFactory.decodeResource(getResources(), iconSrc); iconBitmap = zoomImg(iconBitmap, iconSize, iconSize); } public Bitmap zoomImg(Bitmap bm, float newWidth, float newHeight) { // 得到图片的宽高 int width = bm.getWidth(); int height = bm.getHeight(); // 计算缩放比例 float scaleWidth = ((float) newWidth) / width; float scaleHeight = ((float) newHeight) / height; // 取得想要缩放的matrix参数 Matrix matrix = new Matrix(); matrix.postScale(scaleWidth, scaleHeight); // 获得新的图片 Bitmap newbm = Bitmap.createBitmap(bm, 0, 0, width, height, matrix, true); return newbm; } ... }
如今,要实现旋转图标的旋转功能了。原理就是在canvas绘制图标时,将canvas进行旋转,canvas旋转着绘制图标也很简单,只须要4步:
canvas.save(); canvas.rotate(degress, centerX, centerY); canvas.drawBitmap(iconBitmap, iconStartX, getHeight() / 2 - iconSize / 2, mPaint); canvas.restore();
接下来要作的,就是计算出旋转中心,旋转角度,并不中止的去调用onDraw()编制图标,可使用ValueAnimator或ObjectAnimator实现这个功能,这里选用ObjectAnimator。实现以下:
public class LQRRefreshButton extends View { ... private float degress = 0; private ObjectAnimator mAnimator; public LQRRefreshButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 旋转动画 mAnimator = ObjectAnimator.ofObject(this, "degress", new FloatEvaluator(), 360, 0); mAnimator.setDuration(2000); mAnimator.setRepeatMode(ObjectAnimator.RESTART); mAnimator.setInterpolator(new LinearInterpolator()); mAnimator.setRepeatCount(ObjectAnimator.INFINITE); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); ... // 三、画刷新图标 float iconStartX = textStartX + measureText + space4TextAndIcon; canvas.save(); float centerX = iconStartX + iconSize / 2; int centerY = getHeight() / 2; canvas.rotate(degress, centerX, centerY); canvas.drawBitmap(iconBitmap, iconStartX, getHeight() / 2 - iconSize / 2, mPaint); canvas.restore(); } public void start() { mAnimator.start(); } public void stop() { mAnimator.cancel(); setDegress(0); } public float getDegress() { return degress; } public void setDegress(float degress) { this.degress = degress; invalidate(); } }
使用ObjectAnimator能够对任意属性值进行修改,因此须要在该控件中声明一个旋转角度变量(degress),并编写getter和setter方法,还须要在setter方法中调用invalidate(),这样才能在角度值发生变换时,让控件回调onDraw()进行图标的旋转绘制。ObjectAnimator的使用也不复杂,这里就不详细介绍了。来看下动画效果吧:
一个自定义控件,是不能把属性值写死在控件里的,因此咱们须要自定义属性,从外界获取这些属性值。
在attrs.xml中编写以下代码:
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="LQRRefreshButton"> <attr name="refresh_btn_borderColor" format="color"/> <attr name="refresh_btn_borderWidth" format="dimension"/> <attr name="refresh_btn_borderRadius" format="dimension"/> <attr name="refresh_btn_text" format="string"/> <attr name="refresh_btn_textColor" format="color"/> <attr name="refresh_btn_textSize" format="dimension"/> <attr name="refresh_btn_iconSrc" format="reference"/> <attr name="refresh_btn_iconSize" format="dimension"/> <attr name="refresh_btn_space4TextAndIcon" format="dimension"/> </declare-styleable> </resources>
在控件的第三个构造函数中获取这些属性值:
public class LQRRefreshButton extends View { public LQRRefreshButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 获取自定义属性值 TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.LQRRefreshButton); borderColor = ta.getColor(R.styleable.LQRRefreshButton_refresh_btn_borderColor, Color.parseColor("#fb7299")); borderWidth = ta.getDimension(R.styleable.LQRRefreshButton_refresh_btn_borderWidth, dipToPx(0)); borderRadius = ta.getDimension(R.styleable.LQRRefreshButton_refresh_btn_borderRadius, dipToPx(60)); text = ta.getString(R.styleable.LQRRefreshButton_refresh_btn_text); if (text == null) text = ""; textColor = ta.getColor(R.styleable.LQRRefreshButton_refresh_btn_textColor, Color.parseColor("#fb7299")); textSize = ta.getDimension(R.styleable.LQRRefreshButton_refresh_btn_textSize, spToPx(14)); iconSrc = ta.getResourceId(R.styleable.LQRRefreshButton_refresh_btn_iconSrc, R.mipmap.tag_center_refresh_icon); iconSize = ta.getDimension(R.styleable.LQRRefreshButton_refresh_btn_iconSize, dipToPx(14)); space4TextAndIcon = ta.getDimension(R.styleable.LQRRefreshButton_refresh_btn_space4TextAndIcon, dipToPx(10)); ta.recycle(); ... } }
这里有一点须要留意:
ta.getDimension(属性id, 默认值)
经过TypedArray对象能够从外界到的的值会根据单位(如:dp、sp)的不一样自动转换成px,但默认值的单位是必定的,为px,因此为了符合安卓规范,不要直接使用px,因此须要手动作个转换。最后还须要调用recycle()方法回收TypedArray。
<com.lqr.biliblili.mvp.ui.widget.LQRRefreshButton android:id="@+id/btn_refresh" android:layout_width="118dp" android:layout_height="32dp" android:layout_gravity="center" android:layout_marginBottom="3dp" android:layout_marginTop="8dp" app:refresh_btn_borderRadius="25dp" app:refresh_btn_borderWidth="1dp" app:refresh_btn_iconSize="16dp" app:refresh_btn_text="点击换一批" app:refresh_btn_textColor="@color/bottom_text_live" app:refresh_btn_textSize="14sp"/>