自定义View实践 - 仿微信的滑动按钮

前言

前几天写过一篇文章View的工做原理,有原理不行,还要有实践,恰好把之前项目写过的仿微信滑动按钮控件封装一下,因此本文记录一下我实现这个控件的细节。java

效果图

控件使用效果以下:android

除了颜色,看起来和微信的仍是挺像的。git

准备

一、选择自定义View的方式

自定义View有3种途径实现:一、组合控件;二、继承现有控件(如Button);三、继承View。下面分别介绍一下:github

  • 一、组合控件:咱们并不须要本身去绘制视图上显示的内容,而是将几个系统原生的控件组合到一块儿,这样建立出的控件就被称为组合控件,好比标题栏就是个很常见的组合控件。
  • 二、继承现有控件:咱们并不须要本身从新去实现一个控件,只须要去继承一个现有的控件,而后在这个控件上增长一些新的功能。它的优势就是不只可以按照咱们的需求加入相应的功能,而且还能够继承现有控件已经封装好的属性,同时不用本身定义测量流程。
  • 三、继承View:咱们继承View,重写相应的方法,从新去实现一个控件。它的优势就是灵活性高,它给你一张白纸,你用画笔尽情发挥。

现实状况使用什么方式根据实际状况考虑,我这个控件的选择是方式3: 继承View,重写onMeasure方法定义它的测量流程,重写onDraw()方法定义它的绘制流程。canvas

二、选择让控件内容滑动的方式

既然是滑动按钮,确定有滑动,当我点击按钮时,若是是打开,按钮的小圆会滑向右边,若是是关闭,按钮的小圆会滑向左边。让控件的内容滑动起来我想到的有3种方式:微信

  • 一、经过Scroller:调用Scroller的startScroll()方法,传入起始点坐标和终点坐标,而后重写View的computeScroll()方法,在这个方法里面调用Scroller的computeScrollOffset()方法开始滑动计算,而后调用View的scrollTo()或scrollBy()方法完成View的滑动距离的更新,而后调用View的invalidate()或postInvalidate()方法重绘View。
  • 二、经过Handler不断的发送延时消息:经过Handler的 sendMessageDelayed(Message msg, long delayMillis)方法不断的发送延时消息,在Handler的handlerMessage()中收到消息后,完成滑动距离的更新,而后调用View的invalidate()或postInvalidate()方法重绘View。
  • 三、经过动画:利用补间动画或属性动画的平移动画可让View动起来,或者经过ValueAnimator,设定一个初始值和结点值,当调用ValueAnimator的start()方法后,就能够在回调中获取动画的进度,而后根据动画的进度更新滑动距离,而后调用View的invalidate()或postInvalidate()方法重绘View。

对于方法1,它更适用于自定义ViewGroup的情景,若是自定义ViewGroup中有许多子View须要滑动起来,就能够考虑使用Scroller,例如Android的ViewPager内部就是使用了Scroller;而对于自定义View,可能方法2和3更适用,我这个控件的选择是方式3: 经过ValueAnimator动画,在构造ValueAnimator时传入起点和终点,而后开启动画,根据动画进度计算滑动距离,让按钮的小圆滑动起来。app

三、要不要考虑padding属性

若是你在自定义控件中没有考虑padding属性,那么用户定义控件的padding值就会失效,个人选择是不考虑用户的padding值,由于滑动按钮中的内容只有一个小圆,且只在一边,padding的意义不大,考虑padding会让不少地方的坐标计算复杂,我还不如让用户直接控制小圆的半径,这样也相似于padding的效果,也简化了计算。less

因此现实状况要不要考虑padding属性须要根据实际状况考虑。而margin值是由父ViewGroup决定,不是由View控制的,咱们不用考虑margin值。ide

实现

一、定义控件属性

在自定义滑动按钮以前,咱们先思考可让用户自定义这个控件的什么属性,如按钮颜色,打开状态和关闭状态的颜色等,在 res -> values 中,右键新建一个名为attrs的xml文件,在这个文件中定义控件属性,以下:函数

