关闭

Android View 仿iOS SwitchButton

10029人阅读 评论(14) 收藏 举报
分类:

自学android差不多有一年了,从最初的小白菜鸟,摸爬滚打,看大神们的博客,android官网的api,某网站的视频教学,github开源项目。奋斗这么久隐隐感觉自己可以脱离新手的身份了,交出这篇文章权当作andriod小学水准的毕业典礼。

iOS SwitchButton。  说实话功能也不过就个开关功能而已。但是为什么让人感觉不错,因为效果看起来赏心悦目呀:

~~~~~~~~~~~~~~~~~


好了,为了实现它,首先要分析它。

这个按钮被我玩来玩去最后静止的时候都会停留在下面的样子:

 

那么怎么把这个实现出来呢?观察一番会发现上图是极其规律的,只是一些基础几何图形的组合。所以具备纯代码实现可能性,同时如果用图片实现这个效果需要对应的png文件辅助,相信大家一定觉得麻烦。

那么就把它画出来!如何画出来的分析路线:

1. 位置固定不变的背景,像田径场一样的形状。

2. 圆圆的按钮,压在“田径场”上面。  【之后背景全称作"田径场",比较形象,不服solo 囧】

3. 淡淡的按钮阴影,夹在他们之间。

ps:哎,我还是分析的那么透彻,赞一个。


开始动手!新建

  1. public class SwitchView extends View {  
  2.     public SwitchView(Context context) {  
  3.         this(context, null);  
  4.     }  
  5.   
  6.     public SwitchView(Context context, AttributeSet attrs) {  
  7.         super(context, attrs);  
  8.         setLayerType(LAYER_TYPE_SOFTWARE, null);  
  9.     }  
  10. }  

之后为自己确定大小~  截图量了一下 算上阴影宽高比例是 149:92 。即 height = width * 0.65 左右

  1. @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
  2.     int widthSize = MeasureSpec.getSize(widthMeasureSpec);  
  3.     int heightSize = (int) (widthSize * 0.65f);  
  4.     setMeasuredDimension(widthSize, heightSize);  
  5. }  
绘制~

  1. @Override protected void onDraw(Canvas canvas) {  
  2.     super.onDraw(canvas);  
  3.     canvas.drawColor(0xffcccccc);  
  4. }  
好哒,通向胜利的第一步已经完成了。


ps: 如果我说,剩下的大家自己思考,你们不会打我吧。

第二步,画田径场!

  1. private final Paint paint = new Paint();  
  2. private final Path sPath = new Path();  
  3.   
  4. private int mWidth, mHeight;  
  5. private float sWidth, sHeight;  
  6. private float sLeft, sTop, sRight, sBottom;  
  7. private float sCenterX, sCenterY;  
  8.   
  9. @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
  10.     super.onSizeChanged(w, h, oldw, oldh);  
  11.     mWidth = w; // 视图自身宽度  
  12.     mHeight = h; // 视图自身高度  
  13.     sLeft = sTop = 0// 田径场 左和上的坐标  
  14.     sRight = mWidth; // 田径场 右占自身的全部  
  15.     sBottom = mHeight * 0.8f; // 田径场底部 占全身的百分之八十, 下面预留百分之二十的空间画按钮阴影。  
  16.     sWidth = sRight - sLeft; // 田径场的宽度  
  17.     sHeight = sBottom - sTop; // 田径场的高度  
  18.     sCenterX = (sRight + sLeft) / 2// 田径场的X轴中心坐标  
  19.     sCenterY = (sBottom + sTop) / 2// 田径场的Y轴中心坐标  
  20.   
  21.     RectF sRectF = new RectF(sLeft, sTop, sBottom, sBottom);  
  22.     sPath.arcTo(sRectF, 90180);  
  23.     sRectF.left = sRight - sBottom;  
  24.     sRectF.right = sRight;  
  25.     sPath.arcTo(sRectF, 270180);  
  26.     sPath.close();    // path准备田径场的路径  
  27. }  
  28.   
  29. @Override protected void onDraw(Canvas canvas) {  
  30.     super.onDraw(canvas);  
  31.     paint.setAntiAlias(true);  
  32.     paint.setStyle(Style.FILL);  
  33.     paint.setColor(0xffcccccc);  
  34.     canvas.drawPath(sPath, paint); // 画出田径场  
  35.   
  36.     paint.reset();  
  37. }  

