Android最易懂的自定义View讲解

前言: 最近开发的时候, 频繁的需要使用到自定义控件。自定义控件是成为高级工程师必不可少的条件之一,所以今天决定认真总结一下。其实自定义控件也没有想象中的那么复杂,无非只要掌握其中的几个关键方法就能满足绝大部分需求。但是若要真的要深入进去,都能写一本书了,这里就不做那么深入了。能满足日常的需求即可, 想深入了解的可自行查阅其他资料进行学习。

在学习本篇自定义View之前,读者有必要先学习一下View的绘制流程,这样才能更好的理解文字的内容。必知必会 | 面试官装逼失败之View的绘制流程

首先我们要明白,为什么要自定义View?主要是Android系统内置的View无法实现我们的需求,我们需要针对我们的业务需求定制我们想要的View。简单来说自定义控件无非就两种,自定义View和自定义ViewGroup:

  • 自定义View
    可以理解为自定义View的父类,是一个单独的控件,里面无法存放子View。例如TextView,ImageView等都是继承View的,View里面最关键的方法是onMeasureonDraw

  • 自定义ViewGroup
    ViewGroup是View的子类,相当于一个容器,里面可以放子View。例如LinearLayout,RelativeLayout等都是继承ViewGroup的。ViewGroup里面最关键的方法是onMeasure和onDraw和onLayout。其中onLayout是ViewGroup中特有的方法,用来实现子View的摆放。

1. 自定义View

自定义View的话我们大部分时候只需重写两个函数:onMeasure()onDraw()。onMeasure负责对当前View的尺寸进行测量,onDraw负责把当前这个View绘制出来。当然了,你还得写至少写2个构造函数:

    // 一个参数的构造方法,在代码中创建该控件时,调用该构造方法
    public MyView(Context context) {
        super(context);
    }
  
    // 在xml 中引用该控件时,调用该方法。attrs是定义在xml布局中的属性集合
    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs); 
    }

1.1 重写onMeasure

我们自定义View,首先得要测量宽高尺寸。为什么要测量宽高尺寸?有的人要问了,我不是在xml文件中已经指定好了宽高尺寸了吗, 我自定义的View有必要再一次获取宽高去设置宽高吗?既然我自定义的View是继承自View类,google团队直接在View类中直接把xml设置的宽高获取,并且设置进去不就好了吗?为什么要让我们自己来做,真可恨!别着急,既然google让我们做这样的“重复工作”,自然有他的道理。

在学习Android的时候,我们就知道,在xml布局文件中,我们的layout_widthlayout_height参数可以不用写具体的尺寸,而是wrap_content或者是match_parent。其意思我们都知道,就是将尺寸设置为“包住内容”和“填充父布局给我们的所有空间”。这两个设置并没有指定真正的大小,可是我们绘制到屏幕上的View必须是要有具体的宽高的,这回知道了吧?并不是所有情况下我们都会给某个View特定的尺寸的。正是因为这个原因,我们必须自己去处理和设置尺寸。当然了,View类给了默认的处理,但是如果View类的默认处理不满足我们的要求,我们就得重写onMeasure函数啦。这里举个例子,比如我们希望我们的View是个正方形,如果在xml中指定宽高为wrap_content,如果使用View类提供的measure处理方式,显然无法满足我们的需求。

关于onMeasure函数的源码解析,我已经在上一篇文章中做了详细的解释了,不了解的请移步必知必会 | 面试官装逼失败之View的绘制流程。了解了onMeaSure方法的实现原理,在自定义View时我们需要对其进行重写。

讲了太多理论,我们来实际操作一下吧,感受一下onMeasure的使用,现在假设我们要实现这样一个效果:将当前的View以正方形的形式显示,即要宽高相等,并且默认的宽高值为100像素。代码如下:

// defaultSize 默认尺寸,这里为100像素
// measureSpec 测量规格
private int getSize(int defaultSize, int measureSpec) {
        int mySize = defaultSize;
        
        // 测量模式
        int mode = MeasureSpec.getMode(measureSpec);
        // 测量尺寸
        int size = MeasureSpec.getSize(measureSpec);

        switch (mode) {
            case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小
                mySize = defaultSize;
                break;
            }
            case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size
                //我们将大小取最大值,你也可以取其他值
                mySize = size;
                break;
            }
            case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它
                mySize = size;
                break;
            }
        }
        return mySize;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = getSize(100, widthMeasureSpec);
        int height = getSize(100, heightMeasureSpec);

        if (width < height) {
            height = width;
        } else {
            width = height;
        }
      
        // 设置测量之后的参数
        setMeasuredDimension(width, height);
}

