自定义控件之---SlideView开关控件

1.演示:

别的不谈,先看下效果:

slideview.gif

2.分析:

在做自定义控件之前,最重要的就是分析你要实现的控件的功能以及效果。将他们拆分成各个模块,然后一一实现。这里我们分析一下这个SlideView。

  • (1) 由一个圆角矩形背景以及一个圆形滑块组成。
  • (2) 圆形滑块可以左右滑动,在滑动时,背景有一个渐变的效果。即圆形滑块使用了平移动画,背景使用了透明度动画。
  • (3) 圆形滑块没有紧贴背景的矩形,有一定的间隙。

3.实现:

剖析完控件之后,我们就可以按步骤一步步来实现了。

控件测量与绘制

首先建立一个SlideView,继承我们的View小哥~

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

  public SlideView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public SlideView(Context context) {
    this(context, null);
  }

在init方法中初始化我们的画笔

  // 绘制背景
  private Paint bgPaint;
  // 绘制圆形滑块
  private Paint circlePaint;
  //关闭时默认背景颜色
  public static final int CLOSE_PAINT_COLOR = 0x667f7f7f;
  //打开时默认背景颜色
  public static final int OPEN_PAINT_COLOR = 0xFF3378D4;
  //打开时背景颜色
  private int openColor = OPEN_PAINT_COLOR;
  //关闭时背景颜色
  private int closeColor = CLOSE_PAINT_COLOR;

  private void init() {
      bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
      bgPaint.setStrokeCap(Cap.ROUND);
      bgPaint.setColor(CLOSE_PAINT_COLOR);

      circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
      circlePaint.setStrokeCap(Cap.ROUND);
      circlePaint.setStrokeJoin(Join.ROUND);
      circlePaint.setColor(Color.WHITE);
    }

初始化完画笔之后,就可以绘制我们的背景了。要绘制就必须重写onDraw方法。

  @Override
  protected void onDraw(Canvas canvas) {
      //绘制背景
      drawBg(canvas);
      //绘制圆形滑块
      drawPoint(canvas);
  }

  private void drawBg(Canvas canvas) {
     
  }

  private void drawPoint(Canvas canvas) {
     
}

因为背景是圆角矩形,所以我们使用了

  //Rectf是一个矩形对象,我们的背景就绘制在这个矩形中
  //x,y代表在各自方向上圆角的半径(直接理解为矩形四个角的弧度有多大)
  canvas.drawRoundRect(Rectf rectf,float x,float y,Paint paint);

圆形小空间我们使用了

  //这里用drawCircle也可以,看个人喜好。
  //此方法是在一个矩形中绘制内接圆,当这个矩形为正方形时,绘制的是园,否则是椭圆。
  canvas.drawOval(Rectf rectf,Paint paint)

所以在drawBg() 和 drawPoint()方法中,这样实现:

  // 圆点半径
  private int mRadius;
  // 圆形滑块距离控件左端的偏移量(当我们改变此偏移量的时候,滑块便可以左右移动,初始为0在最左端)
  private int leftOffset = 0;
  // 空隙距离2dp
  private int intervalWidth = dip2px(2);
  // 图形背景绘制区域
  RectF bgRectf = new RectF();
  // 圆点按钮绘制区域
  RectF pointRectF = new RectF();

  private void drawBg(Canvas canvas) {
    canvas.drawRoundRect(bgRectf, dip2px(15), dip2px(15), bgPaint);
  }

  private void drawPoint(Canvas canvas) {
      pointRectF.set(intervalWidth + leftOffset, intervalWidth, intervalWidth + mRadius * 2 + leftOffset,
            mRadius * 2 + intervalWidth);
      canvas.drawOval(pointRectF, circlePaint);
  }

  //此方法是将dp值转化为px值,方便适配
  private int dip2px(float dpValue) {
    final float scale =   getContext().getResources().getDisplayMetrics().density;
    return (int) (dpValue * scale + 0.5f);
  }

很多同学一看到这立马炸毛,你这些变量都在哪初始化的值?别着急,这里我选择在onMeasure方法中初始化。

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       setMeasuredDimension(getMeasuredSize(widthMeasureSpec,60),
            getMeasuredSize(heightMeasureSpec,25));
       //此方法便是初始化各种默认属性值
     initDefaultSize();
    }

