本文共 8856 字,大约阅读时间需要 29 分钟。
0概述
一日,当我仔细观察锤子的Swtich控件时,其中的交互细节使我汗颜。这个小小的控件里好像包含了一万个细节,如同光秃秃的脑袋里装着满满的疑惑。
内阴影、外阴影、按压效果、立体模拟等等,每个细节的完美呈现才能支撑这个控件的交互逻辑。
要开发出这个控件,难度很大。
这篇文章,就来开发这个锤子的开关控件。
真·做个锤子的开关。
1UI拆解
开关背景的内阴影
状态指示器的内阴影
注意,开关指示器带有立体感,这种立体感是通过内阴影凸显的。可以观察到,开关指示器带有从右下角往左上角方向投射的内阴影。
开关指示器的外阴影,带有扩散效果
内阴影和外阴影的变化模拟出了3D按压的效果,仿佛用户真的将整个控件从屏幕外向屏幕里进行了按压。而当用户的手指离开控件时,控件内部仿佛有弹簧一般,又从屏幕内向屏幕外进行了弹出。
当用户手指滑动距离不算长时,此时控件的开关状态并不会发生改变,而是动画回归原状态的位置.
圆角矩形没跑了,需要注意的是形状的宽高,需要为阴影绘制留出距离
聪明人都知道这是个圆形[doge]
这不是圆形是啥?
绘制代码onDraw中,按照形状分类拆分不同的方法。需要注意的是,先绘制状态指示器,它在最下层
@Overrideprotected void onDraw(Canvas canvas) { super.onDraw(canvas); setLayerType(LAYER_TYPE_SOFTWARE, null); //绘制状态指示器 drawFlag(canvas); //绘制背景区域 drawBackgroundArea(canvas); //绘制开关指示器 drawIndicator(canvas);}
内阴影的绘制思路我在《微质感的层级选择器,隔壁产品都馋哭了》提过,感兴趣的请翻看我的往期文章。
https://zhuanlan.zhihu.com/p/185805279
代码如下:
void drawBackgroundArea(Canvas canvas) { //绘制边框及内阴影 canvas.save(); backgroundAreaPaint.setStyle(Paint.Style.STROKE); int strokeW = indicatorR / 2; backgroundAreaPaint.setStrokeWidth(strokeW); backgroundAreaPaint.setColor(Color.parseColor("#66bcbcbc")); backgroundAreaShadowSize = backgroundAreaH / 4; backgroundAreaShadowDistance = backgroundAreaH / 12; backgroundAreaPaint.setShadowLayer(backgroundAreaShadowSize + shadowOffset, 0, backgroundAreaShadowDistance, Color.GRAY); RectF strokeRectF = new RectF(-strokeW + (width - backgroundAreaW) / 2, -strokeW + (height - backgroundAreaH) / 2, strokeW + (width - backgroundAreaW) / 2 + backgroundAreaW, strokeW + (height - backgroundAreaH) / 2 + backgroundAreaH); Path strokePath = new Path(); strokePath.addRoundRect(strokeRectF, (backgroundAreaH + strokeW) / 2, (backgroundAreaH + strokeW) / 2, Path.Direction.CW); RectF rectF = new RectF((width - backgroundAreaW) / 2, (height - backgroundAreaH) / 2, (width - backgroundAreaW) / 2 + backgroundAreaW, (height - backgroundAreaH) / 2 + backgroundAreaH); Path path = new Path(); path.addRoundRect(rectF, backgroundAreaH / 2, backgroundAreaH / 2, Path.Direction.CW); canvas.clipPath(path); canvas.drawPath(strokePath, backgroundAreaPaint); backgroundAreaPaint.setStrokeWidth(2); backgroundAreaPaint.clearShadowLayer(); canvas.drawPath(path, backgroundAreaPaint); canvas.restore();}
其中包括了凸显立体感的内阴影以及外阴影的绘制,见代码:
void drawIndicator(Canvas canvas) { //绘制外阴影 indicatorPaint.setColor(indicatorColor); indicatorPaint.setStyle(Paint.Style.FILL); indicatorShadowSize = indicatorR / 3; indicatorShadowDistance = indicatorShadowSize / 2; indicatorPaint.setShadowLayer(indicatorShadowSize - shadowOffset, 0, indicatorShadowDistance, Color.parseColor("#ffc1c1c1")); canvas.drawCircle(indicatorX + indicatorXOffset, (height - backgroundAreaH) / 2 + indicatorR, indicatorR, indicatorPaint); //绘制内阴影 canvas.save(); indicatorPaint.setColor(Color.parseColor("#66bcbcbc")); int strokeW = indicatorR / 2; indicatorPaint.setStrokeWidth(strokeW); indicatorPaint.setStyle(Paint.Style.STROKE); indicatorPaint.setShadowLayer(indicatorR / 3, -indicatorR / 6, -indicatorR / 6, Color.parseColor("#fff1f1f1")); Path strokePath = new Path(); strokePath.addCircle(indicatorX + indicatorXOffset, (height - backgroundAreaH) / 2 + indicatorR, indicatorR + strokeW / 2, Path.Direction.CW); Path path = new Path(); path.addCircle(indicatorX + indicatorXOffset, (height - backgroundAreaH) / 2 + indicatorR, indicatorR, Path.Direction.CW); canvas.clipPath(path); canvas.drawPath(strokePath, indicatorPaint); indicatorPaint.setStrokeWidth(2); indicatorPaint.clearShadowLayer(); canvas.drawPath(path, indicatorPaint); canvas.restore();}
这一步中也包含内阴影的绘制。额外注意连续调用两次canvas.save方法,通过clipPath裁剪出背景区域形状的画布。
void drawFlag(Canvas canvas) { //首先裁剪出背景圆角矩形画布 canvas.save(); RectF rectF = new RectF((width - backgroundAreaW) / 2, (height - backgroundAreaH) / 2, (width - backgroundAreaW) / 2 + backgroundAreaW, (height - backgroundAreaH) / 2 + backgroundAreaH); Path bgAreaPath = new Path(); bgAreaPath.addRoundRect(rectF, backgroundAreaH / 2, backgroundAreaH / 2, Path.Direction.CW); canvas.clipPath(bgAreaPath); //绘制on flag flagPaint.setStyle(Paint.Style.FILL); flagPaint.setColor(onColor); flagPaint.clearShadowLayer(); canvas.drawCircle(indicatorX + indicatorXOffset - backgroundAreaW * 3 / 5, height / 2, indicatorR / 4, flagPaint); //内阴影 flagPaint.setStyle(Paint.Style.STROKE); int onStrokeW = indicatorR / 4; flagPaint.setStrokeWidth(onStrokeW); flagPaint.setShadowLayer(onStrokeW, -onStrokeW, onStrokeW, onColor); Path onPath = new Path(); onPath.addCircle(indicatorX + indicatorXOffset - backgroundAreaW * 3 / 5, height / 2, indicatorR / 4 + onStrokeW / 2, Path.Direction.CW); canvas.save(); canvas.clipPath(onPath); canvas.drawPath(onPath, flagPaint); flagPaint.clearShadowLayer(); canvas.restore(); //绘制off flag flagPaint.setStyle(Paint.Style.FILL); flagPaint.setColor(offColor); canvas.drawCircle(indicatorX + indicatorXOffset + backgroundAreaW * 3 / 5, height / 2, indicatorR / 4, flagPaint); //内阴影 flagPaint.setStyle(Paint.Style.STROKE); int offStrokeW = indicatorR / 4; flagPaint.setStrokeWidth(offStrokeW); flagPaint.setShadowLayer(offStrokeW, -offStrokeW, offStrokeW, offColor); Path offPath = new Path(); offPath.addCircle(indicatorX + indicatorXOffset + backgroundAreaW * 3 / 5, height / 2, indicatorR / 4 + offStrokeW / 2, Path.Direction.CW); canvas.save(); canvas.clipPath(offPath); canvas.drawPath(offPath, flagPaint); canvas.restore(); canvas.restore();}
2.2 交互实现
当用户滑动超过边界时,强制重新赋值
indicatorXOffset = (int) (event.getX() - downX);//边界判断if (indicatorX + indicatorXOffset <= (width - backgroundAreaW) / 2 + indicatorR) { indicatorXOffset = (width - backgroundAreaW) / 2 + indicatorR - indicatorX;} else if (indicatorX + indicatorXOffset >= width - (width - backgroundAreaW) / 2 - indicatorR) { indicatorXOffset = width - (width - backgroundAreaW) / 2 - indicatorR - indicatorX;}
注意一个细节,当用户的滑动距离非常小时,算作点按,此时控件的开关状态要改变;滑动距离超过一定阈值时,算滑动操作,此时控件的开关状态不一定改变
if (Math.abs(indicatorXOffset) <= 20) { //todo:点按操作}else{ //todo:滑动操作}
定义一个字段isChecked表示开关状态,根据开关指示器位置判断状态是否应该改变
if ((indicatorXOffset > 0 && indicatorXOffset >= (backgroundAreaW - 2 * indicatorR) / 2) || (indicatorXOffset < 0 && indicatorXOffset > -(backgroundAreaW - 2 * indicatorR) / 2)) { indicatorXOffset = 0; //切换状态:ON isChecked = true; startTranslateAnim(true);} else if ((indicatorXOffset > 0 && indicatorXOffset < (backgroundAreaW - 2 * indicatorR) / 2) || (indicatorXOffset < 0 && indicatorXOffset <= -(backgroundAreaW - 2 * indicatorR) / 2)) { indicatorXOffset = 0; //切换状态:OFF isChecked = false; startTranslateAnim(false);}
封装成阴影变化动画,通过Animator计算
//开始阴影变化动画if (shadowAnimator != null) { shadowAnimator.cancel();}shadowAnimator = ValueAnimator.ofInt(0, indicatorR / 4);shadowAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { shadowOffset = (int) animation.getAnimatedValue(); postInvalidate();}});shadowAnimator.setDuration(200L);shadowAnimator.start();
封装一个平移动画方法,更具状态判断要移动的目标位置
/*** @param isChecked true==>移动至on状态;false==>移动至off状态*/void startTranslateAnim(boolean isChecked) { if (translateAnimator != null) { translateAnimator.cancel(); } if (isChecked == true) { translateAnimator = ValueAnimator.ofInt(indicatorX + indicatorXOffset, width - (width - backgroundAreaW) / 2 - indicatorR); } else { translateAnimator = ValueAnimator.ofInt(indicatorX + indicatorXOffset, (width - backgroundAreaW) / 2 + indicatorR); } translateAnimator.setDuration(200L); translateAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { indicatorX = (int) valueAnimator.getAnimatedValue(); postInvalidate(); }});translateAnimator.start();}
此控件仿照锤子UI风格,部分阴影颜色、深度做了略微调整。该控件100%代码绘制,定制方便。
回顾锤子系统的整个体验,能够深刻感觉到他们团队在无数个细节中流的血汗和掉的头发,很多细节都能给用户带来惊喜。有时候反思,细节真的决定成败么?
一个好的产品,永远不是因为细节越多就会越好,而是在大方向正确的情况下,细节越多越具竞争力。
控件地址在gitee
https://gitee.com/null_077_5468/uidemos
转载地址:http://cmchb.baihongyu.com/