布局中使用它:

<com.jieyao.test.MyView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#ff0000" />

使用了我们自己定义的onMeasure函数后的效果:

正方形显示View

而如果我们不重写onMeasure,效果则是如下:

未重写onMeasure的效果

显然重写之后按照了我们意愿去显示的,实现了我们的需求。

1.2 重写onDraw

上面我们学会了自定义尺寸大小,尺寸我们会设定了,接下来就是把我们想要的效果画出来吧~绘制我们想要的效果很简单,直接在画板Canvas对象上绘制就好啦,逻辑过于简单,我们以一个简单的例子去学习:假设我们需要实现的是,我们的View显示一个圆形,我们在上面已经实现了宽高尺寸相等的基础上,继续往下做:

@Override
    protected void onDraw(Canvas canvas) {
        //调用父View的onDraw函数,因为View这个类帮我们实现了一些
        // 基本的而绘制功能,比如绘制背景颜色、背景图片等
        super.onDraw(canvas);
       //也可以是getMeasuredHeight()/2。
       //本例中我们已经将宽高设置相等了。
        int r = getMeasuredWidth() / 2;
        //圆心的横坐标为当前的View的左边起始位置+半径
        int centerX = getLeft() + r;
        //圆心的纵坐标为当前的View的顶部起始位置+半径
        int centerY = getTop() + r;

        Paint paint = new Paint();
        paint.setColor(Color.GREEN);
        //绘制圆形
        canvas.drawCircle(centerX, centerY, r, paint);
    }

效果图如下:

圆形显示

1.3 自定义属性

有时候有些属性我们希望由用户指定,只有当用户不指定的时候才用我们硬编码的值,比如上面的默认尺寸,我们想要由用户自己在布局文件里面指定该怎么做呢?那当然是通我们自定属性,让用户用我们定义的属性啦~

  • 首先我们需要在res/values/attrs.xml文件(如果没有请自己新建)里面声明一个我们自定义的属性:
<resources>
    <!--name为声明的"属性集合"名,可以随便取,但是最好是设置为跟我们的View一样的名称-->
    <declare-styleable name="MyView">
        <!--声明我们的属性,名称为default_size,取值类型为尺寸类型(dp,px等)-->
        <attr name="default_size" format="dimension" />
    </declare-styleable>
</resources>
  • 接下来就是在布局文件用上我们的自定义的属性啦~
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:jieyao="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.jieyao.test.MyView
        android:layout_width="match_parent"
        android:layout_height="100dp"
       jieyao:default_size="100dp" />

</LinearLayout>

注意:需要在根标签(LinearLayout)里面设定命名空间,命名空间名称可以随便取,比如 jieyao,命名空间后面取的值是固定的:"http://schemas.android.com/apk/res-auto"

  • 最后就是在我们的自定义的View的代码里面把我们自定义的属性的值取出来,在构造函数中,还记得有个AttributeSet属性吗?就是靠它帮我们把布局里面的属性取出来:
  private int defalutSize;

  public MyView(Context context, AttributeSet attrs) {
      super(context, attrs);
        //第二个参数就是我们在attrs.xml文件中的<declare-styleable>标签
        //即属性集合的标签,在R文件中名称为R.styleable.name
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);
        
        //第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
        //第二个参数为,如果没有设置这个属性,则设置的默认的值
        defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);
        
        //最后记得将TypedArray对象回收
        a.recycle();
   }

最后,把MyView的完整代码附上:

package com.jieyao.test;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;

public class MyView extends View {

