Android 精通自定义视图(3)

项目Demo:https://github.com/liaozhoubei/CustomViewDemo

自定义的开关视图

前面我们学习了几个自定义视图,但是我们发现了一个特点,那就是那些自定义视图都是通过现有的组件的组合做出来的视图,虽然也属于自定义视图的一种,但也可以说是伪自定义视图。那么怎样样才能够真正定义出自己的视图,下面我们通过学习直接继承View类,来获取一个开光按键,效果如下图:

toggleview.gif

这次的目标是定义一个开关,它的功能要像系统组件一样,可以在xml中设定它的属性,可以在代码中调用它的方法。下面我们就来分析这个自定义开关的代码,研究它的构成吧!代码如下:

public class ToggleView extends View {
    private Bitmap mSlideButtonBitmap;
    private Bitmap mSwitchBackgroundBitmap; // 背景图片
    private Paint mPaint;
    private boolean mSwitchState = false; // 开关状态, 默认false
    private float mCurrentX; // 滑动的位置
    private boolean isTouchMode = false;
    private OnSwitchStateUpdateListener onSwitchStateUpdateListener;

    /**
     * 用于代码创建控件
     * @param context
     */
    public ToggleView(Context context) {
        super(context);
        init();
    }
    /**
     * 用于在xml里使用, 可指定自定义属性
     * @param context
     * @param attrs
     */
    public ToggleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();

        // 第一种在获取配置的自定义属性,官方推荐
        TypedArray attrsArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ToggleView, 0,0);
        // R.styleable.ToggleView是在attrs.xml中给定属性的名字,后两个为默认值,0代表不寻找默认值
        // 获取在XML中设置的布尔值
        mSwitchState = attrsArray.getBoolean(R.styleable.ToggleView_switch_state, false);
        // 获取从xml中得到的图片资源ID
        int switch_background = attrsArray.getResourceId(R.styleable.ToggleView_switch_background, -1);
        int slide_button = attrsArray.getResourceId(R.styleable.ToggleView_slide_button, -1);        

        setSwitchBackgroundResource(switch_background);
        setSlideButtonResource(slide_button);
        setSwitchState(mSwitchState);
    }

    /**
     * 用于在xml里使用, 可指定自定义属性, 如果指定了样式, 则走此构造函数
     * @param context
     * @param attrs
     * @param defStyle
     */
    public ToggleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mPaint = new Paint();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(mSwitchBackgroundBitmap.getWidth(), mSwitchBackgroundBitmap.getHeight());
    }
    // Canvas 画布, 画板. 在上边绘制的内容都会显示到界面上.
    @Override
    protected void onDraw(Canvas canvas) {
        // 1. 绘制背景
        canvas.drawBitmap(mSwitchBackgroundBitmap, 0, 0, mPaint );
        // 2. 绘制滑块
        if (isTouchMode) {
    // 根据当前用户触摸到的位置画滑块

            // 让滑块向左移动自身一半大小的位置
            float newPositon = mCurrentX - mSlideButtonBitmap.getWidth() / 2.0f;
            float maxWidth = mSwitchBackgroundBitmap.getWidth() - mSlideButtonBitmap.getWidth();
            // 限定滑块范围
            if (newPositon < 0) {
                newPositon = 0; // 左边范围
            }
            if (newPositon > maxWidth) {
                newPositon = maxWidth; // 右边范围
            }

            canvas.drawBitmap(mSlideButtonBitmap, newPositon, 0, mPaint);
        } else {
            // 根据开关状态boolean, 直接设置图片位置
            if (mSwitchState){ // 开
                float maxWidth = mSwitchBackgroundBitmap.getWidth() - mSlideButtonBitmap.getWidth();
                canvas.drawBitmap(mSlideButtonBitmap, maxWidth, 0, mPaint);
            } else { // 关
                canvas.drawBitmap(mSlideButtonBitmap, 0, 0, mPaint);
            }
        }

    }

    // 重写触摸事件, 响应用户的触摸.
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            isTouchMode = true;
            mCurrentX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            mCurrentX = event.getX();
            break;
        case MotionEvent.ACTION_UP:
            isTouchMode =false;
            mCurrentX = event.getX();

            float center = mSwitchBackgroundBitmap.getWidth() / 2;
            // 根据当前按下的位置, 和控件中心的位置进行比较.
            boolean state = mCurrentX > center;

            // 如果开关状态变化了, 通知界面. 里边开关状态更新了.
            if (state != mSwitchState && onSwitchStateUpdateListener != null){
                // 把最新的boolean, 状态传出去了
                onSwitchStateUpdateListener.onStateUpdate(state);
            }

            mSwitchState = state;
            break;

        default:
            break;
        }
        // 重绘界面
        invalidate(); // 会引发onDraw()被调用, 里边的变量会重新生效.界面会更新
        return true; // 消费了用户的触摸事件, 才可以收到其他的事件.
    }

    /**
     * 设置背景图
     * @param switchBackground
     */
    public void setSwitchBackgroundResource(int switchBackground) {
        mSwitchBackgroundBitmap = BitmapFactory.decodeResource(getResources(), switchBackground);
    }
    /**
     * 设置滑块图片资源
     * @param slideButton
     */
    public void setSlideButtonResource(int slideButton) {
        mSlideButtonBitmap = BitmapFactory.decodeResource(getResources(), slideButton);
    }
    public void setSwitchState(boolean mSwitchState) {
        this.mSwitchState = mSwitchState;

    }
    /**
     * 设置开关状态
     * @param b
     */
    public interface OnSwitchStateUpdateListener{
        // 状态回调, 把当前状态传出去
        void onStateUpdate(boolean state);
    }

    public void setOnSwitchStateUpdateListener(OnSwitchStateUpdateListener onSwitchStateUpdateListener){
        this.onSwitchStateUpdateListener = onSwitchStateUpdateListener;
    }

}

