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

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

推荐阅读更多精彩内容