    private int defalutSize;

    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, AttributeSet attrs) {
        super(context, attrs);
        //第二个参数就是我们在styles.xml文件中的<declare-styleable>标签
        //即属性集合的标签,在R文件中名称为R.styleable.name
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);
        //第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
        //第二个参数为,如果没有设置这个属性,则设置的默认的值
        defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);
        //最后记得将TypedArray对象回收
        a.recycle();
    }

    private int getSize(int defaultSize, int measureSpec) {
        int mySize = defaultSize;

        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);

        switch (mode) {
            case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小
                mySize = defaultSize;
                break;
            }
            case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size
                //我们将大小取最大值,你也可以取其他值
                mySize = size;
                break;
            }
            case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它
                mySize = size;
                break;
            }
        }
        return mySize;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = getSize(defalutSize, widthMeasureSpec);
        int height = getSize(defalutSize, heightMeasureSpec);

        if (width < height) {
            height = width;
        } else {
            width = height;
        }

        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //调用父View的onDraw函数,因为View这个类帮我们实现了一些
        // 基本的而绘制功能,比如绘制背景颜色、背景图片等
        super.onDraw(canvas);
        int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我们已经将宽高设置相等了
        //圆心的横坐标为当前的View的左边起始位置+半径
        int centerX = getLeft() + r;
        //圆心的纵坐标为当前的View的顶部起始位置+半径
        int centerY = getTop() + r;

        Paint paint = new Paint();
        paint.setColor(Color.GREEN);
        //绘制圆形
        canvas.drawCircle(centerX, centerY, r, paint);
    }
}

2. 自定义ViewGroup

自定义View的过程很简单,就那几步,可自定义ViewGroup可就没那么简单啦~,因为它不仅要管好自己的,还要兼顾它的子View。我们都知道ViewGroup是个View容器,它装纳child View并且负责把child View放入指定的位置。我们结合一个具体案例来一步步实现自定义ViewGroup的过程:将子View按从上到下以垂直顺序一个挨着一个摆放,即模仿实现LinearLayout的垂直布局。

2.1 重写onMeasure

重写onMeasure,实现测量子View大小以及设定ViewGroup的大小,代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //将所有的子View进行测量,这会触发每个子View的onMeasure函数
        //注意要与measureChild区分,measureChild是对单个view进行测量
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int childCount = getChildCount();// 子View个数

        if (childCount == 0) {//如果没有子View,当前ViewGroup没有存在的意义,不用占用空间
            setMeasuredDimension(0, 0);
        } else { // 有子View,对MeasureSpec为AT_MOST时进行特殊处理
            //如果宽高都是包裹内容
            if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
                //我们将高度设置为所有子View的高度相加,宽度设为子View中最大的宽度
                int height = getTotleHeight();
                int width = getMaxChildWidth();
                setMeasuredDimension(width, height);
            } else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹内容
                //宽度设置为ViewGroup自己的测量宽度,高度设置为所有子View的高度总和
                setMeasuredDimension(widthSize, getTotleHeight());
            } else if (widthMode == MeasureSpec.AT_MOST) {//如果只有宽度是包裹内容
                //宽度设置为子View中宽度最大的值,高度设置为ViewGroup自己的测量值
                setMeasuredDimension(getMaxChildWidth(), heightSize);
            }
        }
    }
    /***
     * 获取子View中宽度最大的值
     */
    private int getMaxChildWidth() {
        int childCount = getChildCount();
        int maxWidth = 0;
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            if (childView.getMeasuredWidth() > maxWidth)
                maxWidth = childView.getMeasuredWidth();
        }
        return maxWidth;
    }

    /***
     * 将所有子View的高度相加
     **/
    private int getTotleHeight() {
        int childCount = getChildCount();
        int height = 0;
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            height += childView.getMeasuredHeight();
        }
        return height;
    }

代码中的注释我已经写得很详细,不再对每一行代码进行讲解,相信很容易理解吧。

2.2 重写onLayout

上面的onMeasure将子View测量好了,以及把自己的尺寸也设置好了,接下来我们去摆放子View吧~只需要重写onLayout方法即可,代码如下:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        //记录当前的高度位置
        int curHeight = t;
        //将子View逐个摆放
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            int height = child.getMeasuredHeight();
            int width = child.getMeasuredWidth();
            //摆放子View,参数分别是子View矩形区域的左、上、右、下边
            child.layout(l, curHeight, l + width, curHeight + height);
            curHeight += height;
        }
    }

