一个像尺子的滑动选择控件 - RulerSelectView

1、简述

Today,写一个像尺子一样的滑动选择控件,效果如下:
RulerView
控件可随手指左右滑动,手指抬起后,若手指力量不足以使它惯性滑动,则会自动将选中的刻度线滑到与中间对齐;若可以惯性滑动,则先加速后减速滑动,停止时选中的刻度线也是与中间对齐,有点类似给 RecyclerView 设置 LinearSnapHelper 后的效果。这种可滑动选择控件比较常见,比如app中的日期、地区选择等等。因为它长的像尺子,所以叫它RulerSelectView。

2、实现方法

第一种方法是使用RecyclerView。RecyclerView这么强大,是肯定可以实现的,而且实现起来并不复杂。一个刻度就是一个ItemView,刻度线就是View加一个带颜色的背景,或者只使用一个空白的View,用ItemDecoration来绘制刻度线。再利用LinearSnapHelper 对齐刻度 ItemView。两边的透明渐变效果通过下面两个属性指定:

//指定渐变方向
android:requiresFadingEdge="horizontal"
//指定渐变长度
android:fadingEdgeLength="100dp"

这样实现起来确实很简单。这种方法不仅仅可以实现这个尺子选择控件,只要是类似的,可以滑动和惯性滑动,需要对齐的控件都可以实现,比如选择生日的控件等等。

第二种方法是自定义View实现。继承View,绘制刻度线,监听触摸事件,处理滑动和惯性滑动,滑动冲突,对齐,边界检查(不能滑出最小/大刻度)等等问题,都需要我们自己来实现。这种方法能运用更多的技能,也并不是很难,但实现过程中可能会遇到各种问题,需要一些时间来不断的调试和修改,最后直到完美。

3、代码实现

我不打算在xml中定义需要的属性,定义这些属性我得考虑会不会和别的控件属性相冲突,而且还需要浪费性能来解析这些个属性。所以我会在代码里面定义一些set方法,通过这些方法来设置和定制该控件。

初始化

重写了三个构造方法,前两个都会调用第三个构造方法。在第三个构造方法中,初始化了一些成员变量,注释对各变量进行了说明。

public RulerSelectView(Context context) {
    this(context, null);
}
public RulerSelectView(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
}
public RulerSelectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//画笔,绘制刻度线和刻度文字
    mPath = new Path();//刻度线的路径
    mDecimalNumber = 10;//进制数,表示一个进制单位包含多少个刻度,如:8,10,16等
    mDecimal = 10;//精确度,值为10的x次的幂,x为小数位数
    mSegmentNumber = 5;//片段(高低刻度线分割的片段),表示一个片段包含多少个刻度
    mIsShowSelectedScale = true;//是否显示中间选中刻度线
    mHasGradient = true;//是否有渐变效果
    final int gap = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, getResources().getDisplayMetrics());
    mScaleLineGap = gap;//刻度线之间的间隔
    mScaleLineHeight = gap * 3;//刻度线的高度
    mSegmentLineHeight = gap * 4;//片段刻度线的高度
    mDecimalLineHeight = mSegmentLineHeight;//进制刻度线的高度
    mScaleLineColor = Color.GRAY;//刻度线的颜色
    mSelectedColor = Color.RED;//中间选中刻度线的颜色
    mPaint.setTextAlign(Paint.Align.CENTER);//设置文本居中绘制
    mPaint.setTextSize(gap);//设置刻度文字大小
    mPaint.setStrokeWidth(gap / 10f);//设置刻度线宽度
    //OverScroller是处理滑动和惯性滑动的利器,构造时传入了一个减速插值器
    mScroller = new OverScroller(getContext(), new DecelerateInterpolator());
    ViewConfiguration vc = ViewConfiguration.get(getContext());
    //最小滑动速率,可以触发惯性滑动的最小速率
    mScaledMinimumFlingVelocity = vc.getScaledMinimumFlingVelocity();//150
    //最大滑动速率,后面使用 VelocityTracker 获取速率是会用到
    mScaledMaximumFlingVelocity = vc.getScaledMaximumFlingVelocity();//24000
}
测量大小