由于田径场不是基础规则的几何图形,只好交给万能的path对象啦。 path 左右加两段圆弧中间一连接,感谢api朋友 [arcTo]

值得注意的是预留出按钮阴影的位置。

既然都做到这个程度了,那就把背景的效果做完嘛。有什么效果呢。

额,这么搓逼,不过确实是这个效果,也许整体完成后就是另一番风景吧。orz

这个效果用大腿想象就知道是点击触发的,既然如此:

  1. @Override public boolean onTouchEvent(MotionEvent event) {  
  2.     switch (event.getAction()) {  
  3.     case MotionEvent.ACTION_DOWN:  
  4.         return true;  
  5.     case MotionEvent.ACTION_CANCEL:  
  6.     case MotionEvent.ACTION_UP:  
  7.         sAnim = 1// 动画标示  
  8.         isOn = !isOn; // 状态标示 , 开关  
  9.         invalidate();  
  10.         break;  
  11.     }  
  12.     return super.onTouchEvent(event);  
  13. }  
这个容易,invalidate嘛。抬起手指重绘嘛,那关键问题就抛给了onDraw同志。

  1. @Override protected void onDraw(Canvas canvas) {  
  2.     super.onDraw(canvas);  
  3.     paint.setAntiAlias(true);  
  4.     paint.setStyle(Style.FILL);  
  5.     paint.setColor(0xffcccccc);  
  6.     canvas.drawPath(sPath, paint); // 画出田径场  
  7.   
  8.     sAnim = sAnim - 0.1f > 0 ? sAnim - 0.1f : 0// 动画标示 ,重绘10次  
  9.     // draw logic - 动态改变绘制参数,达到动画效果  
  10.     paint.reset();  
  11.     if (sAnim > 0) invalidate(); // 继续重绘  
  12. }  
so,配菜都准备好了,上主菜~

分析:不用分析了,一张图大家都懂的~

看不出这是两个田径场的说明你只是地球人而已。对 就是一个白色的田径场压在了灰色的上面而已,然后大小随某个东西【anim标示】的变化而变化。

  1. @Override protected void onDraw(Canvas canvas) {  
  2.     super.onDraw(canvas);  
  3.           
  4.     // ...  
  5.       
  6.     sAnim = sAnim - 0.1f > 0 ? sAnim - 0.1f : 0// 动画标示 ,重绘10次  
  7.           
  8.     final float scale = 0.98f * (isOn ? sAnim : 1 - sAnim); //缩放大小参数随sAnim变化而变化  
  9.     canvas.save();  
  10.     canvas.scale(scale, scale, sCenterX, sCenterY);  
  11.     paint.setColor(0xffffffff);  
  12.     canvas.drawPath(sPath, paint);  
  13.     canvas.restore();  
  14.           
  15.     paint.reset();  
  16.     if (sAnim > 0) invalidate(); // 继续重绘  
  17.           
  18.     // ...  
  19. }  
没错,还是用的sPath只不过换了个颜色而已。 我们玩的是canvas, canvas的api scale 可以缩放画布。之前申明并且计算好的sCenterX,和sCenterY的作用就是确定画布缩放中心啦。 而0.98的作用便为我们免费的留下了一条边缘。如果设置1的话,白色的田径场将完全覆盖灰色田径场,界面上就是一片白了。

为了完美适配各种分辨率,这个0.98应该被一个变量替换。
对比完成的效果图会使用感觉有点别扭,别扭的原因就是缩放的中心位置。


应该在4个箭头的起点处。就是那里:田径场的宽度 减去 按钮的一个半径,在减去按钮距离右边的间隔什么的。