自定义ViewGroup已经完成, 我们来测试一下效果,将我们自定义的ViewGroup里面放3个Button ,将这3个Button的宽度设置不一样,把我们的ViewGroup的宽高都设置为包裹内容wrap_content,为了看的效果明显,我们给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="match_parent">

    <com.jieyao.test.MyViewGroup
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#ff9900">

        <Button
            android:layout_width="100dp"
            android:layout_height="wrap_content"
            android:text="btn" />

        <Button
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:text="btn" />

        <Button
            android:layout_width="50dp"
            android:layout_height="wrap_content"
            android:text="btn" />
    </com.hc.studyview.MyViewGroup>
</LinearLayout>

看看最后的效果吧~是不是很激动我们自己也可以实现LinearLayout的效果啦

自定义ViewGroup

最后附上MyViewGroup的完整源码:

package com.jieyao.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;

public class MyViewGroup extends ViewGroup {

    public MyViewGroup(Context context) {
        super(context);
    }

    public MyViewGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    /***
     * 获取子View中宽度最大的值
     */
    private int getMaxChildWidth() {
        int childCount = getChildCount();
        int maxWidth = 0;
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            if (childView.getMeasuredWidth() > maxWidth)
                maxWidth = childView.getMeasuredWidth();
        }
        return maxWidth;
    }

    /***
     * 将所有子View的高度相加
     **/
    private int getTotleHeight() {
        int childCount = getChildCount();
        int height = 0;
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            height += childView.getMeasuredHeight();
        }
        return height;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //将所有的子View进行测量,这会触发每个子View的onMeasure函数
        //注意要与measureChild区分,measureChild是对单个view进行测量
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int childCount = getChildCount();//子View个数

        if (childCount == 0) {//如果没有子View,当前ViewGroup没有存在的意义,不用占用空间
            setMeasuredDimension(0, 0);
        } else { // 有子View,对MeasureSpec为AT_MOST时进行特殊处理
            //如果宽高都是包裹内容
            if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
                //我们将高度设置为所有子View的高度相加,宽度设为子View中最大的宽度
                int height = getTotleHeight();
                int width = getMaxChildWidth();
                setMeasuredDimension(width, height);
            } else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹内容
                //宽度设置为ViewGroup自己的测量宽度,高度设置为所有子View的高度总和
                setMeasuredDimension(widthSize, getTotleHeight());
            } else if (widthMode == MeasureSpec.AT_MOST) {//如果只有宽度是包裹内容
                //宽度设置为子View中宽度最大的值,高度设置为ViewGroup自己的测量值
                setMeasuredDimension(getMaxChildWidth(), heightSize);
            }
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        //记录当前的高度位置
        int curHeight = t;
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            int height = child.getMeasuredHeight();
            int width = child.getMeasuredWidth();
            child.layout(l, curHeight, l + width, curHeight + height);
            curHeight += height;
        }
    }   
}

3. 实战项目

本人虽然是一个Android开发者,却对苹果手机有独特的爱好。经常使用苹果手机的朋友可能知道, 苹果的设置界面有很多滑动的开关按钮, 可以左滑右滑实现某个功能的开启和关闭, 看上去也是很酷炫有没有~今天, 就来实现一下这个功能。

首先,来看一下我实现的滑动开关效果图:

滑动开关效果图

这个滑动开关是一个纯粹的自定义控件,上面的按钮会随着我们的左右滑动而滑动,并且在状态改变时通知用户,这也是应用中设置某些状态信息时最常见的控件。

在实际开发中,完整的实现一个自定义控件,并让该控件具备某个功能,一般来说要有以下几个步骤:

  1. 创建一个view继承自View或者ViewGroup
  2. 定义自定义view的属性
  3. 在代码中获取属性,并给自定义属性相应的设置事件
  4. 根据实际重写自定义view的onMeasure,onLayout,onDraw方法
  5. 与用户进行交互的逻辑实现
  6. 自定义view的代码优化
  • 1.创建view