为了支持在布局文件中指定宽高为wrap_conent,我强制设置wrap_conent时宽为屏幕宽度,高度为100dp。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = MeasureSpec.getSize(widthMeasureSpec);
    //如果widthMode是AT_MOST或UNSPECIFIED,width为父控件剩余的宽度,否则为布局文件中声明的宽度
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int height = MeasureSpec.getSize(heightMeasureSpec);
    //如果是heightMode是AT_MOST或UNSPECIFIED,height为父控件剩余的高度,否则为布局文件中声明的高度
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
    if (widthMode != MeasureSpec.EXACTLY) {
        width = displayMetrics.widthPixels;
    }
    if (heightMode != MeasureSpec.EXACTLY) {
        height = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100f, displayMetrics);
    }
    setMeasuredDimension(width, height);
}
OnSizeChange

测量完成后,这个方法会马上调用,该方法中可以拿到view的宽高。

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    Rect rect = new Rect();
    //测量刻度文字的高度,用户计算下面的Y坐标值
    mPaint.getTextBounds("1", 0, 1, rect);
    int textHeight = rect.height();
    //刻度线底部在view中的Y坐标,用于绘制刻度线
    mScaleBottomY = h - getPaddingBottom() - textHeight - mScaleLineGap;
    if (mHasGradient) {//如果有渐变效果,初始化LinearGradient,用它来实现渐变。
        mLinearGradient = new LinearGradient(0f, 0f, w / 2f, 0f,//设置渐变效果从0到中间位置
                new int[]{Color.TRANSPARENT, mScaleLineColor},//渐变的颜色
                new float[]{0.2f, 0.8f},//渐变颜色占的比重,透明为0.2,不透明为0.8
                Shader.TileMode.MIRROR);//设置平铺模式为镜像,即中间位置到width的渐变效果为0到中间位置的镜像(中间位置到 0)
    }
}
绘制刻度线

有了刻度线之后,我们才好做下一步处理。考虑到绘制的效率,只绘制屏幕上出现的刻度线,没有出现的刻度线不用绘制,一是因为浪费时间和性能,二是因为使用Path绘制路劲时,如果坐标很大(y坐标超过9000 或 x坐标超过9000),Path 不会绘制出来。

选中的刻度线(mSelectedPosition)始终是在中点,绘制的时候以中点坐标 cx 为基准计算每个刻度线的坐标,先绘制它和大于它(后面)的刻度线,坐标大于控件 width - paddingEnd 的不绘制。再绘制小于它(前面)的刻度线,坐标小于 paddingStart 的不绘制。
mSelectedPosition 默认为 0,在滑动结束后,才会更新为当前选中的刻度,在滑动过程中还是上一次选中的刻度。所以滑动距离很大时,mSelectedPosition 可能并不在屏幕上。比如手指往左滑动了很久,这时绘制后面的刻度线时,其实有很多刻度线小于了paddingStart,这些刻度也是不能绘制的。同样在手指往右滑动了很久,绘制前面的刻度线时,大于 width - paddingEnd 的刻度线不能绘制。
同时在绘制时,计算了mSnapX 的值,该值的绝对值是所有刻度线中与中点距离相差最小的值,它对应的刻度线为 mSnapPosition 。mSnapPosition 记录着滑动过程中选中刻度线的变化,在滑动结束后会赋值给 mSelectedPosition。