getMeasuredSize()方法是为了在布局文件中设置了wrap_content属性后,可以正常显示,属于一段模板代码,自定义View时经常要用到:

  private int getMeasuredSize(int measureSpecValue,int defaultValue) {
      int specMode = MeasureSpec.getMode(measureSpecValue);
      int specSize = MeasureSpec.getSize(measureSpecValue);
      int defaultSize = dip2px(defaultValue);
      if (specMode == MeasureSpec.EXACTLY) {
        defaultSize = specSize;
      } else if (specMode == MeasureSpec.AT_MOST) {
        defaultSize = Math.min(specSize, defaultSize);
      } 
      return defaultSize;
  }

  private void initDefaultSize() {
    // TODO Auto-generated method stub
      //半径为 (测量高度 /2) - 间隙 
    mRadius = getMeasuredHeight() / 2 - intervalWidth;
      //背景的矩形 四个值 左上右下 左-0 上-0 右-控件的测量宽 下-控件的测量高
    bgRectf.set(0, 0, getMeasuredWidth(), getMeasuredHeight());
      //滑块可滑动的最大宽度 = 背景的宽度 -2* 间隙 - 圆形滑块的直径(这个可以看图理解)
    mMaxWidth = bgRectf.right -2* intervalWidth - mRadius * 2;
  }
模型.png

这里理解上图需要明确知道几个变量的意思:

  • intervalWidth ->圆形滑块与背景之间的间隙,默认2dp
  • mRadius->圆形滑块的半径,如图可知等于控件高度一般减去间隙
  • mMaxWidth ->圆形滑块距左端最大距离。这个稍微不同一些,如图所示,是从控件左端开始(也就是0)算起,这个mMaxWidth 最终要赋值给leftOffset,所以圆形滑块据相对控件左端最大的距离为leftOffset+intervalWidth,如drawPoint()方法中所写的那样。
  • mMinWidth ->圆形滑块距左端最小距离,为0,因为其也是赋值给leftOffset。
  • leftOffset ->真正控制圆形滑块位置的变量,这里我们都是从控件左端(0)开始算的,因为最终leftOffset要加上intervalWidth。

如果懂了以上变量的意思,那我们就可以正式写滑动逻辑了,肯定是重写
onTouchEvent()事件:

  // 手指按下时,起始X(这个x是距离屏幕左端的水平距离)
  private float preX;
  // 圆点在手指按下时,起始距离控件左端的偏移量
  private float preLeftOffSet;

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        //手指按下,没有滑动...
        preX = event.getRawX();
        //把间隙减掉,保证从最左端算起(0)
        preLeftOffSet = pointRectF.left - intervalWidth;
        break;
    case MotionEvent.ACTION_MOVE:
        //手指正在滑动...不断记录当前x轴坐标
        float culX = event.getRawX();
        //手指滑动距离(当前手指所在x轴坐标减去按下时x轴坐标)
        float dx = culX - preX;
        //当手指滑动5个像素时,我们才认为是真正滑动了
        if (Math.abs(dx) > 5) {
            //当前    
            leftOffset = (int) (dx + preLeftOffSet);
            //leftOffset = fixLeftOffset(leftOffset);
            invalidate();
        }
        break;
    case MotionEvent.ACTION_UP:
        //松开手指...
        break;
    }
    //事件被这个控件消费啦 不回传给父控件
    return true;
  }

加上以上代码,我们的小滑块就可以左右拖动了,但是有些无法无天,他可以被拖动到控件之外,这显示不是我们想要的。所以必须要修正一下leftOffset。所以把注释的代码打开:
fixLeftOffset(leftOffset) ->修正leftOffset,返回一个在mMinWidth到mMaxWidth之间的值

  private int fixLeftOffset(int leftOffset) {
    leftOffset = (int) (leftOffset > mMaxWidth ? mMaxWidth : leftOffset);
    leftOffset = (int) (leftOffset < mMinWidth ? mMinWidth : leftOffset);
    return leftOffset;
  }

加上上述代码限制,我们的小滑块就踏不出我们的手掌心了。但现在问题又出现了,就是当松手时,我们希望滑块自动滑动到左端或是右端,而不是停在中间,这个该怎么事件呢,其实很简单,用ValueAnimator值动画就可以快速实现。