那么我就要把按钮的一些参数给算好咯~

  1. private float bRadius, bStrokWidth;  
  2. private float bWidth;  
  3. private float bLeft, bTop, bRight, bBottom;  
  4. private float sScaleCenterX;  
  5. private float sScale;  
  6.   
  7. @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
  8.     super.onSizeChanged(w, h, oldw, oldh);  
  9.     // ...  
  10.     bLeft = bTop = 0;  
  11.     bRight = bBottom = sBottom; // 和田径场同高,同宽的节奏, 没错包裹圆形的肯定是个正方形是小孩子都知道的。  
  12.     bWidth = bRight - bLeft;  
  13.     final float halfHeightOfS = (sBottom - sTop) / 2;  
  14.     bRadius = halfHeightOfS * 0.9f; // 按钮的半径  
  15.     bStrokWidth = 2 * (halfHeightOfS - bRadius); // 按钮的边框  
  16.   
  17.     sScale = 1 - bStrokWidth / sHeight; //替换之前的0.98<  
  18.         sScaleCenterX = sWidth - halfHeightOfS;  
  19.         // ...  
  20. }  
  21.   
  22. @Override protected void onDraw(Canvas canvas) {  
  23.         // ...  
  24.         canvas.scale(scale, scale, sScaleCenterX, sCenterY);  
  25. }  

大家估计看出我的命名规则啦:  s开头的表示田径场,b开头的表示按钮。

那么效果理所当然的变成下图所示。

继续补上按钮。

  1. canvas.save();  
  2. paint.setStyle(Style.FILL);  
  3. paint.setColor(0xffffffff);  
  4. canvas.drawCircle(bWidth / 2, bWidth / 2, bRadius, paint); // 按钮白底  
  5. paint.setStyle(Style.STROKE);  
  6. paint.setColor(0xffdddddd);  
  7. paint.setStrokeWidth(bStrokWidth);  
  8. canvas.drawCircle(bWidth / 2, bWidth / 2, bRadius, paint); // 按钮灰边  
  9. canvas.restore();  
上面通过canvas.drawCircle的方法画了俩圆,看起来就是我们滴按钮咯。

之后动起来~这里依旧玩canvas。


相对于scale缩放方法, 他还有translate平移和retate旋转方法,是不是很牛逼~ 。这里需要的是平移方法

  1. bTranslateX = sWidth - bWidth;  
  2.           
  3. final float translate = bTranslateX * (isOn ? 1 - sAnim : sAnim); // 平移距离参数随sAnim变化而变化  
  4.           
  5. canvas.translate(translate, 0);  
很清晰的可以看出,按钮平移的距离 是 田径场的宽度 减去按钮所占区域的宽度 , 所以即时再复杂的逻辑一点点抽丝剥茧 那么- 真相只有一个。


好了 接下来进入我们的正题内容:

对于按钮的开关不见得是我们想开就开,想关就关的。举生活中常见的例子:小明家跳闸了,然后小明去把闸往上推,然后发现推不上去自动弹回来,再推,再弹 *n。

之后小明终于受不了打电话给闪电侠,通知它过来修。 电话拨出去的时候,并不是马上被接听,同时也有可能没人接。。。编不下去了。

特么生活的现实就是比理想残酷,小明我不是故意黑你的。


为了能够让大家明确的感受到按钮与之前的不同,所以换了一个明显的颜色~


那么转换为程序语言这个按钮开关应该是4种状态:

已经关闭。 已经打开。准备关闭。准备打开。

所以我们为switchView 添加4种状态,  之前的 变量isOn可以退休了。

  1. private final int STATE_SWITCH_ON = 4// 已经打开  
  2. private final int STATE_SWITCH_ON2 = 3// 准备关闭  
  3. private final int STATE_SWITCH_OFF2 = 2// 准备打开  
  4. private final int STATE_SWITCH_OFF = 1// 已经关闭  
  5. private int state = STATE_SWITCH_OFF;  
  6. private int lastState = state;  


细心的朋友会发现,田径场还是那个田径场,而按钮不再是那个按钮。它可以变瘪 orz

so canvas.drawCircle就不行啦,  好了我们再来看一张图。


