Android 高级自定义View实战

本文详细介绍如何自定义Android View,包括onMeasure和onDraw方法的使用,实现文本视图的精确包裹,以及绘制复杂UI组件的方法,例如仿华为记步器和验证码输入框。

     在android组件中主要分为两种:容器(LinearLayout....)和子View(TextView......),但是这些现有的组件往往不能满足app的开发。比如实现一个流式标签,炫酷的进度条显示呢。都需要我们探寻源码,分析和改造成我们想要的效果。这次的主题先从View的自定义入手。

165540_Fapb_2978666.png 

概述:

    针对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布局

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:orientation="vertical"
    android:layout_height="match_parent"
    tools:context="huangzhibo.com.learndemo.activity.CustomView">

    <huangzhibo.com.learndemo.view.MyTextView
        android:background="@color/colorAccent"
        android:id="@+id/myTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</RelativeLayout>

代码都有相应的注释,这里就不在废话,这里对于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的测量了。不过对于以上的代码还需要解释下:

基本的自定义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已经放在网上仓库,需要的话可以点击https://git.oschina.net/huagnzhibo123/LearnDemo

这个demo有我最近在整理的项目常用的工具类和框架,会不断完善。

转载于:https://my.oschina.net/huangzhi1bo/blog/1517689

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值