<resources>

    <declare-styleable name="SwitchButton" >
        <attr name="sb_openBackground" format="color"/>
        <attr name="sb_closeBackground" format="color"/>
        <attr name="sb_circleColor" format="color"/>
        <attr name="sb_circleRadius" format="dimension"/>
        <attr name="sb_status">
            <enum name="close" value="0"/>
            <enum name="open" value="1"/>
        </attr>
        <attr name="sb_interpolator">
            <enum name="Linear" value="0"/>
            <enum name="Overshoot" value="1"/>
            <enum name="Accelerate" value="2"/>
            <enum name="Decelerate" value="3"/>
            <enum name="AccelerateDecelerate" value="4"/>
            <enum name="LinearOutSlowIn" value="5"/>
        </attr>
    </declare-styleable>

</resources>
复制代码

这样用户在引用这个控件时就能使用这些属性,以下:

<com.example.library.SwitchButton android:id="@+id/sb_button2" android:layout_width="wrap_content" android:layout_height="wrap_content" app:sb_interpolator="Accelerate" app:sb_status="open" app:sb_circleRadius="10dp" app:sb_closeBackground="@android:color/black" app:sb_openBackground="@android:color/holo_blue_bright" app:sb_circleColor="@android:color/white" />
复制代码

属性的名称要作到见名知意,app只是一个命名空间,取什么名字均可以,不要和系统android相同就行。关于这些属性什么意思能够看SwitchButton

二、初始化控件属性

重写View的3个构造方法,分别在3个构造函数中调用init()方法获取控件属性并初始化控件,以下:

public class SwitchButton extends View {
    public SwitchButton(Context context) {
        super(context);
        init(context, null);
    }

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

    public SwitchButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, @Nullable AttributeSet attrs) {
        TypedArray typedValue = context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
        mOpenBackground = typedValue.getColor(R.styleable.SwitchButton_sb_openBackground, DEFAULT_OPEN_BACKGROUND);
        mCloseBackground = typedValue.getColor(R.styleable.SwitchButton_sb_closeBackground, DEFAULT_CLOSE_BACKGROUND);
        //...
        typedValue.recycle();
         //...
        //初始画笔,动画等
    }
}
复制代码

咱们在attrs中定义的控件属性都在AttributeSet这个集合中,而后经过TypedArray这个类帮助咱们把值获取出来,最后必定要记得调用 typedValue.recycle() 方法回收资源。

为何要重写3个构造函数呢?由于你的控件有可能在代码中引用或者在xml布局中引用,若是你的控件在xml布局中被引用,那么系统就会调用含有两个参数的构造函数来初始化控件;若是你直接在代码中 new 一个控件而后 add 到容器中,那么大多数状况你会使用含有一个参数的构造函数来初始化控件,如:SwitchButton button = new SwitchButton(this),而无论一个参数的仍是两个参数的系统最终都会调用含有三个参数的构造函数,以防万一,3个构造函数都要重写。

三、重写onMeasure方法,设定按钮的测量宽高

重写onMeasure方法在这个方法设定滑动控件的测量宽高,以下:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int measuredWidthMode = MeasureSpec.getMode(widthMeasureSpec);
    int measuredHeightMode = MeasureSpec.getMode(heightMeasureSpec);
    //取出系统测量宽高
    int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
    int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
    
    int defaultWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 60, getResources().getDisplayMetrics());//控件的默认宽
    int defaultHeight = (int) (defaultWidth *  0.5f);//控件的默认高是默认宽的一半
    
    //OFFSET == 6
    int offset = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, OFFSET * 2 * 1.0f, getResources().getDisplayMetrics());//控件宽和高的差距不能小于12dp, 不然按钮就很差看了
    
    //考虑wrap_content状况
    if(measuredWidthMode == MeasureSpec.AT_MOST && measuredHeightMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        measureHeight = defaultHeight;
    }else if(measuredHeightMode == MeasureSpec.AT_MOST){
        measureHeight = defaultHeight;
        if(measuredWidth - measureHeight < offset)
            measuredWidth = defaultWidth;
    }else if(measuredWidthMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        if(measuredWidth - measureHeight < offset)
            measureHeight = defaultHeight;
    }else {
        //处理输入非法的宽高状况,即高度大于宽度,把它们交换就行
        if(measuredWidth < measureHeight){
            int temp = measuredWidth;
            measuredWidth = measureHeight;
            measureHeight = temp;
        }
    }
    
    if(Math.abs(measureHeight - measuredWidth) < offset) throw new IllegalArgumentException("layout_width cannot close to layout_height nearly, the diff must less than 12dp!");
    
    setMeasuredDimension(measuredWidth, measureHeight);
    
}
复制代码

