需求描述

当咱们需求做一些带校准的功用时,需求调节一些值来反映校准的作用,或许是相机之类的运用,需求设置焦距,曝光值之类的,为了便利用户设置这些值,一般需求用到滑动挑选的控件,比方体系供给的SeekBar控件。用户经过滑动屏幕就能设置值。运用体系的seekBar虽然能够完成这些功用,可是不美观。一般产品都不会采用体系的原生控件,所以只能是咱们自己来经过自界说view制作。今日咱们要制作的自界说View如下所示:

Android自界说View 完成一个带音效和轰动的SeekBar
然后在第一次的时分,会有个动画提示用户,怎么操作。作用如下:
Android自界说View 完成一个带音效和轰动的SeekBar
最终用户开端操作动画就会消失,用户操作时的作用如下:本文便是首要介绍怎么完成这样一个控件,这个控件在滑动的时分会伴随音效以及手机的轰动感。

思路

当咱们拿到一个自界说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

Android自界说View 完成一个带音效和轰动的SeekBar
咱们只需保证设置的值在这个区间即可。 咱们滑动的时分会得到一个刻度间隔最左面或许最右边的间隔值,这个值正好能够用于换算成颜色值,注意:假如刻度间间隔设置得很大,需求从头映射,这儿我默认刻度在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倍处理的,所以回调值的时分需求
        缩小10if (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将这个控件完成一下,原理根本一样,便是运用的语法不同罢了。有问题的评论区一起沟通。