废话不多说,先上图
什么是质感?翻译成程序语言就是合理设置部分内阴影、外阴影,使之具备一定的立体的视觉效果。
在交互设计上,用户拖动指示器时,指示器会缩放;用户拖动结束后,指示器会自动被临近的节点所吸附。动画过度非常自然流畅。
在UI细节设计上,内阴影和外阴影衬托出立体感,从而提升了整个控件的质感。
该控件可以使用在功能上有层级设置的场景,如星巴克的中杯、大杯、超大杯。(罗老师打脸状)
一、设计思路
此控件类似于android系统的seekbar。seekbar适用性虽强,但在某些特定的场景下,用户可能更关注“层级”而非“进度”,此时需要一个新的控件承载。层级选择器便诞生了。为了使控件表现得更为立体,添加内阴影和外阴影进行凸显;为了保证控件的可扩展性,采用100%代码进行绘制。
二、实现方式
2.1 UI拆解
可以观察到,UI控件由指示器、背景条以及节点组成,因此按照形状可分为以下几种:
指示器:圆形,外圆+内圆
背景条:圆角矩形
节点:圆形
渐变色背景条:圆角矩形
2.2 UI绘制
有了形状,接下来就给各个形状赋予颜色、阴影等细节,onDraw方法中如下
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
setLayerType(LAYER_TYPE_SOFTWARE, null);
//画线段点
for (int i = 0; i < levelCount + 1; i++) {
canvas.drawCircle(lineSegmentWidth * i + 2 * indicatorInitRadius, height / 2, dotRadius, dotPaint);
}
//画背景线条
drawLineBg(canvas);
//画指示器
drawIndicator(canvas);
}
2.2.1 绘制背景条
背景条的部分难点在于如何绘制内阴影从而体现立体感,Canvas提供了绘制阴影的方法:setShadowLayer,d但阴影并不能绘制在绘制目标的内部。
这里的思路是先绘制形状边框,通过设置边框的外阴影叠加至绘制形状的上面,最后通过Canvas的裁剪方法:clipPath裁剪出目标形状的画布去掉多余阴影,从而实现内阴影。见图解。
叠加阴影后:
可以看到,叠加后虽然内阴影模拟出来了,但底部存在多余的阴影
裁剪形状后:
通过clipPath裁剪后去掉多余阴影
最后的效果,放大后:
效果不错,nice,兄dei
代码如下:
需要注意的是,在使用Canvas的clip方法之前记得调用save方法进行存储,绘制完阴影之后调用restore方便其它形状的绘制;并不是所有android版本都支持在硬件加速下绘制阴影,因此阴影绘制不生效时可以调用setLayerType(LAYER_TYPE_SOFTWARE, null) 取消硬件加速
void drawLineBg(Canvas canvas) {
RectF rectF = new RectF(2 * indicatorInitRadius - lineBgHeight / 2, indicatorY - lineBgHeight / 2, width - 2 * indicatorInitRadius + lineBgHeight / 2, indicatorY + lineBgHeight / 2);
//设置内阴影
Path path = new Path();
path.addRoundRect(rectF, lineBgHeight / 2, lineBgHeight / 2, Path.Direction.CW);
linePaint.setStyle(Paint.Style.FILL);
linePaint.clearShadowLayer();
linePaint.setColor(0x88e0e0e0);
canvas.drawPath(path, linePaint);
//绘制指示器左边的线段背景
drawIndicatorLeftLineBg(canvas);
canvas.save();
linePaint.setShadowLayer(3, 0, 2, shadowColor);
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setColor(0xffe0e0e0);
canvas.clipPath(path);
canvas.drawPath(path, linePaint);
canvas.restore();
}
2.2.2 绘制渐变背景条
本质上是个圆角矩形,计算好关键坐标即可;渐变色使用LinearGradient进行预设,通过setShader将LinearGradient配置给画笔linePaint
void drawIndicatorLeftLineBg(Canvas canvas) {
LinearGradient linearGradient = new LinearGradient(0, 0, width, 0, leftLineStartColor, leftLineEndColor, Shader.TileMode.CLAMP);
linePaint.setShader(linearGradient);
linePaint.setStyle(Paint.Style.FILL);
linePaint.clearShadowLayer();
Path path = new Path();
RectF rectF = new RectF(2 * indicatorInitRadius - lineBgHeight / 2, indicatorY - lineBgHeight / 2, indicatorX + indicatorOffset + lineBgHeight / 2, indicatorY + lineBgHeight / 2);
path.addRoundRect(rectF, lineBgHeight / 2, lineBgHeight / 2, Path.Direction.CW);
canvas.drawPath(path, linePaint);
linePaint.clearShadowLayer();
linePaint.setShader(null);
}
2.2.3 绘制节点
节点的位置相对简单,按照层级段数levelCount循环绘制即可
//画线段点、线段
for (int i = 0; i < levelCount + 1; i++) {
canvas.drawCircle(lineSegmentWidth * i + 2 * indicatorInitRadius, height / 2, dotRadius, dotPaint);
}
2.2.4 绘制指示器
形状非常简单,由两个圆组成。难点在于内圆的内阴影。内阴影按照背景条中内阴影的思路如法炮制,先绘制圆形边框,调用clip裁剪出内阴影。
void drawIndicator(Canvas canvas) {
indicatorPaint.setColor(Color.WHITE);
indicatorPaint.setStyle(Paint.Style.FILL);
indicatorPaint.setShadowLayer(3, 0, 2, Color.GRAY);
canvas.drawCircle(indicatorX + indicatorOffset, indicatorY, indicatorRadius, indicatorPaint);
indicatorPaint.setColor(indicatorDotColor);
indicatorPaint.setStyle(Paint.Style.FILL);
indicatorPaint.clearShadowLayer();
canvas.drawCircle(indicatorX + indicatorOffset, indicatorY, indicatorRadius / 2, indicatorPaint);
//绘制内阴影
canvas.save();
Path path = new Path();
path.addCircle(indicatorX + indicatorOffset, indicatorY, indicatorRadius / 2, Path.Direction.CW);
indicatorPaint.setColor(Color.WHITE);
indicatorPaint.setShadowLayer(2, 0, 2, Color.GRAY);
indicatorPaint.setStyle(Paint.Style.STROKE);
canvas.clipPath(path);
canvas.drawPath(path, indicatorPaint);
canvas.restore();
}
2.3 实现交互
2.3.1 边界判断
用户手指移动超过背景线条的最左边或最右边都不允许,如果超过,则强制重新赋值
if (indicatorX + indicatorOffset <= 2 * indicatorInitRadius) {
indicatorOffset = 2 * indicatorInitRadius - indicatorX;
} else if (indicatorX + indicatorOffset >= width - 2 * indicatorInitRadius) {
indicatorOffset = width - 2 * indicatorInitRadius - indicatorX;
}
2.3.2 实现指示器被节点自动吸附
当用户手指抬起时,自动寻找最近的节点并滑动至该节点。判断标准为用户滑动结束后是否超过了一个线段长度的1/2
//移动的线段数
int overLineSegmentCount = (int) Math.abs((indicatorX + indicatorOffset) / lineSegmentWidth);
//要移动的目标线段所在的线段数,从0开始
int indexSegmentCount = overLineSegmentCount;
if (Math.abs((indicatorX + indicatorOffset) % lineSegmentWidth) > lineSegmentWidth / 2) {
//移动到下一个临近节点
indexSegmentCount = overLineSegmentCount + 1;
} else {
//回滚至上一个临近节点
indexSegmentCount = overLineSegmentCount;
}
indicatorTransAnimator = ValueAnimator.ofFloat(indicatorX + indicatorOffset, indexSegmentCount * lineSegmentWidth + 2 * indicatorInitRadius);
indicatorTransAnimator.setDuration(150L);
indicatorTransAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
indicatorX = (float) animation.getAnimatedValue();
postInvalidate();
}
});
indicatorTransAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//开始缩放动画
scaleInIndicator();
}
});
indicatorX = indicatorX + indicatorOffset;
indicatorOffset = 0;
downX = 0;
downY = 0;
indicatorTransAnimator.start();
2.3.3 实现指示器缩放
用户在点击时放大指示器,用户滑动结束后缩小指示器
/**
* 扩大指示器动画
*/
void scaleOutIndicator() {
if (indicatorScaleAnimator != null) {
indicatorScaleAnimator.cancel();
}
indicatorScaleAnimator = ValueAnimator.ofFloat(indicatorInitRadius, indicatorInitRadius * 2);
indicatorScaleAnimator.setDuration(150L);
indicatorScaleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
indicatorRadius = (float) animation.getAnimatedValue();
postInvalidate();
}
});
indicatorScaleAnimator.start();
}
/**
* 缩小指示器动画
*/
void scaleInIndicator() {
if (indicatorScaleAnimator != null) {
indicatorScaleAnimator.cancel();
}
indicatorScaleAnimator = ValueAnimator.ofFloat(indicatorRadius, indicatorInitRadius);
indicatorScaleAnimator.setDuration(300L);
indicatorScaleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
indicatorRadius = (float) animation.getAnimatedValue();
postInvalidate();
}
});
indicatorScaleAnimator.start();
}
三、后记
自定义控件中难免会遇到阴影、形状、复杂交互等这类难搞的问题,但只要逐步拆解,先构思形状,再构思颜色,最后构思交互,按照这个步骤,复杂问题都能得到有效解决。
控件放在了gitee上,地址在
加vx:pengyeah888 获取
既然看到这里了,是时候亮出我的公众号了,啊,我还没有公众号。需要此控件的扫一扫加我vx:pengyeah888