Android 从 0 开始自定义控件之 自定义 View 基础实例(十)

3,720 阅读11分钟

转载请标明出处: blog.csdn.net/airsaid/art…
本文出自:周游的博客

前言

在前面的博客当中,我们学习了 View 的一些工作原理知识等,现在是时候来写一波实例了,毕竟实践出真知嘛~ 在开始写实例之前,首先来了解下自定义 View 到底有哪几种吧,然后再根据每种分别写一个简单的小栗子。

自定义 View 分类

在我们准备编写一个自定义 View 的时候,我们需要根据我们的需求来编写不同的自定义 View。比如说,如果只是想对 TextView 进行扩展,那么可以继承自 TextView 来编写一个新的 View,如果想自己定义一个不同的布局,那么可以继承 ViewGroup 来实现。那么到底有多少类的自定义 View 呢?在网上搜了下,发现大都是说三种、或四种,感觉自定义 View 的分类标准并不一,在这里的话还是以主席的为准分为四类吧,感觉更细分一些,这四类分别是:

继承 View

当我们需要实现的效果是一个不规则效果的时候,那么这时就需要继承 View 来实现了,我们需要重写 onDraw 方法,在该方法里实现各种不规则的图形和效果。当我们使用这种方式的时候,需要自己去处理 warp_content 和 padding。

继承 ViewGroup

当系统所提供的 LinearLayout、FrameLayout 等布局控件无法满足我们的需求时,这时我们就需要使用这种方式来实现自己想要的布局效果了。当我们使用这种方式的时候,需要重写 onLayout 方法来对子 View 进行布局,以及测量本身和子 View 宽高,还需要处理本身的 margin 和子 View 的 padding。

继承已有 View

当我们需要基于已有的 View 进行扩展或修改的时候,那么就可以使用这种方式。比如说,我们需要一个圆角的 ImageView,那么这时就可以继承 ImageView 进行修改了。当我们使用这种方式的时候,一般不需要自己去处理 wrap_content 和 padding 等,因为系统控件已经帮我们做好了。

继承已有布局

这种方式也叫做:自定义组合 View。该方式比较常见,当我们需要将一些 View 组合在一起,方便后期复用的时候,就可以使用该方法。当我们使用这种方式的时候,不需要去处理 ViewGroup 的测量和布局流程,因为系统控件已经帮我们做好了。

自定义 View 实例

下面我们了解了自定义 View 的四种分类,下面我们分别写四个与分类对应的实例来了解下其各自的写法,以及一些需要注意的问题。

继承 View 的实例

当我们自定义 View 继承子 View 时,我们需要注意的细节有:

  • View 是 wrap_contetn 时需要手动测量 View 宽高。
  • View 有 padding 值时需要处理。

在这个实例当中,我们只需简单的画一个圆即可。重点是细节上的处理,写出一个规范的自定义 View。

实例代码如下:

public class CircleView extends View {

    private Paint mPaint;

    /** 圆半径 */
    private float mRadius = 50;

    public CircleView(Context context) {
        this(context, null);
    }

    public CircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        // 初始化画笔
        mPaint = new Paint();
        mPaint.setColor(Color.BLUE);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        canvas.drawCircle(width / 2, height / 2,  mRadius, mPaint);
    }
}

上面的自定义 View 实例很简单,就是绘制了一个圆形。放在布局中,运行看下效果:

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.airsaid.customviewdemo.widget.CircleView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#999999"/>

</RelativeLayout

运行结果:
这里写图片描述
虽然我们已经实现了一个简单的继承 View 的自定义 View,但该自定义 View 还不是一个规范的自定义 View,比如此时我们把布局改动一下,将 View 的宽高改为 wrap_content 以及增加 padding 和 margin 属:

<com.airsaid.customviewdemo.widget.CircleView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="15dp"
    android:background="#999999"
    android:padding="15dp"/>

重新运行结果:
这里写图片描述

可以看到,虽然我们重新修改了宽高 为 wrap_content,以及增加了 padding 和 margin ,但是最终生效的只有 margin。其他的一概没有生效,而 wrap_content 竟然和 match_parent 一样。这是怎么一回事呢?

这是因为,margin 是由父控件来控制的,所以我们不需要在继承 View 的自定义 View 中进行处理,但是当我们继承自 View 或者 ViewGroup 时,我们需要对 View 的 padding 进行处理,当我们继承 View 时需要对 wrap_content 的情况进行处理,否则 wrap_content 的效果会和 match_parent 一样。

