Android 精通自定义视图(3)

作者: 宝塔山上的猫 | 来源:发表于2016-07-29 09:31 被阅读243次

    项目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

    相关文章

      网友评论

        本文标题:Android 精通自定义视图(3)

        本文链接:https://www.haomeiwen.com/subject/rwinjttx.html