@Override
protected void onDraw(Canvas canvas) {
    if (mData == null || mData.isEmpty()) return;//mData是刻度线集合,没有刻度线不绘制
    final int w = getWidth();
    final int h = getHeight();
    if (w == 0 || h == 0) return;//宽或高为0不绘制
    mPath.reset();//清除上一次的路径
    final int size = mData.size();//刻度值的大小
    float cx = w / 2f;//中点
    float x;//刻度线的x坐标
    float snap;//刻度线与中点的距离( cx - x ),最小的snap对应的刻度即为当前选中的刻度线
    mSnapX = Integer.MAX_VALUE;//最小的snap,这里是重置,后面会对其赋值
    if (mHasGradient) {
        mPaint.setShader(mLinearGradient);//有渐变效果,设置渐变渲染
    } else {
        mPaint.setColor(mScaleLineColor);//没有渐变效果,就刻度线颜色
    }
    mPaint.setStyle(Paint.Style.FILL);
    //两个循环,一个从 mSelectedPosition 往上(++)循环,另一个从 mSelectedPosition 往下(--)开始循环
    //选中的刻度线始终在中点,以中点坐标 cx 为基准计算每个刻度线的坐标
    for (int i = mSelectedPosition; i < size; i++) {
    	//x 为当前i(刻度线)的坐标,mOffset为滑动的距离,滑动时会不断改变它
        x = cx + (i - mSelectedPosition) * mScaleLineGap + mOffset;
        if (x > w - getPaddingEnd()) break;//超过w - getPaddingEnd直接返回
        //在往左的fling状态下(很快),由于mSelectedPosition没有及时改变,会有很多大于mSelectedPosition的i的坐标小于getPaddingStart,这样的i也不绘制,但不能break,因为真正要绘制的i(出现在屏幕上的)比这样的i大,所以只能跳过。
        if (x < getPaddingStart()) continue;
        snap = cx - x;//当前i与中点的距离
        forPath(canvas, i, x, snap, h);//该方法用于更新mSnapX ,添加路径,绘制刻度文字
    }
    if (mSelectedPosition > 0) {
        for (int i = mSelectedPosition - 1; i >= 0; i--) {
            x = cx - (mSelectedPosition - i) * mScaleLineGap + mOffset;
            if (x < getPaddingStart()) break;//小于getPaddingStart直接返回
            //在往右的fling状态下(很快),由于mSelectedPosition没有及时改变,会有很多小于mSelectedPosition的i的坐标大于w - getPaddingEnd,这样的i也是不绘制的,但不能break,因为真正要绘制的i(出现在屏幕上的)比这样的i小,所以只能continue;
            if (x > w - getPaddingEnd()) continue;
            snap = cx - x;//当前i与中点的距离
            forPath(canvas, i, x, snap, h);
        }
    }
    mPaint.setStyle(Paint.Style.STROKE);
    canvas.drawPath(mPath, mPaint);//绘制刻度线路径
    mPath.reset();//清除路径
    if (mHasGradient) {
        mPaint.setShader(null);//重置
    }
    if (mIsShowSelectedScale) {//绘制中间选中的刻度线
        mPath.moveTo(cx, mScaleBottomY);
        mPath.lineTo(cx, mScaleBottomY - mScaleLineHeight - 2 * mScaleLineGap);
        mPaint.setColor(mSelectedColor);
        canvas.drawPath(mPath, mPaint);
    }
    //mLastSnapPosition 为滑动过程中上次的选中刻度线
    //mSnapPosition 为滑动过程中的选中刻度线
    //滑动时,如果上一次与这次的不一样,就通过接口回调来通知选中刻度线的变化
    if (mLastSnapPosition != mSnapPosition) {
        mLastSnapPosition = mSnapPosition;//更新滑动过程中上次的选中刻度线
        mSelectedValue = Math.round(mData.get(mSnapPosition) * mDecimal) / mDecimal;//计算选中刻度线对应的刻度值
        if (mRulerSelectChangeListener != null) {//接口回调监听者选中的刻度线变了
            mRulerSelectChangeListener.onRulerChange(this, mSnapPosition, mSelectedValue);
        }
    }
}