下面我们重写修改下,在 onDraw 绘制时,加上 padding 值,代码如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    int paddingLeft = getPaddingLeft();
    int paddingTop = getPaddingTop();
    int paddingRight = getPaddingRight();
    int paddingBottom = getPaddingBottom();

    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom;
    canvas.drawCircle(width / 2 + paddingLeft, height / 2 + paddingTop,  mRadius, mPaint);
}

重写 onMeasure 方法,判断当是 wrap_content 的情况时,自己测量 View 的宽或高:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    if(widthMode == MeasureSpec.AT_MOST){
        widthSize = (int) (mRadius * 2 + getPaddingLeft() + getPaddingRight());
    }

    if(heightMode == MeasureSpec.AT_MOST){
        heightSize = (int) (mRadius * 2 + getPaddingTop() + getPaddingBottom());
    }

    setMeasuredDimension(widthSize, heightSize);
}

重新运行:
这里写图片描述

可以看到,padding 和 wrap_content 都已经生效了,这时候才是一个规范的自定义 View。

继承 ViewGroup 实例

当我们自定义 View 继承自 ViewGroup 时,就需要去实现 onLayout 方法来指定子 View 的摆放位置,并且需要重写 onMeasure 方法来测量大小。在这个实例当中,我们简单模仿下 LinearLayout ,只不过只实现其 Vertical 模式,在这个实例当中,我们需要注意的细节有:

  • ViewGroup 是 wrap_conent 时需要手动测量。
  • 当 ViewGroup 本身有 padding 值时需要处理。
  • 当子 View 有 margin 值时需要处理。

作为一个规范的自定义 ViewGroup ,这几个细节我们都需要去处理,下面直接上代码:


/**
 * 作者: 周游
 * 时间: 2017/1/5
 * 博客: http://blog.csdn.net/airsaid
 * 描述: 一个继承 ViewGroup 的自定义 View 入门实例,该 ViewGroup 垂直摆放子 View。
 */
public class SimpleVerticalLayout extends ViewGroup {

    private final Context mContext;

    public SimpleVerticalLayout(Context context) {
        this(context, null);
    }

    public SimpleVerticalLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SimpleVerticalLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 获取 ViewGroup 的测量模式、大小。
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        // 获取 ViewGroup 的 padding 值
        int pl = getPaddingLeft();
        int pt = getPaddingTop();
        int pr = getPaddingRight();
        int pb = getPaddingBottom();

        // 测量所有子 View,当测量后才能获取到子 View 的测量宽高
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        // 自己计算的 ViewGroup 的宽高
        int width = 0;
        int height = 0;

        // 判断如果 ViewGroup 的宽度是 wrap_content
        if(widthMode == MeasureSpec.AT_MOST){
            // 计算 ViewGroup 的宽度,遍历所有子 View,最宽的那个 View 的宽度就是 ViewGroup 的宽度
            int maxWidth = 0;
            for (int i = 0; i < getChildCount(); i++) {
                View childAt = getChildAt(i);
                if(childAt.getVisibility() == View.GONE){
                    continue;
                }

                MarginLayoutParams lp = (MarginLayoutParams) childAt.getLayoutParams();
                int childWidth = childAt.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
                maxWidth = childWidth > maxWidth ? childWidth : maxWidth;
            }
            width = maxWidth + pl + pr;
        }

        // 判断如果 ViewGroup 的高度是 wrap_content
        if(heightMode == MeasureSpec.AT_MOST){
            // 计算 ViewGroup 的高度,由于是垂直摆放,所以高度为每个子 View 的高度和
            for (int i = 0; i < getChildCount(); i++) {
                View childAt = getChildAt(i);
                if(childAt.getVisibility() == View.GONE){
                    continue;
                }

                MarginLayoutParams lp = (MarginLayoutParams) childAt.getLayoutParams();
                height += childAt.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            }
            height = height + pt + pb;
        }

        setMeasuredDimension(widthMode == MeasureSpec.AT_MOST ? width : widthSize
                , heightMode == MeasureSpec.AT_MOST ? height : heightSize);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int pl = getPaddingLeft();
        int pt = getPaddingTop();
        int pr = getPaddingRight();
        int pb = getPaddingBottom();