public class ToggleButton extends View { // 滑动开关类
}
    1. 自定义view属性
  <?xml version="1.0" encoding="utf-8"?>
  <resources>
     <declare-styleable name="ToggleButton">
          <!-- 滑动开关背景图片属性-->
          <attr name="SwitchBtnBackgroud" format="reference" />
           <!-- 滑动块背景图片属性-->
          <attr name="SlidBtnBackgroud" format="reference" />
          <!-- 滑动开关的状态-->
         <attr name="CurrentState" format="boolean" />
     </declare-styleable>
  </resources>
    1. 在代码中获取属性并给自定义属性相应的设置事件
        private Bitmap switchBitmap;//滑动开关的背景图片
        private Bitmap slidBitmap;//滑动块的背景图片
        private boolean currentState;// 滑动开关的状态

        //在xml 中引用该控件时,调用该方法
        public ToggleButton(Context context, AttributeSet attrs) {
                super(context, attrs);
                String namespace = "http://schemas.android.com/apk/res/com.itheima.togglebuttondemo";
                currentState = attrs.getAttributeBooleanValue(namespace, "CurrentState",
                int switchBtnBackgroudId = attrs.getAttributeResourceValue(namespace, "SwitchBtnBackgroud", -1);
                int slidBtnBackgroudId =attrs.getAttributeResourceValue(namespace, "SlidBtnBackgroud", -1);
                setSwitchBtnBackgroudResource(switchBtnBackgroudId);
                setSlidBtnBackgroudResource(slidBtnBackgroudId);
        }
  
        //在代码中创建该控件时,调用该构造方法
        public ToggleButton(Context context) {
                super(context);
        }

        // 为了可以高度自定义和增强可扩展性,我们给滑动按钮背景和滑动块背景都提供了设置方法
        //设置滑动开关的背景图片
        public void setSwitchBtnBackgroudResource(int switchBackground) {
                switchBitmap = BitmapFactory.decodeResource(getResources(), switchBackground);
        }

        // 设置滑动块的背景图片
       public void setSlidBtnBackgroudResource(int slideButtonBackground) {
                slidBitmap = BitmapFactory.decodeResource(getResources(), slideButtonBackground);
       }

      //设置滑动开关的默认状态
      public void setCurrentState(boolean b) {
                currentState = b;
      }
    1. 重写onMeasure方法和onDraw方法
       // 1、测量滑动开关的宽高
       @Override
       protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            // TODO Auto-generated method stub
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            setMeasuredDimension(switchBitmap.getWidth(), switchBitmap.getHeight());
       }
  
       // 2、绘制,画出我们的滑动开关
       //canvas:画布,将图形绘制在canvas,才能显示到屏幕上
       @Override
       protected void onDraw(Canvas canvas) {
           //绘制滑动开关的背景图片
           canvas.drawBitmap(switchBitmap, 0, 0, null);
           //绘制滑动块的背景图片,要根据手势实时绘制
           if(isTouching){//手指触摸的时候,根据currentx 的值来绘制滑动块
               //根据手指的X 值,来绘制滑动块图片
               int left = currentX - slidBitmap.getWidth()/2;
               if(left < 0){//设置左边界
                      left = 0;//左边零点
               }else if(left > (switchBitmap.getWidth() - slidBitmap.getWidth())){//设置右边界
                      left = switchBitmap.getWidth() - slidBitmap.getWidth();//中心点
               }
               canvas.drawBitmap(slidBitmap, left, 0, null);//根据左边界位置绘制滑动块背景
           }else{ // 手指已经离开控件的时候,根据状态来绘制滑动块
               // 根据状态值,来绘制滑动块
               if(currentState){ //当前为true,开关打开,滑动块显示在最右边
                      canvas.drawBitmap(slidBitmap,switchBitmap.getWidth() - slidBitmap.getWidth(),0, null);
               }else{//当前为false,开关关闭,滑动块显示在最左边
                       canvas.drawBitmap(slidBitmap, 0, 0, null);
               }
           }
       }