//更新mSnapX ,添加路径,绘制刻度文字
private void forPath(Canvas canvas, int i, float x, float snap, int h) {
    if (Math.abs(mSnapX) > Math.abs(snap)) {//滑动过程中,与中点距离最短的i发生变化
        mSnapX = snap;//更新mSnapX为最小的snap
        if (mSnapPosition != i) mSnapPosition = i;//更新选中的i
    }
    //下面是添加刻度线路径和绘制刻度线文字
    mPath.moveTo(x, mScaleBottomY);
    if (i % mDecimalNumber == 0) {//进制刻度线
        mPath.lineTo(x, mScaleBottomY - mDecimalLineHeight);
        float s = Math.round(mData.get(i) * mDecimal) / mDecimal;
        String text;
        if (s * mDecimalNumber % mDecimalNumber == 0) {
            text = String.valueOf((int) s);
        } else {
            text = String.valueOf(s);
        }
        canvas.drawText(text, x, h - getPaddingBottom(), mPaint);
    } else if (i % mSegmentNumber == 0) {//片段刻度线
        mPath.lineTo(x, mScaleBottomY - mSegmentLineHeight);
    } else {
        mPath.lineTo(x, mScaleBottomY - mScaleLineHeight);
    }
}
监听触摸事件处理滑动

要让刻度线滑动起来,就要根据手指的滑动距离来不断的重新绘制,在onDraw中,计算刻度线坐标时已经加上了滑动的距离mOffset,我们只要在 ACTION_MOVE 事件改变它即可。
可以随手指滑动之后,还需要处理 ACTION_UP事件。当手指抬起时我们通过 VelocityTracker 获取手指的速率,判断该速率是否足够惯性滑动,不足够的话就需要调整选中的刻度线到中间位置,在onDraw中已经计算了要调整的距离mSnapX。怎么调整呢,肯定不能从当前刻度线直接到中点,这样像断片了一样,我们使用 Scroller 来平缓调整到中间。如果足够惯性滑动的话,我们使用 Scroller 的 fling方法来惯性滑动。

因为该控件需要消费触摸事件,若放到也可以左右滑动的ViewPager中,会产生滑动冲突导致无法正常滑动,所以需要处理冲突。处理方法是,当该控件可以滑动时(继续滑动不会超过边界),请求父控件不拦截事件,当该控件不能滑动了(继续滑动会超过边界),就默认由父处理事。