控件的动画(滑块平移+背景渐变)

  1. 滑块平移动画
    首先,我们要知道,滑块是在手指松开时才产生动画,这里分四种情况。
  • 第一种:当手指松开时,滑块距控件左端的距离大于控件的一半,并且为close状态。这时,让滑块滑动到右端,状态置为open。


    one.gif
  • 第二种:当手指松开时,滑块距控件左端的距离小于控件的一半,并且为close状态。这时,滑块滑回左端,状态依然为close。


    two.gif
  • 第三种:当手指松开时,滑块距控件左端的距离小于控件的一半,并且为open状态。这时,让滑块滑动到左端,状态置为close。


    three.gif
  • 第四种:当手指松开时,滑块距控件左端的距离大于控件的一半,并且为open状态。这时,滑块滑回右端,状态依然为open。


    four.gif

理解了上面四种情况,我们现在就可以编码实现啦~!

  // 是否打开
  private boolean checked = false;

case MotionEvent.ACTION_UP添加以下代码:

  case MotionEvent.ACTION_UP:
       //拿到滑块的中心位置x轴坐标
       int pointCenterX = (int) pointRectF.centerX();
       //用滑块中心x轴坐标和背景(即控件)x轴坐标的一半作比较
       if (pointCenterX >= bgRectf.right / 2 && !checked) {
            changeState(checked);
       } else if (pointCenterX < bgRectf.right / 2 && checked) {
            changeState(checked);
       }
       //执行平移动画
       releaseShowAnim();
  break;

这时可以顺便加上状态监听接口,方便外部回调,得知当前控件状态:

  public interface OnCheckedChangedListener {
      void onCheckedChange(boolean isCheck);
  }

  private OnCheckedChangedListener onCheckedChangedListener;

  public void setOnCheckedChangedListener(OnCheckedChangedListener onCheckedChangedListener) {
      this.onCheckedChangedListener = onCheckedChangedListener;
  }
  //变更当前状态
  private void changeState(boolean checked) {
      this.checked = !checked;
      //状态监听接口
      if (onCheckedChangedListener != null)
          onCheckedChangedListener.onCheckedChange(this.checked);
  }

释放显示动画的代码:

  private void releaseShowAnim() {
    //值动画不难理解,下面这段代码的意思其实就是给定一个值,到另一个值。
    //在400毫秒的时间内,每隔一定时间,给你返回一个当前动画执行的进度。     
    //动画执行的进度,是一个百分数(0~1),0没执行呢,1执行完了。期间还能返回执行了多少,是一个确定值。
    //例如 1 ~ 100 执行100秒,执行进度30%(0.3),返回的是30(匀速运动前提下)
    //pointRectF.left - intervalWidth滑块距控件左端的距离,注意要把间隙减掉
    //如果为check状态,则滑动到最右端,否则滑到最左端。
    ValueAnimator valueAnimator = ValueAnimator.ofFloat(pointRectF.left - intervalWidth,
            checked ? mMaxWidth : mMinWidth);
    //该动画执行400毫秒
    valueAnimator.setDuration(400);
    //定义该运动为先加速再减速 (还有很多)
    valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
    //开启动画
    valueAnimator.start();
    //增加动画执行监听 这里就可以每次给你返回执行进度和执行值
    valueAnimator.addUpdateListener(new AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            //当前动画执行了多少 也就是我们的偏移量
            float offset = (Float) animation.getAnimatedValue();
            //当前动画执行进度,这个值是用来以后改变背景颜色的~
            float fraction = animation.getAnimatedFraction();
            //赋值给我们的leftOffset吧
            leftOffset = (int) offset;
            //重新绘制
            invalidate();
        }

    });
  }

好啦 ,加上如上代码,部署到机器上,运行,是不是已经有平移动画了呢?

下面我们把点击事件也加上,当点击控件两端,我们也希望滑块做相应的滑动。这就要先判断当前手指是滑动还是点击。所以引入isScroll变量,记录手指是点击还是滑动。还需要有一个变量记录手指当前点击的位置距控件左端的距离clickLeftOffset,看代码:

  // 是否是滑动
  private boolean isScroll = false;
  // 手指点击位置 距控件左端的偏移量
  private float clickLeftOffset = 0;