若是知道View的工做原理,那么理解上面的代码就很简单,主要是考虑wrap_content状况,咱们要给滑动按钮设置一个默认的宽或高,默认的宽是60dp,默认高是30dp即宽的一半,若是不是wrap_content状况就让View直接使用系统测量的宽或高,最后必定要记得调用setMeasuredDimension()设定View的测量宽高。

同时咱们还要考虑理输入非法的宽高状况,必定要保证宽 > 高,若是用户输入的宽高是 宽 < 高,这样会致使按钮竖起来,这种状况,我直接让高度与宽度交换;若是用户输入的宽高是 宽 > 高,可是若是高很接近宽甚至相等,那么致使滑动控件就是一个圆形,按钮就很差看了,因此咱们还要控制宽高不能相差得太近,为了美观,我设定阈值是12dp,若是宽高相差小于12dp,我就抛个异常提示用户。

四、在onLayout()方法中根据View的宽高计算坐标

滑动控件被分为4个部分:左圆、矩形、右圆、小圆,以下:

在onDraw()方法中也会按顺序绘制滑动按钮的4个部分,在View的工做原理中讲到,onMeasure()有可能会被系统调用屡次,因此最好在onLayout()方法中经过getHeight()和getWidth()方法得到View的真实宽高,因此在onLayout()方法中首先根据View的宽高计算出左圆的半径,小圆的半径,矩形左边界的x坐标,矩形右边界的x坐标,还有小圆圆心的x坐标,以下:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    //得出左圆的半径
    mLeftSemiCircleRadius = getHeight() / 2;
    //小圆的半径 = 大圆半径减OFFER,OFFER = 6
    if(!checkCircleRaduis(mCircleRadius)) mCircleRadius = mLeftSemiCircleRadius - OFFSET;
    //矩形左边的x坐标
    mLeftRectangleBolder = mLeftSemiCircleRadius;
    //矩形右边的x坐标
    mRightRectangleBolder = getWidth() - mLeftSemiCircleRadius;
    //小圆的圆心x坐标一直在变化
    mCircleCenter = isOpen ? mRightRectangleBolder : mLeftRectangleBolder;
}
复制代码

能够看到左圆的半径等于View高的一半,而后基于左圆的半径得出其余坐标,小圆与左圆之间会有一些空隙,因此左圆半径减去offset值得出小圆半径,矩形左边的x坐标直接等于左圆的半径,矩形右边的x坐标View的宽度减左圆的半径,小圆圆心的x坐标根据初始状态是开启仍是关闭,决定它的圆心的初始坐标是在矩形的右边界仍是左边界。

在接下来只要你不断的改变小圆圆心的x坐标并重绘View,就可让滑动按钮滑动起来。

五、重写onDraw()方法,绘制按钮内容

View的工做原理中咱们知道,View会在onDraw()方法中绘制本身,因此咱们重写onDraw()方法,绘制滑动按钮的四个部分,以下:

