在android组件中主要分为两种:容器(LinearLayout....)和子View(TextView......),但是这些现有的组件往往不能满足app的开发。比如实现一个流式标签,炫酷的进度条显示呢。都需要我们探寻源码,分析和改造成我们想要的效果。这次的主题先从View的自定义入手。
概述:
针对View的自定义主要从 onMeasure()和onDraw()这两个方法入手。
onMeasure():测量View的大小 测量view只要针对我们在xml中wrap_content和match_parent的两种属性。而在onMeasure()对 应的字段是AT_MOST 和EXACTLY。Android默认的实现了EXACTLY的测量也就是精准测量(对应xml属性 match_parent和具体值),对于AT_MOST默认填充父容器,如果要实现包裹那就需要我们自己动手丰衣 足食了。onDraw():绘制内容,比如形状,图片啊都在这里实现。
主要用到类有paint(画笔),canvas(画板)由这两个类,我们就可以随心所欲的画画l咯。实践出真理:
“纸上得来终觉浅,绝知此事要躬行“,现在我们就一步一步的用代码来分析自定义View的实践。想了好久我要用啥一个相对简单但大家都熟悉的view作为本文的的入门demo呢?最后决定我们来实现android自带的TextView。
入门demo:
创建一个class类,名为MyTextView 继承View。
package huangzhibo.com.learndemo.view;import android.content.Context;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.support.annotation.Nullable;import android.util.AttributeSet;import android.view.View;import huangzhibo.com.learndemo.R;/** * Created by HuangZhiBo on 2017/8/20/020. */public class MyTextView extends View { private String text; private int paintColor = Color.BLACK; public MyTextView(Context context) { super(context); } public MyTextView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); /**实现AT_MOST的测量*/ } public void setText(String info) { text = info; } public void setTextColor(int color) { paintColor = color; } @Override protected void onDraw(Canvas canvas) { if (text == null) { //文本为空 return; } Paint paint = new Paint(); paint.setColor(paintColor); paint.setAntiAlias(true); //抗锯齿 paint.setTextSize(30); //文本大小 canvas.drawText(text,this.getWidth()/2-getTextWidth(paint,text)/2,this.getHeight()/2,paint); } /** * 测量文字宽度 * @param paint * @param str * @return */ public static int getTextWidth(Paint paint, String str) { int w= 0; if (str != null && str.length() > 0) { int len = str.length(); float[] widths = new float[len]; paint.getTextWidths(str, widths); for (int j = 0; j < len; j++) { w+= (int) Math.ceil(widths[j]); } } return w; }}
xml布局
代码都有相应的注释,这里就不在废话,这里对于onMeasure并没有处理,采用默认。注意看layout_width和layout_height的属性值为wrap_content。上文我说道,如果对于onMearsure并未处理,那么view将填充父容器。为了看清楚边界,view设置了一个背景。结果不出所料,那么接下我们就来实现Android对于wrap_content的测量。
效果图:
在对于onMeasure的AT_MOST,我查了下网上很多资料都是直接给个固定值,然后通过比较返回最大值,这样根本就是治标不治本,我们要的效果是完全的TextView效果。那么问题来了,要实现包裹效果,也就需要确定宽和高。而这个宽和高就是文本的宽和高加上内边距(pading)。思路有了那就好办了。
package huangzhibo.com.learndemo.view;import android.content.Context;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.support.annotation.Nullable;import android.util.AttributeSet;import android.view.View;import huangzhibo.com.learndemo.R;import static android.os.Build.VERSION_CODES.M;/** * Created by HuangZhiBo on 2017/8/20/020. */public class MyTextView extends View { private String text; private int paintColor = Color.BLACK; private Paint mPaint; public MyTextView(Context context) { super(context); } public MyTextView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); mPaint=new Paint(); mPaint.setColor(paintColor); mPaint.setAntiAlias(true); //抗锯齿 mPaint.setTextSize(30); //文本大小 } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int result = 200; /**实现AT_MOST的测量*/ setMeasuredDimension(meaSureWidth(widthMeasureSpec),meaSureHeight(heightMeasureSpec)); } /** * 测量宽度 * @param measureSpec * @return */ private int meaSureWidth(int measureSpec){ int result=0; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode==MeasureSpec.AT_MOST){ /**文本宽度+左右内边距*/ int v = (int)mPaint.measureText(text) + getPaddingLeft() + getPaddingRight(); result= Math.min(v, specSize); } return result; } /** * 测量宽度 * @param measureSpec * @return */ private int meaSureHeight(int measureSpec){ int result=0; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode==MeasureSpec.AT_MOST){ /**文本高度+上下内边距*/ int v = (int) (-mPaint.ascent() + mPaint.descent()) + getPaddingTop() + getPaddingBottom(); result= Math.min(v, specSize); } return result; } public void setText(String info) { text = info; } public void setTextColor(int color) { paintColor = color; } @Override protected void onDraw(Canvas canvas) { if (text == null) { //文本为空 return; } /**注意 drawText中的x,y分别指的是文字的左边位置,文字baseLine的位置*/ canvas.drawText(text,getPaddingLeft(),this.this.getHeight()-mPaint.descent()-getPaddingBottom(),mPaint); } /** * 测量文字宽度 * @param paint * @param str * @return */ public static int getTextWidth(Paint paint, String str) { int w= 0; if (str != null && str.length() > 0) { int len = str.length(); float[] widths = new float[len]; paint.getTextWidths(str, widths); for (int j = 0; j < len; j++) { w+= (int) Math.ceil(widths[j]); } } return w; }}
效果图:
到这里,我们已经完全解决了AT_MOST的测量了。不过对于以上的代码还需要解释下:
- (-mPaint.ascent() + mPaint.descent()) 这个为啥能得到文字高度?原理可以看下这篇文章(这个必须掌握)
基本的自定义view,我相信到这里你已经差不多掌握了,那么如何达到炉火纯青,信手拈来的境界呢?没有捷径,就是熟能生巧,接下来实现几个效果View。
仿华为记步View和常见的验证码View:
华为记步View代码如下:
package huangzhibo.com.learndemo.view;import android.animation.ValueAnimator;import android.content.Context;import android.graphics.Canvas;import android.graphics.Paint;import android.graphics.RectF;import android.support.annotation.Nullable;import android.util.AttributeSet;import android.util.Log;import android.view.View;import huangzhibo.com.learndemo.R;/** * Created by HuangZhiBo on 2017/7/31/031. */public class StepView extends View { /*圆弧宽度*/ private float borderWidth = 38f; /* 画步数的数值的字体大小*/ private float numberTextSize = 0; /** * 开始绘制圆弧的角度 */ private float startAngle = 135; /** * 终点对应的角度和起始点对应的角度的夹角 */ private float angleLength = 270; /** * 所要绘制的当前步数的红色圆弧终点到起点的夹角 */ private float currentAngleLength = 0; private String stepNumber; /** * 动画时长 */ private int animationLength = 3000; public StepView(Context context) { super(context); } public StepView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public StepView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); /*中心点坐标*/ float centerX = (getWidth()) / 2; /*指定圆弧的外轮廓矩形区域*/ RectF rectF = new RectF(0 + borderWidth, borderWidth, 2 * centerX - borderWidth, 2 * centerX - borderWidth); /*绘制红色圆弧*/ drawArcYellow(canvas, rectF); /*绘制蓝色走过步数*/ drawArcRed(canvas, rectF); /*文字*/ drawTex(canvas, rectF); } private void drawTex(Canvas canvas, RectF rectF) { Paint paint = new Paint(); paint.setColor(getResources().getColor(R.color.colorAccent)); paint.setAntiAlias(true); paint.setTextSize(32); int length = String.valueOf(currentAngleLength).length(); canvas.drawText(currentAngleLength + "", rectF.centerX() - 70, getHeight() / 2, paint); } /** * 1.绘制总步数的黄色圆弧 * * @param canvas 画笔 * @param rectF 参考的矩形 */ private void drawArcYellow(Canvas canvas, RectF rectF) { Paint paint = new Paint(); paint.setColor(getResources().getColor(R.color.colorAccent)); /** 结合处为圆弧*/ paint.setStrokeJoin(Paint.Join.ROUND); /** 设置画笔的样式 Paint.Cap.Round ,Cap.SQUARE等分别为圆形、方形*/ paint.setStrokeCap(Paint.Cap.ROUND); /** 设置画笔的填充样式 Paint.Style.FILL :填充内部;Paint.Style.FILL_AND_STROKE :填充内部和描边; Paint.Style.STROKE :仅描边*/ paint.setStyle(Paint.Style.STROKE); /**抗锯齿功能*/ paint.setAntiAlias(true); /**设置画笔宽度*/ paint.setStrokeWidth(38f); canvas.drawArc(rectF, startAngle, angleLength, false, paint); } /** * 2.绘制当前步数的蓝色圆弧 * * @param canvas * @param rectF */ private void drawArcRed(Canvas canvas, RectF rectF) { Paint paint = new Paint(); /**设置结合处的样子,Miter:结合处为锐角, Round:结合处为圆弧:BEVEL:结合处为直线。*/ paint.setStrokeJoin(Paint.Join.ROUND); paint.setStyle(Paint.Style.STROKE);//设置填充样式 /*** 当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式,如圆形样式 Cap.ROUND,或方形样式Cap.SQUARE */ paint.setStrokeCap(Paint.Cap.ROUND); paint.setAntiAlias(true);//抗锯齿功能 paint.setStrokeWidth(borderWidth);//设置画笔宽度 paint.setColor(getResources().getColor(R.color.colorPrimary)); canvas.drawArc(rectF, startAngle, currentAngleLength, false, paint); } /** * 所走的步数进度 * * @param totalStepNum 设置的步数 * @param currentCounts 所走步数 */ public void setCurrentCount(int totalStepNum, int currentCounts) { stepNumber = currentCounts + "";/**如果当前走的步数超过总步数则圆弧还是270度,不能成为园*/ if (currentCounts > totalStepNum) { currentCounts = totalStepNum; }/**所走步数占用总共步数的百分比*/ float scale = (float) currentCounts / totalStepNum;/**换算成弧度最后要到达的角度的长度-->弧长*/ float currentAngleLength = scale * angleLength;/**开始执行动画*/ setAnimation(0, currentAngleLength, animationLength); } /** * 为进度设置动画 * ValueAnimator是整个属性动画机制当中最核心的一个类,属性动画的运行机制是通过不断地对值进行操作来实现的, * 而初始值和结束值之间的动画过渡就是由ValueAnimator这个类来负责计算的。 * 它的内部使用一种时间循环的机制来计算值与值之间的动画过渡, * 我们只需要将初始值和结束值提供给ValueAnimator,并且告诉它动画所需运行的时长, * 那么ValueAnimator就会自动帮我们完成从初始值平滑地过渡到结束值这样的效果。 * * @param last * @param current */ private void setAnimation(float last, float current, int length) { ValueAnimator progressAnimator = ValueAnimator.ofFloat(last, current); progressAnimator.setDuration(length); progressAnimator.setTarget(currentAngleLength); progressAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentAngleLength = (float) animation.getAnimatedValue(); invalidate(); } }); progressAnimator.start(); }}
代码里面也注释很多了,这里就不在过多解释
常见的验证码View代码:
package huangzhibo.com.learndemo.utils.commonedite;import android.content.Context;import android.content.res.TypedArray;import android.graphics.Canvas;import android.graphics.Paint;import android.support.annotation.ColorRes;import android.support.v4.content.ContextCompat;import android.support.v7.widget.AppCompatEditText;import android.text.Editable;import android.text.TextPaint;import android.text.TextWatcher;import android.util.AttributeSet;import android.util.DisplayMetrics;import android.util.TypedValue;import android.view.WindowManager;import huangzhibo.com.learndemo.R;/** * Created by HuangZhiBo on 2017/7/11/011. */public class VerificationCodeEditText extends AppCompatEditText implements VerificationAction, TextWatcher { private int mFigures; private int mVerCodeMargin; private int mBottomSelectedColor; //底部选种颜色 private int mBottomNormalColor; //未选中颜色 private float mBottomLineHeigth; //底部高度 private int mSeleceBackgroundColor; //选中的背景颜色 private OnVerificationCodeChangedListener onCodeChangeListener; private int mCurrentPosition = 0; private int mEachRectLength = 0; private Paint mSelectBackGroundPaint; private Paint mNormalBackGroundPaint; private Paint mBottomSelectdPaint; private Paint mBottomNormalPaint; public VerificationCodeEditText(Context context) { this(context, null); } public VerificationCodeEditText(Context context, AttributeSet attrs) { this(context, null,0); } public VerificationCodeEditText(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initAttrs(attrs); setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent)); //防止出现下划线 initPaint(); setFocusableInTouchMode(true); super.addTextChangedListener(this); } /** * 初始化pain */ private void initPaint() { mSelectBackGroundPaint = new Paint(); mSelectBackGroundPaint.setColor(mSeleceBackgroundColor); mNormalBackGroundPaint = new Paint(); mNormalBackGroundPaint.setColor(getColor(android.R.color.transparent)); mBottomSelectdPaint = new Paint(); mBottomSelectdPaint.setColor(mBottomSelectedColor); mBottomNormalPaint = new Paint(); mBottomNormalPaint.setColor(mBottomNormalColor); mBottomSelectdPaint.setStrokeWidth(mBottomLineHeigth); mBottomNormalPaint.setStrokeWidth(mBottomLineHeigth); } private void initAttrs(AttributeSet attrs) { TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.EditText); mFigures = ta.getInteger(R.styleable.EditText_figures, 4); mVerCodeMargin = (int) ta.getDimension(R.styleable.EditText_verCodeMargin, 10); mBottomSelectedColor = ta.getColor(R.styleable.EditText_bottomLineSelectedColor, getCurrentTextColor()); mBottomNormalColor = ta.getColor(R.styleable.EditText_bottomLineNormalColor, getColor(android.R.color.holo_red_dark)); mBottomLineHeigth = ta.getDimension(R.styleable.EditText_bottomLineHeight, dp2px(5)); mSeleceBackgroundColor = ta.getColor(R.styleable.EditText_selectedBackgroundColor, getColor(android.R.color.holo_red_dark)); ta.recycle(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthResult = 0, heighResult = 0; //最终宽度 int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); if (widthMode == MeasureSpec.EXACTLY) { widthResult = widthSize; } else { widthResult = getScreenWidth(getContext()); } //每个矩形的宽度 mEachRectLength = (widthResult - (mVerCodeMargin * (mFigures - 1))) / mFigures; //最终高度 int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (heightMode == MeasureSpec.EXACTLY) { heighResult = heightSize; } else { heighResult = mEachRectLength; } setMeasuredDimension(widthResult, heighResult); } @Override protected void onDraw(Canvas canvas) { mCurrentPosition = getText().length(); int width = mEachRectLength - getPaddingLeft() - getPaddingRight(); //每个矩形宽度 int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); //整体高度 //绘制每个矩形 for (int i = 0; i < mFigures; i++) { canvas.save(); int start = width * i + i * mVerCodeMargin; int end = width + start; //画一个矩形 if (i == mCurrentPosition) {//选中 canvas.drawRect(start, 0, end, height, mSelectBackGroundPaint); } else { canvas.drawRect(start, 0, end, height, mNormalBackGroundPaint); } canvas.restore(); } //绘制文字 String value = getText().toString(); for (int i = 0; i < value.length(); i++) { canvas.save(); int start = width * i + i * mVerCodeMargin; float x = start + width / 2; TextPaint paint = getPaint(); paint.setTextAlign(Paint.Align.CENTER); paint.setColor(getCurrentTextColor()); Paint.FontMetrics fontMetrics = paint.getFontMetrics(); float baseLine = (height - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top;//top 是个负数 canvas.drawText(String.valueOf(value.charAt(i)), x, baseLine, paint); canvas.restore(); } //绘制底线 for (int i = 0; i < mFigures; i++) { canvas.save(); float lineY = height - mBottomLineHeigth / 2; int start = width * i + i * mVerCodeMargin; int end = width + start; if (i < mCurrentPosition) { canvas.drawLine(start, lineY, end, lineY, mSelectBackGroundPaint); } else { canvas.drawLine(start, lineY, end, lineY, mBottomNormalPaint); } canvas.restore(); } } /** * 获取手机屏幕的宽度 */ static int getScreenWidth(Context context) { DisplayMetrics metrics = new DisplayMetrics(); WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); wm.getDefaultDisplay().getMetrics(metrics); return metrics.widthPixels; } @Override final public void setCursorVisible(boolean visible) { super.setCursorVisible(false);//隐藏光标的显示 } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { mCurrentPosition = getText().length(); postInvalidate(); } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { mCurrentPosition = getText().length(); postInvalidate(); if (onCodeChangeListener != null) { onCodeChangeListener.onVerCodeChanged(getText(), start, before, count); } } @Override public void afterTextChanged(Editable s) { mCurrentPosition = getText().length(); postInvalidate(); if (getText().length() == mFigures) { if (onCodeChangeListener != null) { onCodeChangeListener.onInputCompleted(getText()); } } else if (getText().length() > mFigures) { getText().delete(mFigures, getText().length()); } } @Override public void setFigures(int figures) { mFigures = figures; postInvalidate(); } @Override public void setVerCodeMargin(int margin) { mVerCodeMargin = margin; postInvalidate(); } @Override public void setBottomSelectedColor(@ColorRes int bottomSelectedColor) { mBottomSelectedColor = bottomSelectedColor; postInvalidate(); } @Override public void setBottomNormalColor(@ColorRes int bottomNormalColor) { mBottomNormalColor = bottomNormalColor; postInvalidate(); } @Override public void setSelectedBackgroundColor(@ColorRes int selectedBackground) { mSeleceBackgroundColor = selectedBackground; postInvalidate(); } @Override public void setBottomLineHeight(int bottomLineHeight) { mBottomLineHeigth=bottomLineHeight; postInvalidate(); } @Override public void setOnVerificationCodeChangedListener(OnVerificationCodeChangedListener listener) { this.onCodeChangeListener=listener; } /** * 返回颜色 */ private int getColor(@ColorRes int color) { return ContextCompat.getColor(getContext(), color); } /** * dp转px */ private int dp2px(int dp) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics()); }}
对于自定义验证码的View,需要考虑超出文字字数处理,文字变化监听处理,相对于这个demo来说还是有一点难度,需要大家慢慢的去理解和消化。这里把几个要点总结下,以便更好的去理解这个实现逻辑
- 初始界面,绘画一个矩形(分为选中和未选中),底部横线,绘制文字
- 文字输入监听,重新绘制
- 文字超出处理
以上的demo已经放在网上仓库,需要的话可以点击
这个demo有我最近在整理的项目常用的工具类和框架,会不断完善。