看不出这是两个田径场的说明你仍然只是地球人而已。对 就是一个紫色的田径场压在了灰色的上面而已,然后形状随某个东西【anim标示】的变化而在圆形和田径场之间变化而已。

  1. private final Path bPath = new Path();  
  2. private final RectF bRectF = new RectF();  
  3.   
  4. private float bOffset;  
  5. private float bOnLeftX, bOn2LeftX, bOff2LeftX, bOffLeftX;  
  6.   
  7. @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
  8.     super.onSizeChanged(w, h, oldw, oldh);  
  9.     // ...  
  10.     final float halfHeightOfS = (sBottom - sTop) / 2;  
  11.     bRadius = halfHeightOfS * 0.9f;  
  12.     bOffset = bRadius * 0.3f;  // 有多瘪~  半径的三分之一左右  
  13.     bStrokWidth = 2 * (halfHeightOfS - bRadius);  
  14.   
  15.     bOnLeftX = sWidth - bWidth;  // 在已经开启状态下,按钮距离自身左端的距离  
  16.     bOn2LeftX = bOnLeftX - bOffset;// 在准备关闭状态下,按钮距离自身左端的距离  
  17.     bOffLeftX = 0;// 在已经关闭状态下,按钮距离自身左端的距离  
  18.     bOff2LeftX = 0;// 在准备开启状态下,按钮距离自身左端的距离  
  19.   
  20.     bRectF.left = bLeft;        // 替代 circle性质的按钮,改为path性质的按钮,以提供“变瘪”的功能 。囧  
  21.     bRectF.right = bRight;  
  22.     bRectF.top = bTop + bStrokWidth / 2;  
  23.     bRectF.bottom = bBottom - bStrokWidth / 2;  
  24.           
  25.     // ...  
  26. }  

那么同时再次观察刚才的gif图片。会发现在按钮变瘪的时候(已经开启到准备关闭,已经关闭到准备开启),田径场是无动作的。

所以anim标示,存在2个~ 之前这个参数叫sAnim而不是 anim,估计大家也猜出来啦。这里在添加一个 bAnim。

  1. private float sAnim, bAnim;  

重新设定动画的触发时机!!!

  1. @Override public boolean onTouchEvent(MotionEvent event) {  
  2.     switch (event.getAction()) {  
  3.     case MotionEvent.ACTION_DOWN:  
  4.         return true;  
  5.     case MotionEvent.ACTION_CANCEL:  
  6.     case MotionEvent.ACTION_UP:  
  7.         lastState = state;  
  8.         if (state == STATE_SWITCH_OFF) {  
  9.             bAnim = 1;  
  10.             state = STATE_SWITCH_OFF2;  
  11.         } else if (state == STATE_SWITCH_OFF2) {  
  12.             bAnim = 1;  
  13.             sAnim = 1;                         // 只有在准备打开,并且结果成功的时候  
  14.             state = STATE_SWITCH_ON;  
  15.         } else if (state == STATE_SWITCH_ON) {  
  16.             bAnim = 1;  
  17.             state = STATE_SWITCH_ON2;  
  18.         } else if (state == STATE_SWITCH_ON2) {  
  19.             bAnim = 1;  
  20.             sAnim = 1;                         // 和在准备关闭,并且结果成功的时候才会  触发背景的变化。   
  21.             state = STATE_SWITCH_OFF;  
  22.         }  
  23.         invalidate();  
  24.         break;  
  25.     }  
  26.     return super.onTouchEvent(event);  
  27. }  

这个事件处理的逻辑依旧容易,invalidate嘛。抬起手指重绘嘛,那关键问题就再一次的抛给了onDraw同志。

