微质感的层级选择器,隔壁产品都馋哭了

606 阅读3分钟

废话不多说,先上图 什么是质感?翻译成程序语言就是合理设置部分内阴影、外阴影,使之具备一定的立体的视觉效果。

在交互设计上,用户拖动指示器时,指示器会缩放;用户拖动结束后,指示器会自动被临近的节点所吸附。动画过度非常自然流畅。

在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