项目Demo:https://github.com/liaozhoubei/CustomViewDemo
自定义的开关视图
前面我们学习了几个自定义视图,但是我们发现了一个特点,那就是那些自定义视图都是通过现有的组件的组合做出来的视图,虽然也属于自定义视图的一种,但也可以说是伪自定义视图。那么怎样样才能够真正定义出自己的视图,下面我们通过学习直接继承View类,来获取一个开光按键,效果如下图:
这次的目标是定义一个开关,它的功能要像系统组件一样,可以在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