onDraw同志需要根据我们给的anim标示来处理按钮的动画效果咯

  1. @Override protected void onDraw(Canvas canvas) {  
  2.     super.onDraw(canvas);  
  3.     paint.setAntiAlias(true);  
  4.     paint.setStyle(Style.FILL);  
  5.     paint.setColor(0xffcccccc);  
  6.     canvas.drawPath(sPath, paint);       // 最底下的田径场  
  7.   
  8.     sAnim = sAnim - 0.1f > 0 ? sAnim - 0.1f : 0;  
  9.     bAnim = bAnim - 0.1f > 0 ? bAnim - 0.1f : 0;  
  10.   
  11.     final boolean isOn = (state == STATE_SWITCH_ON || state == STATE_SWITCH_ON2);  
  12.     final float scale = sScale * (isOn ? sAnim : 1 - sAnim);  
  13.     final float scaleOffset = (bOnLeftX + bRadius - sCenterX) * (isOn ? 1 - sAnim : sAnim);  
  14.     canvas.save();  
  15.     canvas.scale(scale, scale, sCenterX + scaleOffset, sCenterY);        // 田径场动画的缩放中心  
  16.     paint.setColor(0xffffffff);  
  17.     canvas.drawPath(sPath, paint);       // 这个之前讲过了  
  18.     canvas.restore();  
  19.   
  20.     canvas.save();  
  21.     final boolean isState2 = (state == STATE_SWITCH_ON2 || state == STATE_SWITCH_OFF2);  
  22.     final float percent = (isState2 ? 1 - bAnim : bAnim);  
  23.     calcBPath(percent);                                   // 根据anim标示计算变瘪的按钮路径  
  24.     paint.setStyle(Style.STROKE);  
  25.     paint.setStrokeWidth(bStrokWidth);  
  26.     paint.setColor(0xffff00cc);                             
  27.     canvas.translate(calcBTranslate(bAnim), 0);           // 根据anim标示计算按钮开关平移的坐标  
  28.     canvas.drawPath(bPath, paint);  
  29.     canvas.restore();  
  30.   
  31.     paint.reset();  
  32.     if (sAnim > 0 || bAnim > 0) invalidate();             // 重绘的标示由1个变为了2个。  
  33. }  

so,白眼狼都看出来啊。啊不,口误,Oh shi-t  笔误,明眼人都看的出来。核心逻辑丢在 calcBPath() 和 calcBTranslate()里面咯。 如果问我为什么要创建2个方法丢在里面,我只能告诉你,这是kami的启示~

  1. private void calcBPath(float percent) {  
  2.     bPath.reset();  
  3.     bRectF.left = bLeft + bStrokWidth / 2;  
  4.     bRectF.right = bRight - bStrokWidth / 2;  
  5.     bPath.arcTo(bRectF, 90180);  
  6.     bRectF.left = bLeft + percent * bOffset + bStrokWidth / 2;  
  7.     bRectF.right = bRight + percent * bOffset - bStrokWidth / 2;  
  8.     bPath.arcTo(bRectF, 270180);  
  9.     bPath.close();  
  10. }  

看方法内容,原来percent 通过直接影响rect来间接影响了path啊。  left和right值似乎随percent的增大而增大。这样 两个圆弧慢慢被拉开,反之缩小。

这特么谁写的,真是太精辟了。 --- 匿名

  1. private float calcBTranslate(float percent) {  
  2.     float result = 0;  
  3.     int wich = state - lastState;  
  4.     switch (wich) {  
  5.     case 1// off - off2  
  6.         result = bOff2LeftX - (bOff2LeftX - bOffLeftX) * percent;  
  7.         break;  
  8.     case 2// off2 - on  
  9.         result = bOnLeftX - (bOnLeftX - bOff2LeftX) * percent;  
  10.         break;  
  11.     case -1:// on - on2  
  12.         result = bOn2LeftX + (bOnLeftX - bOn2LeftX) * percent;  
  13.         break;  
  14.     case -2:// on2 - off  
  15.         result = bOffLeftX + (bOn2LeftX - bOffLeftX) * percent;  
  16.         break;  
  17.     }  
  18.   
  19.     return result - bOffLeftX;  
  20. }  

看方法内容,原来平移的结果result 是根据不同的情况进行了分类啊,真是层次分明。

这特么谁写的,66666。   ---匿名


好了,打死我也不会告诉你们,评论是我自己留下的。

做到这里几乎完成了大半,不过仍有不合理的地方。 首先从刚才的TouchEvent的代码逻辑可以看出,点击一下动一下,点击四下一个周天~