@Override
protected void onDraw(Canvas canvas) {
    //左圆
    canvas.drawCircle(mLeftRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    //矩形
    canvas.drawRect(mLeftRectangleBolder, 0, mRightRectangleBolder, getMeasuredHeight(), mPathWayPaint);
    //右圆
    canvas.drawCircle(mRightRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    //小圆
    canvas.drawCircle(mCircleCenter, mLeftSemiCircleRadius, mCircleRadius, mCirclePaint);
}

复制代码

canvas是系统提供给咱们的画布,在canvas绘制的东西就是View显示的内容,根据在onLayout中的计算,咱们用画笔Paint在canvas中绘制出滑动按钮的4个部分,绘制后显示以下:

接下来就是让它滑动起来,这样就能达到效果图的效果。

六、重写onTouchEvent()方法,让按钮滑动起来

View的事件分发机制讲到,触摸事件若是不被拦截,最终会分发到View的onTouchEvent()方法中,在这个方法中咱们能够根据事件的类型作出滑动按钮的不一样行为,咱们知道当手指按下按钮而后抬起,滑动按钮的小圆就会滑动到另外一边;当手指按下按钮而后移动,滑动按钮的小圆也会跟随手指移动,知道了这两个行为后,咱们看onTouchEvent()方法以下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    //不在动画的时候能够点击
    if(isAnim) return false;
    switch(event.getAction()){
        case MotionEvent.ACTION_DOWN:
            //开始的x坐标
            startX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            float distance = event.getX() - startX;
            //更新小圆圆心坐标
            mCircleCenter += distance / 10;
            //控制范围
            if (mCircleCenter > mRightRectangleBolder) {//最右
                mCircleCenter = mRightRectangleBolder;
            } else if (mCircleCenter < mLeftRectangleBolder) {//最左
                mCircleCenter = mLeftRectangleBolder;
            }
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            float offset = Math.abs(event.getX() - Math.abs(startX));
            float diff;
            //分2种状况
            if (offset < mMinDistance) { //1.点击, 按下和抬起的距离小于mMinDistance肯定是点击了
                if(isOpen){
                    diff = mLeftRectangleBolder - mCircleCenter;
                }else{
                    diff = mRightRectangleBolder - mCircleCenter;
                }
            } else {//2.滑动
                if (mCircleCenter > getWidth() / 2) {//滑过中点,滑到最右
                    this.isOpen = false;
                    diff = mRightRectangleBolder - mCircleCenter;
                } else{//没滑过中点,回归原点
                    this.isOpen = true;
                    diff = mLeftRectangleBolder - mCircleCenter;
                }
            }
            mValueAnimator.setFloatValues(0, diff);
            mValueAnimator.start();
            startX = 0;
            break;
        default:
            break;
    }
    return true;
}

复制代码

咱们先看ACTION_DOWN,当手指按下,咱们记录手指按下的x坐标。

接着看ACTION_MOVE,若是按下后移动,咱们就让小圆跟随手指移动便可,因此ACTION_MOVE中先计算出手指移动的距离distance,往右移distance是正数,往左移distance是负数,而后加到小圆的圆心坐标,还要控制小圆的圆心坐标的范围,不要超出矩形左右边界,最后调用 invalidate()重绘View,这样onDraw()方法就会从新执行,更新小圆的位置,就会让小圆慢慢滑动起来。

最后看ACTION_UP,mMinDistance = new ViewConfiguration().getScaledTouchSlop(),它是系统定义的临界值,当抬起手指时,若是移动的距离offset大于mMinDistance ,就认为抬起手指前,手指在移动,不然就认为在点击。若是手指在移动后抬起,这时就判断小圆圆心是否滑过中点算出滑动距离,若是滑过中点(getWidth() / 2),就让小圆滑到最右,若是没有滑过中点,就让小圆滑到最左;若是手指只是在点击控件,这时就根据控件目前处于开启仍是关闭状态算出滑动距离,若是目前处于开启状态,就让小圆滑到最左,若是目前处于关闭状态就让小圆滑到最右;而这个滑动距离diff就是小圆圆心到矩形边界的距离,至因而距离左边界仍是右边界,就看上述状况了,计算出滑动距离后设置给ValueAnimator,最后开启动画,在ValueAnimator的updateListener中接收动画进度,以下:

mValueAnimator.addUpdateListener(animation -> {
    float value = (float)animation.getAnimatedValue();
    mCircleCenter -= mPreAnimatedValue;
    //更新小圆圆心坐标
    mCircleCenter += value;
    mPreAnimatedValue = value;
    invalidate();
});
复制代码

在里面根据动画进度更新小圆圆心坐标,而后调用 invalidate()重绘View,这样onDraw()方法就会从新执行,更新小圆的位置,这样重复执行直到动画结束,就会让小圆慢慢滑动起来。

结语

到最后就已经实现了效果图的效果,整个过程的原理仍是挺简单,使用到了动画还有自定义View的基础知识,赶快动手实践一下。

地址:SwitchButton

相关文章
相关标签/搜索