Scroller并不直接让 View 滑动,它只是用来计算一个运动过程中不同时间的运动距离。 当调用它的 startScroll 方法时,它并没有做什么特殊操作,只是保存传入的参数和记录运动开始的时间,当调用它的 computeScrollOffset 方法时,会根据开始时间,插值器,和startScroll 方法传入的距离(最终距离)、时间等等计算出此时的运动距离,并返回一个布尔值,true表示运动结束,false表示还没有结束。它的filing方法,会根据传入速率计算出的该速率可以运动多长距离,并在调用 computeScrollOffset 方法时,计算惯性运动此时运动的距离,该距离和真实的运动效果一样。它的 getCurrX/Y 方法用来获取当前时间的距离。

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (mData == null || mData.isEmpty()) return super.onTouchEvent(event);
    if (mVelocityTracker == null) {//初始化了mVelocityTracker
        mVelocityTracker = VelocityTracker.obtain();
    }
    // 只在MotionEvent.ACTION_UP里面添加是不起作用的
    // 翻译:向速率追踪者中加入一个用户的移动事件,你应该最先在ACTION_DOWN调用这个方法,
    // 然后在你接受的ACTION_MOVE,最后是ACTION_UP。你可以为任何一个你愿意的事件调用该方法
    mVelocityTracker.addMovement(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //不是在边界,需要消费事件,请求父View请求不要拦截
            if (!isRightBound() && !isLeftBound()) {
                getParent().requestDisallowInterceptTouchEvent(true);
            }
            if (mScroller.computeScrollOffset()) {//如果上一次运动没有结束,停止掉
                mScroller.forceFinished(true);
            }
            mIsFling = false;//是否是惯性滑动状态,重置为不是
            mLastX = event.getX();//获取手指按下的坐标
			//左边可以被滑动的最大距离,正数;手指向右滑时进行比较,mOffset为正
            mMaxLeftOffset = mSelectedPosition * mScaleLineGap;
            //右边可以被滑动的最大距离,负数;手指向左滑时进行比较,mOffset为负
            mMaxRightOffset = (mSelectedPosition - mData.size() + 1) * mScaleLineGap;
            break;
        case MotionEvent.ACTION_MOVE:
            float x = event.getX();//手指的坐标
            float dx = x - mLastX;//手指相比上一次的滑动距离,往左为负,往右为正
            boolean isLeftBound = isLeftBound();//是否到达左边界
            boolean isRightBound = isRightBound();//是否到达右边界
            if (isLeftBound || isRightBound) {
            //如果到达左边界,手指还往右滑,不处理;如果到达右边界,手指还往左滑,不处理
                if (dx == 0 || (isLeftBound && dx > 0) || (isRightBound && dx < 0)) {
                    return false;
                }
            }
            //如果到达了边界,但滑动方向与边界方向相同,仍然可以滑动,请求不要拦截
            getParent().requestDisallowInterceptTouchEvent(true);//请求不要拦截
            if (dx == 0) return true;//滑动距离为0不重新绘制
            mOffset += dx;//累加滑动的距离
            mLastX = x;//更新为当前坐标
            if (dx > 0) {//手指往右滑动,被滑动的是左边的距离
                if (mOffset > mMaxLeftOffset) {//如果大于左边可滑的最大距离,重新赋值
                    mOffset = mMaxLeftOffset;
                }
            } else {//往左滑动,被滑动的是右边的距离
                if (mOffset < mMaxRightOffset) {//如果小于右边可滑的最大距离,重新赋值
                    mOffset = mMaxRightOffset;
                }
            }
            //请求重新绘制,执行onDraw方法,因为mOffset的值已经发生变化,所以绘制的刻度线位置也发生变化,看起来就像在滑动一样
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            //参数:units 你想要指定的得到的速度单位,如果值为1,代表1毫秒运动了多少像素。
            //     如果值为1000,代表1秒内运动了多少像素。
            //     同样的力量,units 的数值越大,滑动的距离越长,数值越小,滑动的距离越小
            mVelocityTracker.computeCurrentVelocity(1000, mScaledMaximumFlingVelocity);
            //手指往左,velocityX 与 mOffset一样为负数; 手指往右,velocityX 与 mOffset一样为正数
            float velocityX = mVelocityTracker.getXVelocity();//获取手指的x轴方向速率
            //如果超过最小速率,可以惯性滑动
            if (Math.abs(velocityX) > mScaledMinimumFlingVelocity) {
             //但是如果已经在左边界,不能向右惯性滑动;如果已经在右边界,不能向左惯性滑动
                if ((isLeftBound() && velocityX > 0) || (isRightBound() && velocityX < 0)) {
                    mLastX = 0;//重置
                    mVelocityTracker.clear();//清除
                    break;//返回
                }
                mIsFling = true;//要开始fling了,设置为true
                mScroller.fling(0, 0, (int) velocityX, 0,
                        Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);//将速率设置给 mScroller 计算可以滑动多长距离
                mOffset = Math.round(mOffset);//取个整
            } else {//没有触发filing ,滑动到中间位置与其对齐
                mScroller.startScroll(0, 0, (int) mSnapX, 0, 300);
            }
            //必须请求绘制,才会执行computeScroll方法和 onDraw方法
            invalidate();
            mLastX = 0;
            mVelocityTracker.clear();
            break;
    }
    return true;//只有 ACTION_DOWN 被消费,后续的事件才会被接受到
}

private boolean isLeftBound() {//是否到达左边界
    //选中的刻度线是第1个,并且与中间距离为0必然是到了左边界
    return mSnapPosition == 0 && mSnapX == 0;
}
private boolean isRightBound() {是否到达右边界
    //选中的刻度线是最后一个,并且与中间距离为0必然是到了右边界
    return mSnapPosition == mData.size() - 1 && mSnapX == 0;
}
computeScroll 处理 fling