对的。 到了准备开启后,或者准备关闭后的结果不用该由控件自身决定,应该交给业务逻辑。 那么其结果是成功还是失败就不管我们的事了,只需要提供一个

执行结果动画的公开方法就OK了。 同时,calcBTranslate()的内部逻辑便需要扩充,添加对失败结果【开启不成功,关闭不成功】的处理等。


那么 定义并且创建接口~

  1. public interface OnSwitchStateChangedListener {  
  2.         void onStateChanged(int state);  
  3. }  
  4.   
  5. private OnSwitchStateChangedListener listener = new OnSwitchStateChangedListener() {  
  6.     @Override public void onStateChanged(int state) {  
  7.         if (state == STATE_SWITCH_OFF2) {  
  8.             toggleSwitch(STATE_SWITCH_ON);  
  9.         }  
  10.         else if (state == STATE_SWITCH_ON2) {  
  11.             toggleSwitch(STATE_SWITCH_OFF);  
  12.         }  
  13.     }  
  14. };  
  15.   
  16. public void setOnSwitchStateChangedListener(OnSwitchStateChangedListener listener) {  
  17.         if (listener == nullthrow new IllegalArgumentException("empty listener");  
  18.         this.listener = listener;  
  19. }  

仔细的朋友又会发现。我这里已经有个接口的默认实现啦。 实现的内容:当状态变换为准备关闭的时候 ,准备打开的时候做了一件不可告人的事情。

  1. private void refreshState(int newState) {  
  2.     lastState = state;  
  3.     state = newState;  
  4.     postInvalidate();     // 为什么postInvalidate() 而不用 invalidate()。  你猜~  
  5. }  
  6.       
  7. private synchronized void toggleSwitch(int wich) {  
  8.     if (wich == STATE_SWITCH_ON || wich == STATE_SWITCH_OFF) {  
  9.         if ((wich == STATE_SWITCH_ON && (lastState == STATE_SWITCH_OFF || lastState == STATE_SWITCH_OFF2))  
  10.                 || (wich == STATE_SWITCH_OFF && (lastState == STATE_SWITCH_ON || lastState == STATE_SWITCH_ON2))) {  
  11.             sAnim = 1;  
  12.         }  
  13.         bAnim = 1;  
  14.         refreshState(wich);  
  15.   
  16.     }  
  17.     else {  
  18.         Log.e("SwitchView_step2""do not support state : " + wich);  
  19.     }  
  20. }  


toggleSwitch()。 根据当前状态和上次状态判断 在成功打开,和成功关闭的时候 开启sAnim田径场背景动画标示~ ,同时必然开始按钮的动画标示~之后重绘。

所以当这个控件没有被重新设置SwitchStateChangedListener的时候一切就是期望的那么美好。 想开就开,想关就关~


接着提供对外方法

  1. public int getState() {                       // 获得状态  
  2.     return state;  
  3. }  
  4.   
  5. public void setState(boolean isOn) {          // 设置状态 只能设置 [已经关闭] 和 [已经开启]  
  6.     final int wich = isOn ? STATE_SWITCH_ON : STATE_SWITCH_OFF;  
  7.     refreshState(wich);  
  8. }  
  9.   
  10. public void toggleSwitch(boolean letItOn) {   // 切换状态 只支持 [已经关闭] 和 [已经开启] 的切换  
  11.     final int wich = letItOn ? STATE_SWITCH_ON : STATE_SWITCH_OFF;  
  12.     postDelayed(new Runnable() {  
  13.         @Override public void run() {  
  14.             toggleSwitch(wich);  
  15.         }  
  16.     }, 300);  
  17. }  



那么300行左右的代码 完成了我们的仿iOS SwitchButton 的控件 SwitchView (就不和它一个名字,不服 solo)生气

至于阴影效果,如何使用的相关内容:

查看SwitchView 源码

转载于:http://blog.csdn.net/bfbx5173/article/details/45191147#comments

11
1

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:551191次
    • 积分:6950
    • 等级:
    • 排名:第3284名
    • 原创:88篇
    • 转载:361篇
    • 译文:0篇
    • 评论:77条
    最新评论