        int cl = 0;
        int ct = 0;
        int cr = 0;
        int cb = 0;
        int bm = 0;

        // 遍历所有子 View
        int childCount = getChildCount();

        for (int i = 0; i < childCount; i++) {
            // 获取子 View
            View childAt = getChildAt(i);
            // 判断当子 View 没有 Gone 掉时
            if(childAt.getVisibility() != View.GONE){
                // 计算每个子 View 的位置
                MarginLayoutParams lp = (MarginLayoutParams) childAt.getLayoutParams();
                cl = lp.leftMargin;
                ct += lp.topMargin;
                cr = childAt.getMeasuredWidth() + lp.leftMargin;
                cb += childAt.getMeasuredHeight() + lp.topMargin;
                // 对子 View 进行布局
                childAt.layout(cl + pl, ct + pt + bm, cr + pr, cb + pb + bm);
                ct += childAt.getMeasuredHeight();
                bm += lp.bottomMargin;
            }
        }
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(mContext, attrs);
    }
}

上面的代码实现的很简单,并且注释也很详细。我们直接放到布局中看看效果:

<com.airsaid.customviewdemo.widget.SimpleVerticalLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="#999999"
    android:padding="10dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:background="#ff0000"
        android:text="我是第1个子 View"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#ffff00"
        android:text="我是第2个子 View"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#ffffff"
        android:text="我是第3个子 View"/>
</com.airsaid.customviewdemo.widget.SimpleVerticalLayout>

运行结果:
这里写图片描述
可以看到,SimpleVerticalLayout 的 padding,和子 View 的 margin 都是生效的。

继承已有 View 实例

当我们自定义 View 继承自系统已有 View 时,一般是基于其原有功能进行扩展或修改。比如这个实例当中,我们对原有的 EditText 进行扩展,增加一个有内容时显示删除按钮,点击按钮清空文本的 EditText,实例很简单,依然直接贴代码了:


/**
 * 作者: 周游
 * 时间: 2017/1/8
 * 博客: http://blog.csdn.net/airsaid
 * 描述: 一个继承已有 View 的自定义 View 实例,带清除按钮的 EditText。
 */
public class CleanEditText extends EditText{

    private final Context mContext;
    private Drawable mDeleteDrawable;

    public CleanEditText(Context context) {
        this(context, null);
    }

    public CleanEditText(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CleanEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;

        // 设置右侧删除图标
        mDeleteDrawable = getResources().getDrawable(R.mipmap.ic_delete);
        // 添加监听
        addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {}

            @Override
            public void afterTextChanged(Editable s) {
                setDeleteDrawable();
            }
        });
        setDeleteDrawable();
    }

    private void setDeleteDrawable() {
        setCompoundDrawablesWithIntrinsicBounds(null, null, length() > 0 ? mDeleteDrawable : null, null);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(event.getAction() == MotionEvent.ACTION_UP){
            if(mDeleteDrawable != null){
                int rawX = (int) event.getRawX();
                int rawY = (int) event.getRawY();
                Rect rect = new Rect();
                getGlobalVisibleRect(rect);
                rect.left = rect.right - 50;
                if(rect.contains(rawX, rawY))
                    setText("");
            }
        }
        return super.onTouchEvent(event);
    }
}

运行结果:
这里写图片描述

继承已有 ViewGroup 实例

这种自定义 View 的实现方式也叫做:“自定义组合控件”,是一种比较简单的自定义 View 方式。使用这种方式时,由于是继承已有的系统控件,所以我们不需去测量、布局、处理 margin、padding等,因为系统控件本身已经处理好了。

当我们的项目中有一些布局在很多地方都要用到的话,那么第一时间肯定就要想到复用了。复用的话,有人可能会想到使用 include 复用布局,但是如果这样的话,当布局改动性很大时,使用 include 并不是很灵活。这时候,就可以使用 ”继承已有 ViewGroup“ 这种方式了。

下面一个实例,就拿我们平时可能经常要写的 Item 为例吧:


/**
 * 作者: 周游
 * 时间: 2017/1/9
 * 博客: http://blog.csdn.net/airsaid
 * 描述: 一个继承已有 ViewGroup 的自定义 View 实例,常用 item 布局。
 */
public class CustomItemLayout extends FrameLayout {

    private TextView mTxtLeft;
    private TextView mTxtRight;
    private ImageView mImgRight;