onTouchEvent 中只是调用了Scroller 相应的方法,还要在computeScroll 方法中获取新的滑动距离赋值给mOffset,来让刻度线滑动到相应位置,该方法在每次绘制前被调用。
在手指抬起时,我们判断了不能触发 fling 时,就滑动 mSnapX 距离来对齐到中点,因 mSnapX 本身是选中刻度线与中点的距离,且在ACTION_MOVE 中我们已经做了边界检查,所以该滑动自然是不会超过边界。但是,在 fling 时,如果速率很大,fling 的距离会很长,就可能出现超过边界的问题。另外,fling 结束时,刻度线要刚好停在在对齐位置上,但 fling 的距离是由速率来决定,无法控制。我们需要处理好这两个问题。
虽然 fling 的距离我们无法控制,但是我们知道 fling 结束时应该停在哪里,超过边界时,应该刚好停在边界上,不超过边界时,选中刻度线应该刚好是停在中点的。当调用Scroller的 filing 方法后,getFinalX方法可以立马获取到可以 fling 的距离,试想我们可以计算出与该距离最相近的,且是刻度线间隔整数倍的新距离,然后通过Scroller的 startScroll 来滑动该新距离,若不超过边界,这样刻度线会刚好停在中点,但是这样看到的就不是真实的惯性滑动效果了,因此还是要在 fling 过程中处理。
在ACTION_DOWN 中,我们计算了本次滑动可以向左的最大距离mMaxLeftOffset,和向右的最大距离mMaxRightOffset,fling 时,我们可以判断滑动距离 mOffset 是否超过最大距离,如果超过,我们就立马停止 fling 。如果不超过的话,就判断是否小于刻度线的间隔,如果小于,同样也是停止 fling ,但是会调用 startScroll 开启新的滑动,使选中刻度线与中的对齐。这样就解决了上面两个问题点,很自然真实的 fling 到对齐位置上。

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        final int currX = mScroller.getCurrX();//获取当前时间的运动距离
        final int finalX = mScroller.getFinalX();//获取终点距离
        //往右fling时finalX > 0 ,  如果大于左边可滑的最大距离,重新赋值
        if (mIsFling && finalX > 0 && mOffset > mMaxLeftOffset) {
			mScroller.forceFinished(true);//结束运动
			//让选中的刻度线为第一个,并让mOffset = 0 ,这样绘制时就在左边界
			mSelectedPosition = 0;
			mOffset = 0;
			mIsFling = false;
			invalidate();
            return;//返回
        }
        //往左fling时finalX < 0 , 如果小于右边可滑的最大距离,重新赋值
        if (mIsFling && finalX < 0 && mOffset < mMaxRightOffset) {
            mScroller.forceFinished(true);
            //让选中的刻度线为最后一个,并让mOffset = 0 ,这样绘制时就在右边界
			mSelectedPosition = mData.size() - 1;
			mOffset = 0;
			mIsFling = false;
			invalidate();
            return;//返回
        }
        final int remain = Math.abs(finalX - currX);//还剩多少运动距离
        if (mIsFling && remain <= mScaleLineGap) {//不会超过边界,但是剩下的距离小于刻度线的间距,需要调整到中点
            int dx = mScaleLineGap;
            //mold有正负,与mOffset正负一致
            int mold = Math.round(mOffset % dx );//已滑动距离除以间距的余数
            //finalX 和 mOffset 可能会不是同一方向的(手指往左滑,mOffset为负,释放时却是往右,finalX为正数)
            if (finalX > 0) {//向右filing 时,dx应为正数,mOffset变大
                dx -= mold;//mOffset + mScaleLineGap - mode 刚好是间距的整数倍
            } else {//向左filing 时,dx应为负数,mOffset变小
                dx = -dx - mold; //mOffset - mScaleLineGap - mold 刚好是间距的整数倍
            }
            //dx正数相当于手指往右滑动,dx负数相当于手指往左滑动
            //已经滑动了mOffset 距离,再滑动 dx 距离 ,最终的滑动距离会比 finalX 长
            mIsFling = false;//不是在fling状态了
  			mLastX = 0;
  			mScroller.forceFinished(true);//停止fling
   			mScroller.startScroll(0, 0, dx, 0, 180);//开启新的滑动
    		invalidate();
            return;//返回
        }
        mOffset = mOffset + currX - mLastX;//累加滑动距离
        mLastX = currX;//更新
        if (mScroller.isFinished()) {
        	//运动结束,更新选中的刻度线,并让mOffset = 0 ,这样绘制时选中刻度线就在中间
         	mSelectedPosition = mSnapPosition;
            mOffset = 0;
        }
   		//调用Scroller的滑动相关方法后必须请求重绘,不然不会再次computeScroll计算新的运动距离
        invalidate();
    }
}
onSaveInstanceState 保存数据