5.与用户进行交互的逻辑实现

    // 当控件被触摸后,会调用该方法(通过改动isTouching 和currentState的值动态绘制滑动块)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:// 手指按下
            isTouching = true;
            currentX = (int) event.getX();
            break;
        case MotionEvent.ACTION_MOVE:// 手指滑动
            isTouching = true;
            currentX = (int) event.getX();
            break;
        case MotionEvent.ACTION_UP:// 手指抬起
            isTouching = false;
            currentX = (int) event.getX();
            int center = switchBitmap.getWidth() / 2;
            // 当滑动块中心点大于滑动开关背景图片的中心线时,显示到右边,状态改为true
            boolean state = currentState;
            // 获取滑动块的状态
            currentState = currentX > center;
            // 设置滑动块的状态
            // state != currentState说明开关状态发生了改变
            if (mToggleBtnStateChangeListener != null && state != currentState) {
                 mToggleBtnStateChangeListener.onToggleBtnStateChange(currentState);
            }
            break;
        default:
            break;
        }
        // 强制让控件重新绘制,
        invalidate(); //此方法可以强制重新调用onDraw方法
        // 自己处理触摸事件
        return true;
    }

    // 给滑动块设置状态改变监听(方便在activity代码中做相应逻辑处理)
    // 参数为ToggleBtnStateChangeListener 接口,传入之后会回调onToggleBtnStateChange方法。
    // 根据回调方法中的currentState做对应逻辑判断和逻辑处理
    public void setToggleBtnStateChangeListener(
            ToggleBtnStateChangeListener listener) {
        this.mToggleBtnStateChangeListener = listener;
    }

    // 滑动开关状态改变的回调接口
    public interface ToggleBtnStateChangeListener {
        void onToggleBtnStateChange(boolean currentState);
    }
  1. 自定义view的代码优化:

在上面的步骤结束之后,其实一个完善的自定义控件已经出来了。接下来你要做的只是确保自定义控件运行得流畅,官方的说法是:为了避免你的控件看得来迟缓,确保动画始终保持每秒60帧.

下面是官网给出的优化建议:

1、避免不必要的代码
2、在onDraw()方法中不应该有会导致垃圾回收的代码。
3、尽可能少让onDraw()方法调用,大多数onDraw()方法调用都是手动调用了invalidate()的结果,所以如果不是必须,不要调用invalidate()方法。

下面贴出自定义滑动开关的完整源码:

/**
 * 自定义滑动开关
 */
public class ToggleButton extends View {

    private Bitmap switchBitmap;// 滑动开关的背景图片

    private Bitmap slidBitmap;// 滑动块的背景图片

    private boolean currentState; // 当前滑动开关的状态

    private int currentX;// 手指触摸点的X值

    private boolean isTouching = false; // 是否触摸到屏幕

    private ToggleBtnStateChangeListener mToggleBtnStateChangeListener;// 状态改变监听器