这么一长串的代码看上去有点触目惊心的感觉,但是不要怕,我们只要一个方法一个方法的分析很快就能分析完的。

首先我们分析它的三个构造方法:

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

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

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

这三个构造方法是继承自view类的,而且是必需要重写的构造方法。第一个只有Context参数的方法是用于代码创建控件的方法,用的可多了,直接new TextView(getContext())这种形式都是用它构造出来的。而拥有两个参数的构造方法则是用来获取在xml中设定好的自定义属性值的,如在ToggleView这个项目中用这些方法获取值:

TypedArray attrsArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ToggleView, 0,0);             
    mSwitchState = attrsArray.getBoolean(R.styleable.ToggleView_switch_state, false);        
    int switch_background = attrsArray.getResourceId(R.styleable.ToggleView_switch_background, -1);
    int slide_button = attrsArray.getResourceId(R.styleable.ToggleView_slide_button, -1);

setSwitchBackgroundResource(switch_background);
    setSlideButtonResource(slide_button);
    setSwitchState(mSwitchState);

TypedArray是用于管理属性类型的数组,我们将获得的所有属性都暂时存储在里面,然后在里面获取。以attrsArray.getBoolean()为例,需要传入getBoolean()中的参数,第一个是属性名称路径,第二个则是默认值,其他获取属性值的方法类似。当获取到属性的值之后,再通过设置控件的值,我们就能够在项目中直接使用xml设定属性值了。

但是在使用之前我们需要在res-values中创建attrs.xml文件,这种文件用于设定自定义属性的,在这个文件中我们添加了一下代码:

<resources>
    <declare-styleable name="ToggleView">
        <attr name="switch_background" format="reference" />
        <attr name="slide_button" format="reference" />
        <attr name="switch_state" format="boolean" />
    </declare-styleable>
</resources>

开头的declare-styleable name="ToggleView"代表我们自定义属性文件名为ToggleView,然后里面的attr则是自定义的属性名和属性类型,format则代表这个属性的类型。

虽然上面我们已经能够通过xml获取属性值,也能设置控件的值,但是如果在xml布局文件中使用也是要讲究技巧的。

使用自定义控件

使用自定义控件我们需要使用控件的全路径,也就是 包名.类名,如下:

 <com.example.customviewdemo.Toggleview.ToggleView
android:id="@+id/toggleView"
android:layout_centerInParent="true"
toggleview:switch_background="@drawable/switch_background"
toggleview:slide_button="@drawable/slide_button"
toggleview:switch_state="true"
android:layout_width="match_parent"
android:layout_height="match_parent" />

在这里我们观察到有个toggleview:switch_background,这是使用我们自定义属性的方法,toggleview表示命名空间(可自由更改,但是在使用时要相同),但是我们现在没有命名空间,因此可以在根布局中定义一个命名空间,命名规则为: xmlns:[空间名]="http://schemas.android.com/apk/res/[包名]",ToggleView的命名空间如下:

项目Demo:https://github.com/liaozhoubei/CustomViewDemo
xmlns:toggleview="http://schemas.android.com/apk/res/com.example.customviewdemo"

设定好命名空间之后就能够像普通的控件属性一样使用了。继续分析toggleview:switch_background,其中的switch_background表示之前在attrs中定义的属性名,在这里我们已经可以直接设置背景图片了。

回到构造方法中,分析拥有三个参数的构造方法,这个方法是当控件有设置style样式的时候使用的,我们并没有设置样式,所以不需理会。

分析完构造方法之后,我们创建了三个方法,它们是通过代码设置属性,如下:

public void setSwitchBackgroundResource(int switchBackground) {
  mSwitchBackgroundBitmap = BitmapFactory.decodeResource(getResources(), switchBackground);
}

public void setSlideButtonResource(int slideButton) {
  mSlideButtonBitmap = BitmapFactory.decodeResource(getResources(), slideButton);
}
public void setSwitchState(boolean mSwitchState) {
  this.mSwitchState = mSwitchState;  
}

代码很简单,也就不多做解释。

然后,我们要思考到这个控件已经被创建出来呢,也设置了图片资源,那么接下来应该怎么办呢?很明显,控件既然已经被构造出来,那么就应该在屏幕中显示出来,所以我们重写了onDraw()方法,但是有个问题,那就是我们还不知道控件的大小。想要画出一个东西,却不知道控件的大小怎么可以,所以我们重写了onMeasure()方法。

使用onMeasure()方法的时候,我们要注意一点,那就是一个控件展示在屏幕中,是在Activity活动setContentView()设置好布局之后,在Activity的生命中期走到onResume()的时候才会显示控件,在这之前是没有控件的,也就意味着没有控件的大小。

这时我们无法直接使用getWidth()/getHeight()方法来获取控件的高度和宽度。没关系,View类中有setMeasuredDimension()方法能够测量到给定图片的宽高,如下:

setMeasuredDimension(mSwitchBackgroundBitmap.getWidth(), mSwitchBackgroundBitmap.getHeight());

这个方法是将获得的图片资源原始的宽高得到。而使用普通的getWidth()/getHeight()则是当控件在屏幕中出现之后才能获取宽高,否则为0!

在获取图片宽高之后,我们就能够正常的使用getWidth()/getHeight()方法了。在onDraw()方法中使用canvas.drawBitmap()便可将图片在屏幕中绘制出来了。

其实走到这一步我们基本上已经完成了自定义视图的所有步骤了。

在onDraw()方法里面还有很多代码,里面的意思是限定开关按键的图片的位置,让其位置限定在某个范围之内,保证其不会发生跑出开关位置的bug。

onTouchEvent()触摸事件也是同样的逻辑,在用手指滑动的时候保证其能够左右滑动,并且停留在开或者关的位置,触摸事件最后调用了

invalidate();

这个方法表示重绘视图,每次开关被移动之后都要重新绘制一遍,让开关动起来。

最后我们还是用接口的方式将当前开关状态传出去,代码如下:

public interface OnSwitchStateUpdateListener{
// 状态回调, 把当前状态传出去
void onStateUpdate(boolean state);
}

public void setOnSwitchStateUpdateListener(OnSwitchStateUpdateListener onSwitchStateUpdateListener){
this.onSwitchStateUpdateListener = onSwitchStateUpdateListener;
}

当这个方法被重写,在onTouchEvent()触摸事件中的MotionEvent.ACTION_UP手指抬起时就会调用接口中的方法,代码如下:

if (state != mSwitchState && onSwitchStateUpdateListener != null){
  // 把最新的boolean, 状态传出去了
  onSwitchStateUpdateListener.onStateUpdate(state);
}

这里简单的解析了一下自定义开关的实现原理,里面的代码还是需要大家多多研究才能够吃透弄懂

扩展阅读:

Android 精通自定义视图(1) http://www.jianshu.com/p/c2195269ce44

Android 精通自定义视图(2) http://www.jianshu.com/p/092e126b623f

Android 精通自定义视图(4) http://www.jianshu.com/p/850e387fc9d8

Android 精通自定义视图(5) http://www.jianshu.com/p/93feac19c396

推荐阅读更多精彩内容