    private Context mContext;
    // 左侧文字
    private String mLeftText;
    // 右侧文字
    private String mRightText;
    // 右侧文字颜色
    private int mRightTextColor = Color.parseColor("#666666");
    // 右侧图片
    private int mRightImageId = R.mipmap.ic_arrow_right;
    // 左侧图片
    private Drawable mLeftImage = null;

    public CustomItemLayout(Context context) {
        this(context, null);
    }

    public CustomItemLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomItemLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
        initAttrs(attrs);
        initView();
        setData();
    }

    private void initAttrs(AttributeSet attrs) {
        // 获取自定义属性
        TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.CustomItemLayout);
        mLeftText = a.getString(R.styleable.CustomItemLayout_item_leftText);
        mRightText = a.getString(R.styleable.CustomItemLayout_item_rightText);
        mRightTextColor = a.getColor(R.styleable.CustomItemLayout_item_rightTextColor, mRightTextColor);
        mRightImageId = a.getResourceId(R.styleable.CustomItemLayout_item_rightImage, mRightImageId);
        mLeftImage = a.getDrawable(R.styleable.CustomItemLayout_item_leftImage);
        a.recycle();
    }

    private void initView() {
        // 加载自定义布局到当前 ViewGroup
        LayoutInflater.from(mContext).inflate(R.layout.view_custom_item_layout, this);
        mTxtLeft = (TextView) findViewById(R.id.txt_left);
        mTxtRight = (TextView) findViewById(R.id.txt_right);
        mImgRight = (ImageView) findViewById(R.id.img_right);
    }

    private void setData() {
        if(mLeftText != null)   mTxtLeft.setText(mLeftText);
        if(mRightText != null)  mTxtRight.setText(mRightText);
        setRightImage(mRightImageId);
        if(mLeftImage != null)
            mLeftImage.setBounds(0, 0, dp2px(22), dp2px(12));
        mTxtLeft.setCompoundDrawables(null, null, mLeftImage, null);
    }

    public void setRightTextColor(int resId){
        mTxtRight.setTextColor(resId);
    }

    public void setRightText(String text){
        mTxtRight.setText(text);
    }

    public void setRightText(int resId){
        mTxtRight.setText(resId);
    }

    public String getRightText(){
        return mTxtRight.getText().toString();
    }

    public void setLeftImage(int leftImageId){
        mLeftImage = getResources().getDrawable(leftImageId);
        setData();
    }

    public void setRightImage(int rightImageId){
        if(rightImageId != -1){
            mImgRight.setVisibility(View.VISIBLE);
            mImgRight.setImageResource(rightImageId);
        }else{
            mImgRight.setVisibility(View.GONE);
        }
    }

    private int dp2px(float dpValue){
        return (int)(dpValue * (getResources().getDisplayMetrics().density) + 0.5f);
    }
}

首先自定义一个类,继承自 FrameLayout,当然,这里你也可以选择继承 LinearLayout 或者其他,根据具体需求来。其中在构造中获取了自定义属性,以及填充了布局。自定义属性有不懂的同学可以先忽略,下篇会单独拿出来写一篇。

最主要的地方就是填充布局那里,将布局填充到了当前控件也就是自定义的 ViewGroup 上。填充的布局如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:background="?android:selectableItemBackground"
              android:gravity="center_vertical"
              android:padding="15dp">

    <TextView
        android:id="@+id/txt_left"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="5dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:textColor="@color/text_black"
        android:textSize="@dimen/txt14"/>

    <TextView
        android:id="@+id/txt_right"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginLeft="10dp"
        android:layout_weight="1"
        android:ellipsize="end"
        android:gravity="right"
        android:maxLines="1"
        android:textSize="@dimen/txt14"/>

    <ImageView
        android:id="@+id/img_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="5dp"
        android:src="@mipmap/ic_arrow_right"/>
</LinearLayout>

使用时,可以直接在布局中通过自定义属性设置数据:

<com.airsaid.customviewdemo.widget.CustomItemLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:item_leftText="版本更新"
    app:item_rightImage="@mipmap/ic_arrow_right"
    app:item_rightText="V1.1"/>

也可以通过暴露的方法设置数据,怎么方便怎么来。

运行结果:
这里写图片描述

源码下载

CSDN:download.csdn.net/detail/airs…