需求描述
当我们需要做一些带校准的功能时,需要调节一些值来反映校准的效果,或者是相机之类的应用,需要设置焦距,曝光值之类的,为了方便用户设置这些值,通常需要用到滑动选择的控件,比如系统提供的SeekBar控件。用户通过滑动屏幕就能设置值。使用系统的seekBar虽然可以完成这些功能,但是不美观。一般产品都不会采纳系统的原生控件,所以只能是我们自己来通过自定义view绘制。今天我们要绘制的自定义View如下所示:
然后在第一次的时候,会有个动画提示用户,如何操作。效果如下:
最后用户开始操作动画就会消失,用户操作时的效果如下:
本文就是主要介绍如何实现这样一个控件,这个控件在滑动的时候会伴随音效以及手机的震动感。
思路
当我们拿到一个自定义View控件需求的时候,首先我们需要先分析下这个自定义控件是否可以使用系统已经有的控件组合实现,如果不能,我们再分析这个自定义控件是一个view还是可以放子view的容器(ViewGroup)。如果是一个容器类的自定义控件,我们就需要继承自ViewGroup。否则就需要我们继承自View自己绘制,然后再添加对应的事件处理就行了。本文要实现的自定义控件属于需要继承自View自己绘制的。首先我们要绘制的View,为了方便我们称为RulerSeekBar。这个RulerSeekBar由几部分组成,分别是:提示文本、指示的指针、长短刻度以及数字。接下来我们需要做的就是计算出他们的对应坐标,然后使用绘图API绘制出来就行了。绘制完View后我们需要做事件处理,比如滑动的时候的吸附效果,惯性滑动,音效,震动处理。而滚动的时候我们使用的是ScrollerView。其实自定义Android中没有的view控件就是将需要绘制的View样式分解成基本图形,算出每个需要绘制的基本图形坐标,使用绘图的API将其分别绘制就行了,然后就是处理事件和调整细节。
绘制提示文本
RulerSeekBar的提示文本是支持多色字体的,这里我们主要使用Android系统提供的SpannableString,这个类运行我们定义各种样式的文本,甚至可以放图片,特别好用。不了解的小伙伴可以去百度下。这个类真的很炫。但是我们是继承自View的,所以绘制SpannableString需要借助DynamicLayout的帮助。否则无法绘制出不同样式的文本。
指示指针
指示指针包括两部分,一个图标,一个带渐变的小圆矩形指针。我们算出他们的坐标后使用绘图API绘制出来就行了
长短刻度和数字
刻度分为长刻度和短刻度,为了不混淆,我使用的是两个画笔绘制分别绘制。然后每个刻度的坐标计算,我们可以使用当前控件的宽除以每个刻度的间隔大小就能得出当前的宽可以绘制多少个刻度。而对于数字,我们可以根据设置的最大值和最小值,刻度间的间隔,当前的位置等信息,计算每个刻度的数字的坐标并绘制,这里处理的时候将每个刻度放大十倍处理,这样可以防止在计算过程中精度的丢失,回调数据的时候再缩小10倍将值给到用户
阴影效果绘制
我们仔细观察可以发现,当我们的RulerSeekBar的两边刻度有个阴影效果,当我们左滑或者右滑的时候,会出现一个渐变,给人一种渐渐消失的感觉,这种效果我们主要通过改变画笔的透明度实现的。具体的看代码
吸附效果和惯性滑动
当我们滑动RulerSeekBar控件选择数值时,有时候会滑动到两个刻度之间,当我们放开手的时候,控件会自动吸附到两个刻度中的一个。这种判断就是当滑动的距离超过了一个阈值后就选择后面的一个刻度,否则回弹回上一个刻度。而惯性滑动就是我们所说的Fling,指的是我们在屏幕上快速滑动然后突然停止后,由于惯性,还会滑动一段距离,这里我们需要借助于速度跟踪器:VelocityTracker和Scroller实现,具体见代码
音效震动处理
当滑动的时候有个音效感觉会好很多,这时候如果能加上震动效果就会更好,这里我们使用的是系统的 Vibrator实现震动,SoundPool实现音效播放。
提示动画的实现
因为动画只是一个横向反复平移。所以我们可以借助于属性动画的ValueAnimator计算出值,然后调用View的invalidate()方法触发view绘制需要动画的对象就行,本文中需要动画的对象是(小手图标)
代码解析
初始化
在初始化的时候我们将自定义的属性解析出来并赋给当前类的成员变量,并且初始化画笔和一些值
public RulerSeekBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 初始化自定义属性
initAttrs(context, attrs);
// 滑动的阈值,后面会通过它去判断当前的是操作是滑动还是触摸操作
ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
TOUCH_SLOP = viewConfiguration.getScaledTouchSlop();
// 速度追踪器的初始化
MIN_FLING_VELOCITY = viewConfiguration.getScaledMinimumFlingVelocity();
MAX_FLING_VELOCITY = viewConfiguration.getScaledMaximumFlingVelocity();
// 将距离值转换成数字
convertValueToNumber();
// 画笔等成员变量的初始化
init(context);
}
在convertValueToNumber中我们将距离转换成对应的数字
private void convertValueToNumber() {
mMinNumber = (int) (minValue * 10);
mMaxNumber = (int) (maxValue * 10);
mCurrentNumber = (int) (currentValue * 10);
mNumberUnit = (int) (gradationUnit * 10);
mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit *
gradationGap;
mNumberRangeDistance = (float) (mMaxNumber - mMinNumber) / mNumberUnit *
gradationGap;
if (mWidth != 0) {
mWidthRangeNumber = (int) (mWidth / gradationGap * mNumberUnit);
}
Log.d(TAG, "convertValueToNumber: mMinNumber: " + mMinNumber + " ,mMaxNumber: "
+ mMaxNumber + " ,mCurrentNumber: " + mCurrentNumber + " ,mNumberUnit: " +
+ mNumberUnit
+ " ,mCurrentDistance: " + mCurrentDistance + " ,mNumberRangeDistance: " +
+ mNumberRangeDistance
+ " ,mWidthRangeNumber: " + mWidthRangeNumber);
+
}
在init函数中,主要是对各种画笔和震动音效的成员变量的初始化工作
private void init(Context context) {
// 短刻度画笔
mShortGradationPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mShortGradationPaint.setStrokeWidth(shortLineWidth);
mShortGradationPaint.setColor(gradationColor);
mShortGradationPaint.setStrokeWidth(shortLineWidth);
mShortGradationPaint.setColor(gradationColor);
mShortGradationPaint.setStrokeCap(Paint.Cap.ROUND);
// 长刻度画笔
mLongGradationPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mLongGradationPaint.setStrokeWidth(longLineWidth);
mLongGradationPaint.setStrokeCap(Paint.Cap.ROUND);
mLongGradationPaint.setColor(Color.parseColor("#FF4AA5FD"));
// 指针画笔,这里用到了LinearGradient ,主要是实现一种渐变效果。
int[] colors = new int[]{0x011f8d8, 0xff0ef4cb, 0x800cf2c3};
LinearGradient linearGradient = new LinearGradient(
0,
0,
100,
100,
colors,
null,
Shader.TileMode.CLAMP
);
mIndicatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mIndicatorPaint.setColor(indicatorLineColor);
mIndicatorPaint.setStrokeWidth(indicatorLineWidth);
mIndicatorPaint.setStrokeCap(Paint.Cap.ROUND);
mIndicatorPaint.setShader(linearGradient);
Bitmap originBp = BitmapFactory.decodeResource(getResources(),
R.drawable.indicator);
indicatorBp = Bitmap.createScaledBitmap(originBp, dp2px(222), dp2px(6.85f), true);
originBp.recycle();
// 手势图标画笔
mGestureAniPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
// 文字画笔
mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setTextSize(textGradationSize);
mTextPaint.setColor(textGradationColor);
mScroller = new Scroller(context);
// 数字画笔
mNumPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mNumPaint.setTextSize(textGradationSize);
mNumPaint.setColor(textGradationColor);
mSoundPool = new SoundPool(10,AudioManager.STREAM_MUSIC,0);
soundId = mSoundPool.load(getContext(),R.raw.sound,1);
// 震动效果
vibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
}
控件测量
在测量阶段主要是决定控件的大小,这里我们只需要处理测量模式为AT_MOST的情况下的控件的高。这种模式下不做限制会导致子控件的高度变得异常:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mWidth = calculateSize(true, widthMeasureSpec);
mHeight = calculateSize(false, heightMeasureSpec);
mHalfWidth = mWidth >> 1;
if (mWidthRangeNumber == 0) {
mWidthRangeNumber = (int) (mWidth / gradationGap * mNumberUnit);
}
Log.d(TAG, "onMeasure: mWidthRangeNumber: " + mWidthRangeNumber + " ,mNumberUnit:
" + mNumberUnit);
setMeasuredDimension(mWidth, mHeight);
}
private int calculateSize(boolean isWidth, int measureSpec) {
final int mode = MeasureSpec.getMode(measureSpec);
final int size = MeasureSpec.getSize(measureSpec);
int realSize = size;
if (mode == MeasureSpec.AT_MOST) {
if (!isWidth) {
int defaultSize = dp2px(74);
realSize = Math.min(realSize, defaultSize);
}
}
Log.d(TAG, "mode: " + mode + " ,size: " + size + " ,realSize: " + realSize);
return realSize;
}
控件绘制
绘制阶段主要是绘制背景,然后绘制刻度和数字,最后绘制指针,然后动画是根据变量isPlayTipAnim来决定是否绘制的,当用户不点击控件的时候,动画会一直播放,用户点击了后停止对动画的绘制
@Override
protected void onDraw(Canvas canvas) {
// 绘制背景
canvas.drawColor(bgColor);
// 绘制刻度和数字
drawGradation(canvas);
// 绘制指针
drawIndicator(canvas);
// 绘制动画的图标
if (isPlayTipAnim) {
drawGestureAniIcon(canvas);
}
}
提示动画绘制
绘制动画的时候我们可以使用一个ValueAnimator属性动画来确定一个动画的范围,当我们开始动画的时候,这个类会给们计算变化的值,我们把这个值设置成小手图标的X坐标,保持Y坐标不变,然后这个值每改变一次,就触发一次重绘,这样就完成了提示动画的效果了,代码如下所示:
private void drawGestureAniIcon(Canvas canvas) {
if (mGestureTipBp == null) {
Bitmap originBp = BitmapFactory.decodeResource(getResources(),
R.drawable.ic_gesture_tip);
mGestureTipBp = Bitmap.createScaledBitmap(originBp, dp2px(46), dp2px(47),
true);
mGestureAniTransX = mHalfWidth - (float) mGestureTipBp.getWidth() / 2 +
dp2px(2);
originBp.recycle();
valueAnimator = ValueAnimator.ofFloat(
mHalfWidth - 11 * gradationGap,
mHalfWidth + 7 * gradationGap); // 此处做动画的范围。按照真实情况合理调整。
valueAnimator.addUpdateListener(animation -> {
mGestureAniTransX = (float) animation.getAnimatedValue();
// Log.d(TAG, "zhongxj111: mGestureAniTransX: " + mGestureAniTransX);
invalidate();
});
valueAnimator.setDuration(2000);
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
valueAnimator.start();
}
canvas.drawBitmap(mGestureTipBp,
mGestureAniTransX,
stopLongGradationY - (float) mGestureTipBp.getHeight() / 2 - dp2px(15),
mGestureAniPaint
);
}
渐变效果的绘制
当绘制刻度的时候,我们需要去实现绘制渐变效果,就是我们的控件两边,如果用户左右滑动的时候,我们的刻度有渐变的效果,感觉好像是慢慢消失一样,这里有的读者可能会想到让UI切一张透明的背景,这种方法如果控件的背景是黑色的时候可行,但是控件的背景是其他的颜色的时候就会发现这个透明的背景很突兀,感兴趣的读者也可以去尝试下。我的实现方式是通过用户滑动的距离换算成透明度设置给刻度的画笔,这样用户滑动的时候,距离是在变化的,或是变大,或是变小,这时候再把这个距离映射成透明的值即可。
我们的Paint的API设置透明值是一个整型的数,范围是0~255
我们只要保证设置的值在这个区间即可。
我们滑动的时候会得到一个刻度距离最左边或者最右边的距离值,这个值正好可以用于换算成颜色值,注意:如果刻度间距离设置得很大,需要重新映射,这里我默认刻度在11dp下的,滑动的距离刚好在0~255之间
关键代码如下:
// 给控件开始的6个刻度做渐变效果
if (distance < 6 * gradationGap) {
Log.d(TAG, "distance==>" + distance + " ,curPosIndex=>" + curPosIndex +
" ,perUnitCount: " + perUnitCount + " ,factor: " + factor
+ " ,6*gradationGap: " + 6 * gradationGap);
//计算开始部分的透明值
int startAlpha = Math.abs((int) (distance));
mLongGradationPaint.setAlpha(startAlpha);
mShortGradationPaint.setAlpha(startAlpha);
mNumPaint.setAlpha(startAlpha);
// 给控件的结尾做渐变效果
} else if (distance > mWidth - 6 * gradationGap) {
// 计算结束的透明值
int endAlpha = Math.abs((int) ((mWidth + gradationGap) - distance));
// Log.d(TAG, "zhongxj: endAlpha: " + endAlpha);
mLongGradationPaint.setAlpha(endAlpha);
mShortGradationPaint.setAlpha(endAlpha);
mNumPaint.setAlpha(endAlpha);
} else {
{
mShortGradationPaint.setAlpha(255);
mLongGradationPaint.setAlpha(255);
mShortGradationPaint.setColor(gradationColor);
mLongGradationPaint.setColor(Color.parseColor("#FF4AA5FD"));
}
}
这里还有一个难点就是结尾处的渐变值如何设置,因为结尾处的距离超过了0~255范围,而且这个渐变值需要和开始部分的透明值保持对应并且是逐渐变小,开始处的透明值是逐渐增大的,比如:开始的透明值是1,2,3,4,那么结尾处的透明值就必须为4,3,2,1。处理的代码为:
int endAlpha = Math.abs((int) ((mWidth + gradationGap) - distance));
这里我们可以举个例子说明下,比如1,2,3,4,5,6,7,8,9,10 当处于2的时候distance为2,7的时候distance为7,gradationGap为1,mWidth为10,我们想要把7,8,9,10映射成4,3,2,1,只需要使用:(10+1)-distance(7,8,9,10)就行了,读者可以去计算试试。
事件的处理
我们滑动屏幕时判断如果是横向滑动,则使用Scroll滚动到我们想要滚动的刻度。如果有惯性滚动,那么惯性滚动后,再自动吸附到最近的一个刻度上即可:
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = event.getAction();
final int x = (int) event.getX();
final int y = (int) event.getY();
Log.d(TAG, "onTouchEvent: " + action);
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
switch (action) {
case MotionEvent.ACTION_DOWN:
mScroller.forceFinished(true);
mDownX = x;
isMoved = false;
isPlayTipAnim = false;
if (valueAnimator != null) {
valueAnimator.cancel();
}
break;
case MotionEvent.ACTION_MOVE:
final int dx = x - mLastX;
//判断是否已经滑动
if (!isMoved) {
final int dy = y - mLastY;
// 滑动的触发条件,水平滑动大于垂直滑动,滑动距离大于阈值
if (Math.abs(dx) < Math.abs(dy) || Math.abs(x - mDownX) < TOUCH_SLOP)
{
break;
}
isMoved = true;
}
mCurrentDistance -= dx;
calculateValue();
break;
case MotionEvent.ACTION_UP:
// 计算速度:使用1000ms 为单位
mVelocityTracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY);
// 获取速度,速度有方向性,水平方向,左滑为负,右滑为正
int xVelocity = (int) mVelocityTracker.getXVelocity();
// 达到速度则惯性滑动,否则缓慢滑动到刻度
if (Math.abs(xVelocity) >= MIN_FLING_VELOCITY) {
mScroller.fling((int) mCurrentDistance, 0, -xVelocity, 0,
0, (int) mNumberRangeDistance, 0, 0);
invalidate();
} else {
scrollToGradation();
}
break;
}
mLastX = x;
mLastY = y;
return true;
}
根据滑动的距离计算处需要滚动的刻度即可:
private void scrollToGradation() {
mCurrentNumber = mMinNumber + Math.round(mCurrentDistance / gradationGap) *
mNumberUnit;
// 算出的值边界设置,如果当前的值小于最小值,则选最小值,如果当前的值大于最大值,则取最大值
mCurrentNumber = Math.min(Math.max(mCurrentNumber, mMinNumber), mMaxNumber);
mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit *
gradationGap;
currentValue = mCurrentNumber / 10f; // 当前的值是放大了10倍处理的,所以回调值的时候需要
缩小10倍
if (mValueChangedListener != null) {
mValueChangedListener.onValueChanged(currentValue);
}
// 播放音效和震动效果
playSoundEffect();
startVibration();
// 触发重绘
invalidate();
}
回调值给用户
在滚动的时候和计算值的时候将值回调给调用者
/**
* 当前值变化监听器
*/
public interface OnValueChangedListener {
void onValueChanged(float value);
}
/**
* 根据distance距离,计算数值
*/
private void calculateValue() {
// 限定范围在最大值与最小值之间
mCurrentDistance = Math.min(Math.max(mCurrentDistance, 0), mNumberRangeDistance);
mCurrentNumber = mMinNumber + (int) (mCurrentDistance / gradationGap) *
mNumberUnit;
// 因为值放大了10倍处理,所以回调值的时候需要缩小10倍
currentValue = mCurrentNumber / 10f;
Log.d(TAG, "currentValue: " + currentValue + ",mCurrentDistance: "
+ mCurrentDistance + " ,mCurrentNumber: " + mCurrentNumber);
if (mValueChangedListener != null) {
mValueChangedListener.onValueChanged(currentValue);
}
invalidate();
}
private void scrollToGradation() {
mCurrentNumber = mMinNumber + Math.round(mCurrentDistance / gradationGap) *
mNumberUnit;
// 算出的值边界设置,如果当前的值小于最小值,则选最小值,如果当前的值大于最大值,则取最大值
mCurrentNumber = Math.min(Math.max(mCurrentNumber, mMinNumber), mMaxNumber);
mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit *
gradationGap;
currentValue = mCurrentNumber / 10f; // 当前的值是放大了10倍处理的,所以回调值的时候需要
// 缩小10倍
if (mValueChangedListener != null) {
mValueChangedListener.onValueChanged(currentValue);
}
// 播放音效和震动效果
playSoundEffect();
startVibration();
invalidate();
}
总结
本文主要介绍了一个RulerSeekBar的自定义View,文中只介绍了关键的实现部分,其他细节部分读者感兴趣可以阅读源码,源码的地址为:RulerSeekBar 自定义View的地址,控件使用的是Java语言编写,虽然现在Android开发中Kotlin是扛把子,但是由于是给只会使用JAVA的用户开发的控件,所以我使用了JAVA语言,但是Kotlin也能使用,并且如果读者有时间可以使用kotlin将这个控件实现一下,原理基本一样,就是使用的语法不同而已。有问题的评论区一起交流。