完成上面的步骤后,我们的控件基本完成。但是自定义View 还有重要的一点是要处理页面重建过程中View的数据的保存和恢复,以不至于重建后数据丢失。 所以当屏幕旋转过程中,我们要保存当前选中的刻度线 mSelectedPosition的值,代码很简单。

protected Parcelable onSaveInstanceState() {
    if (mSelectedPosition != 0) {//不是0才需要保存我们的数据
        Bundle bundle = new Bundle();//新建一个Bundle用于保存数据
        Parcelable parcelable = super.onSaveInstanceState();//获取View自身的数据
        bundle.putParcelable("sys", parcelable);//保存View自身的数据
        bundle.putInt("i", mSelectedPosition);//保存我们的数据
        return bundle;
    } else {
        return super.onSaveInstanceState();
    }
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
    if (state instanceof Bundle) {//判断是不是我们保存的数据
        Bundle bundle = (Bundle) state;
        mSelectedPosition = bundle.getInt("i");//恢复我们的数据
        mSelectedValue = mData.get(mSelectedPosition);
        super.onRestoreInstanceState(bundle.getParcelable("sys"));//恢复View自身的数据
    } else {
        super.onRestoreInstanceState(state);
    }
}

到这里,RulerSelectView 就完工了,另外还有设置数据源和一些get、set方法没有贴出代码,有兴趣的可以去查看下源码。

总结

最后,总结这个自定义 View 运用了哪些知识:
1、如何让自定义的View可以滑动,以及滑动过程中的高效的绘制,边界处理;
2、处理触摸事件,和滑动冲突;
3、使用Scroller 和 VelocityTracker 处理滑动和惯性滑动;
4、巧妙地先 fling,然后再 startScroll 到终点,实现自然的滑动到对齐位置;
5、View 重建过程中,数据的保存和回复。

源码及Demo
  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
好的,下面是一个简单的原生JS实现滚动尺子的代码: HTML代码: ``` <div class="slider-wrapper"> <div class="slider-track"></div> <div class="slider-handle"></div> </div> ``` CSS代码: ``` .slider-wrapper { width: 300px; height: 30px; border: 1px solid #ccc; position: relative; } .slider-track { width: 100%; height: 5px; background-color: #ccc; position: absolute; top: 50%; transform: translateY(-50%); } .slider-handle { width: 20px; height: 20px; background-color: #fff; border: 1px solid #ccc; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%, -50%); cursor: pointer; } ``` JS代码: ``` var sliderWrapper = document.querySelector('.slider-wrapper'); var sliderHandle = sliderWrapper.querySelector('.slider-handle'); var sliderTrack = sliderWrapper.querySelector('.slider-track'); var isDragging = false; sliderHandle.addEventListener('mousedown', function(e) { isDragging = true; }); document.addEventListener('mousemove', function(e) { if (isDragging) { var x = e.clientX - sliderWrapper.offsetLeft; if (x < 0) { x = 0; } else if (x > sliderWrapper.offsetWidth - sliderHandle.offsetWidth) { x = sliderWrapper.offsetWidth - sliderHandle.offsetWidth; } var percent = x / (sliderWrapper.offsetWidth - sliderHandle.offsetWidth); sliderHandle.style.left = percent * 100 + '%'; sliderTrack.style.width = percent * 100 + '%'; } }); document.addEventListener('mouseup', function(e) { isDragging = false; }); ``` 说明:该代码使用了部分ES6语法,需要在支持ES6的浏览器中运行。该滚动尺子可拖动滑块调节滑块位置。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值