case MotionEvent.ACTION_DOWN添加以下代码:

   //getX()和getRawX()前者是获取手指点击位置距控件左端x轴坐标,后者是距屏幕左端 
   clickLeftOffset = event.getX();
   //在手指按下时 把isScroll置为false
   isScroll = false;

case MotionEvent.ACTION_MOVE添加以下代码:

   if (Math.abs(dx) > 5) {
        //...
        isScroll = true;
   }

case MotionEvent.ACTION_UP添加以下代码:

        if (isScroll) {//滑动
            int centerX = (int) pointRectF.centerX();
            if (centerX >= bgRectf.right / 2 && !checked) {
                changeState(checked);
            } else if (centerX < bgRectf.right / 2 && checked) {
                changeState(checked);
            }
        } else {//点击
            if (clickLeftOffset >= bgRectf.right / 2 && !checked) {
                changeState(checked);
            } else if (clickLeftOffset < bgRectf.right / 2 && checked) {
                changeState(checked);
            }
        }

重新部署一下~是不是点击事件也生效了呢?

  1. 背景颜色渐变

颜色渐变我采用了ArgbEvaluator,用法我会在后面介绍

颜色渐变也分为两种情况:一种是在手指拖动的时候,另一种是在手指松开的时候(拖动到一半松开或者直接是点击)

我们先来实现第一种,那么肯定要定位到ACTION_MOVE

   if (Math.abs(dx) > 5) {
        //...
        //通过当前偏移量  / mMaxWidth , 计算出滑动的百分比(0~1)
        float percent = leftOffset * 1.0f / mMaxWidth;
        //将百分比,颜色变化的区间(close时的背景颜色-open时的背景颜色)
        changeBgColor(percent, CLOSE_PAINT_COLOR, OPEN_PAINT_COLOR);
   }

看changeBgColor方法:

  //颜色插值器
  ArgbEvaluator argbEvaluator= new ArgbEvaluator();
  
  private void changeBgColor(float fraction, int startColor, int endColor) {               
        bgPaint.setColor((int)argbEvaluator.evaluate(fraction, startColor, endColor));
  }

argbEvaluator.evaluate(fraction,startColor,endColor)方法接收三个参数,第一个是百分比,后两个参数是颜色区间。他会根据百分比计算出一个当前处于区间范围内的一个值,返回给你。我们把这个值赋给背景的画笔,再重绘界面,这样我们的背景就会有一个渐变的效果。

再看松开手指的执行渐变,这自然定位到我们的releaseShowAnim()方法。在其中找到动画监听,在监听里,之前所写的当前动画执行的百分比就派上用场了,看代码:

  //获取松开手指时,背景颜色
  final int startColor = bgPaint.getColor();
  valueAnimator.addUpdateListener(new AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float offset = (Float) animation.getAnimatedValue();
            float fraction = animation.getAnimatedFraction();
            if (checked)
                //open状态,从当前颜色渐变到open时的颜色
                changeBgColor(fraction, startColor, openColor);
            else
                //close状态,从当前颜色渐变到close时的颜色
                changeBgColor(fraction, startColor, closeColor);
            leftOffset = (int) offset;
            // Log.i(TAG, "------>leftOffset = " + leftOffset);
            // alpha = (int) (0x66 * (float) leftOffset / (float)
            // mMaxWidth);
            invalidate();
        }

    });

赶紧加上试一试,背景已经如期望的那样渐变了吧!到此为止我们的自定义开关就接近尾声了,还有一些其他的功能,例如代码控制开关,控件不可用,当应用异常退出时保存View状态,改变颜色等等,都是一些很简单的小功能,希望小伙伴们自行实现,加深理解。

代码没托管到github,写这个的主要目的是学习并且巩固,毕竟这样的轮子已经有很多了,会用的同时也要会写一写。好累 ,吃个饭~ 下篇自定义控件见!

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,398评论 25 707
  • 编辑: 王健 导读:2017年人工智能已经成为全球的创业热点,为了帮助大家快速找到对应的投资机构,获得投资实现自己...
    Sting阅读 664评论 0 1
  • // 1.截取字符串
    HJXu阅读 831评论 0 0
  • 在音乐结束的角落, 我寻找她的身影, 感到了孤独。 走在街上, 擦一擦被雨淋湿的脸, 灵魂慌乱里总有你在身旁。 这...
    夕木阳阅读 182评论 0 0