    // 在xml中引用该控件时,调用该方法
    public ToggleButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 声明的命名空间
        String namespace = "http://schemas.android.com/apk/res/com.itheima.togglebuttondemo";
        // 获取布局中滑动开关状态的属性
        currentState = attrs.getAttributeBooleanValue(namespace,
                "CurrentState", false);
        // 获取布局中滑动开关背景的属性
        int switchBtnBackgroudId = attrs.getAttributeResourceValue(namespace,
                "SwitchBtnBackgroud", -1);
        // 获取布局中滑动开关滑动块的背景的属性
        int slidBtnBackgroudId = attrs.getAttributeResourceValue(namespace,
                "SlidBtnBackgroud", -1);
        // 根据布局中的属性设置滑动开关背景
        setSwitchBtnBackgroudResource(switchBtnBackgroudId);
        // 根据布局中的属性设置滑动开关滑动块的背景
        setSlidBtnBackgroudResource(slidBtnBackgroudId);
    }

    // 在代码中创建该控件时,调用该构造方法
    public ToggleButton(Context context) {
        super(context);
    }

    // 设置滑动开关的背景图片
    public void setSwitchBtnBackgroudResource(int switchBackground) {
        switchBitmap = BitmapFactory.decodeResource(getResources(),
                switchBackground);
    }

    // 设置滑动块的背景图片
    public void setSlidBtnBackgroudResource(int slideButtonBackground) {
        slidBitmap = BitmapFactory.decodeResource(getResources(),
                slideButtonBackground);
    }

    // 设置滑动开关的默认状态
    public void setCurrentState(boolean b) {
        currentState = b;
    }

    // 1、测量滑动开关的宽高
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(switchBitmap.getWidth(), switchBitmap.getHeight());
    }

    // 2、绘制,画出我们的滑动开关
    // canvas:画布,将图形绘制在canvas,才能显示到屏幕上
    @Override
    protected void onDraw(Canvas canvas) {
        // 绘制滑动开关的背景图片
        canvas.drawBitmap(switchBitmap, 0, 0, null);
        // 绘制滑动块的背景图片
        if (isTouching) {// 手指触摸的时候,根据currentX的值来绘制滑动块
            // 根据手指的X值,来绘制滑动块图片
            int left = currentX - slidBitmap.getWidth() / 2;
            if (left < 0) { // 设置左边界
                left = 0;
            } else if (left > (switchBitmap.getWidth() - slidBitmap.getWidth())) {// 设置右边界
                left = switchBitmap.getWidth() - slidBitmap.getWidth();
            }
            canvas.drawBitmap(slidBitmap, left, 0, null);
        } else {// 手指离开控件的时候,根据状态来绘制滑动块
                // 根据状态值,来绘制滑动块
            if (currentState) {// 当前为true,开关打开,滑动块显示在最右边
                canvas.drawBitmap(slidBitmap, switchBitmap.getWidth()
                        - slidBitmap.getWidth(), 0, null);
            } else {// 当前为false,开关关闭,滑动块显示在最左边
                canvas.drawBitmap(slidBitmap, 0, 0, null);
            }
        }
    }

    // 当控件被触摸后,会调用该方法
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:// 手指按下
            isTouching = true;
            currentX = (int) event.getX();
            break;
        case MotionEvent.ACTION_MOVE:// 手指滑动
            isTouching = true;
            currentX = (int) event.getX();
            break;
        case MotionEvent.ACTION_UP:// 手指抬起
            isTouching = false;
            currentX = (int) event.getX();
            int center = switchBitmap.getWidth() / 2;
            // 当滑动块中心点大于滑动开关背景图片的中心线时,显示到右边,当前状态为true
            boolean state = currentState;
            // 获取滑动块的状态
            currentState = currentX > center;
            // 设置滑动块的状态
            if (mToggleBtnStateChangeListener != null && state != currentState) {
                mToggleBtnStateChangeListener
                        .onToggleBtnStateChange(currentState);
            }
            break;
        default:
            break;
        }
        // 强制让控件重新绘制,重新调用onDraw方法
        invalidate();
        // 自己处理触摸事件
        return true;
    }

    // 给滑动块设置状态改变监听
    public void setToggleBtnStateChangeListener(
            ToggleBtnStateChangeListener listener) {
        this.mToggleBtnStateChangeListener = listener;
    }

    // 滑动开关状态改变的回调接口
    public interface ToggleBtnStateChangeListener {
        void onToggleBtnStateChange(boolean currentState);
    }
}

大功告成O(∩_∩)O哈哈~ 下面就是使用啦~

xml布局文件如下:

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

    <com.jieyao.togglebuttondemo.view.ToggleButton 
        android:id="@+id/togglebutton"
        android:layout_width="wrap_content"
        android:layout_centerInParent="true"
        jieyao:SwitchBtnBackgroud="@drawable/switch_background"
        jieyao:SlidBtnBackgroud="@drawable/slide_button_background"
        jieyao:CurrentState="false"
        android:layout_height="wrap_content"/>

</RelativeLayout>

activity中使用~

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // 初始化滑动开关
        ToggleButton togglebutton = (ToggleButton) findViewById(R.id.togglebutton);
        // 设置滑动开关的背景图片
        togglebutton.setSwitchBtnBackgroudResource(R.drawable.switch_background);
        // 设置滑动块的背景图片
        togglebutton.setSlidBtnBackgroudResource(R.drawable.slide_button_background);
        // 设置滑动开关的默认状态
        togglebutton.setCurrentState(true);
        // 设置滑动开关状态监听
        togglebutton.setToggleBtnStateChangeListener(new ToggleBtnStateChangeListener() {

                    @Override
                    public void onToggleBtnStateChange(boolean currentState) {
                        //下面就是根据currentState状态做相应的逻辑咯,根据需求来做
                        if (currentState) {
                            Toast.makeText(getApplicationContext(), "开关打开",Toast.LENGTH_SHORT).show();
                        } else {
                            Toast.makeText(getApplicationContext(), "开关关闭",Toast.LENGTH_SHORT).show();
                        }
                    }
                });
    }
}

效果图如下:

滑动开关效果

以上就是自定义View的全过程啦~ 希望能对你们有帮助~! 本人技术有限,如有错误,还请指出,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容