Android 滚轮选择器的实现详解

简介

最近用一个日期选择控件,感觉官方的DatePicker操作有点复杂,而且不同的Android版本样式也都不一样。后来发现小米日历的日期选择控件蛮好看的,于是自己尝试仿写一个,感觉效果还不错。

效果图:

Picture

Picture

功能分析

  • 滚轮:首先绘制一列文本,然后添加一个偏移量,在onDraw中根据手指滑动,改变偏移量并重新绘制这一列文本,这样就实现了滑动的效果。

  • Fling:这个应该很常见了,用VelocityTrackerScroller来实现。

  • 循环滚动:当滚动超过数据集的大小后,从头继续获取数据即可。

  • 幕布效果:在中心区域绘制一个矩形。

  • 字体颜色渐变:

  • 从中心到两边,逐渐将Paint的透明度变小。

  • 从中心相邻项到中心,字体颜色渐变。

  • 中心选项文字变大: 从中心相邻项到中心,字体大小渐变。

  • 指示器文字,在中间的Item后边绘制一个文字。

到这里,所有的功能点的思路大概就清晰了。

实现方法

测量控件大小

这里主要是测量wrap_content模式的大小。 首先,要确定单个item的文字的宽高。代码如下:

public void computeTextSize() {
    mTextMaxWidth = mTextMaxHeight = 0;
    if (mDataList.size() == 0) {    
        return;
    }

    //这里使用最大的,防止文字大小超过布局大小。
    mPaint.setTextSize(mSelectedItemTextSize > mTextSize ? mSelectedItemTextSize : mTextSize);

    if (!TextUtils.isEmpty(mItemMaximumWidthText)) {
        mTextMaxWidth = (int) mPaint.measureText(mItemMaximumWidthText);
    } else {
        mTextMaxWidth = (int) mPaint.measureText(mDataList.get(0).toString());
    }
    Paint.FontMetrics metrics = mPaint.getFontMetrics();
    mTextMaxHeight = (int) (metrics.bottom - metrics.top);
}
然后确定布局的大小,布局的宽度就等于测量的mTextMaxWidth,高度为测量的mTextMaxHeight * itemCount。这里宽高中可以加入一个额外的Space,要不然文字就会挤到一起,比较难看。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int specWidthSize = MeasureSpec.getSize(widthMeasureSpec);
        int specWidthMode = MeasureSpec.getMode(widthMeasureSpec);
        int specHeightSize = MeasureSpec.getSize(heightMeasureSpec);
        int specHeightMode = MeasureSpec.getMode(heightMeasureSpec);
    
        int width = mTextMaxWidth + mItemWidthSpace;
        int height = (mTextMaxHeight + mItemHeightSpace) * getVisibleItemCount();
    
        width += getPaddingLeft() + getPaddingRight();
        height += getPaddingTop() + getPaddingBottom();
        setMeasuredDimension(measureSize(specWidthMode, specWidthSize, width),
                measureSize(specHeightMode, specHeightSize, height));
    }
    
    private int measureSize(int specMode, int specSize, int size) {
        if (specMode == MeasureSpec.EXACTLY) {
            return specSize;
        } else {
            return Math.min(specSize, size);
        }
    }
复制代码

然后确定布局的大小,布局的宽度就等于测量的mTextMaxWidth,高度为测量的mTextMaxHeight * itemCount。这里宽高中可以加入一个额外的空间,要不然文字就会挤到一起,比较难看。

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int specWidthSize = MeasureSpec.getSize(widthMeasureSpec);
        int specWidthMode = MeasureSpec.getMode(widthMeasureSpec);
        int specHeightSize = MeasureSpec.getSize(heightMeasureSpec);
        int specHeightMode = MeasureSpec.getMode(heightMeasureSpec);
    
        int width = mTextMaxWidth + mItemWidthSpace;
        int height = (mTextMaxHeight + mItemHeightSpace) * getVisibleItemCount();
    
        width += getPaddingLeft() + getPaddingRight();
        height += getPaddingTop() + getPaddingBottom();
        setMeasuredDimension(measureSize(specWidthMode, specWidthSize, width),
                measureSize(specHeightMode, specHeightSize, height));
    }
    
    private int measureSize(int specMode, int specSize, int size) {
        if (specMode == MeasureSpec.EXACTLY) {
            return specSize;
        } else {
            return Math.min(specSize, size);
        }
    }
复制代码

###滚轮的绘制 一般滚轮被选中的位置都是在中间,所以显示的设置为奇数比较合适。为了方便,定义mHalfVisibleItemCount作为显示的个数的一半,总显示个数为mHalfVisibleItemCount * 2 + 1,这样就可以保证选中的item在正中间。

首先最简单的,不考虑滚动的情况,直接绘制一列文字,这里为了方便计算,设置中间的为数据集的第0个,第一个item就为0-mHalfVisibleItemCount,最后一个则为mHalfVisibleItemCount,代码如下:

protected void onDraw(Canvas canvas) {
    for (int drawDataPos = -mHalfVisibleItemCount; drawDataPos <= mHalfVisibleItemCount; drawDataPos++) {
        if (pos < 0 || pos > mDataList.size() - 1) {
            continue;
        }
        int itemDrawY = mFirstItemDrawY + (drawDataPos + mHalfVisibleItemCount) * mItemHeight;
        T data = mDataList.get(pos);
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
    }
}
复制代码

接下来加入滚动,获取手指滑动的值,然后绘制的时候添加偏移量就好了。

手指滑动,这个应该都懂,这里就不废话了,直接上代码:

public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mLastDownY = (int) event.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            float move = event.getY() - mLastDownY;
            mScrollOffsetY += move;     //滑动的偏移量
            mLastDownY = (int) event.getY();
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
复制代码

更改onDraw()的代码这边有两个问题:

  • 上下两边要多绘制一个出来,因为在滚动的时候,实际在容器内的物品要比原定的项目要多一个。

  • 定位中间向位于数据集的位置,用偏移量/ item的高度即可。由于手指的坐标是以左上角为原点的,这里要注意坐标正负问题。 代码如下:

protected void onDraw(Canvas canvas) {
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        if (drawDataPos < 0 || drawDataPos > mDataList.size() - 1) {
            continue;
        }
        int itemDrawY = mFirstItemDrawY + (drawDataPos + mHalfVisibleItemCount) * mItemHeight + mScrollOffsetY;
        T data = mDataList.get(drawDataPos);
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
    }
}
复制代码

###弗林的效果 先上主要代码:

public boolean onTouchEvent(MotionEvent event) {
    if (mTracker == null) {
        mTracker = VelocityTracker.obtain();
    }
    mTracker.addMovement(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mTracker.clear();
            mTouchDownY = mLastDownY = (int) event.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            mTouchSlopFlag = false;
            float move = event.getY() - mLastDownY;
            mScrollOffsetY += move;
            mLastDownY = (int) event.getY();
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            mTracker.computeCurrentVelocity(1000, mMaximumVelocity);
            int velocity = (int) mTracker.getYVelocity();
            mScroller.fling(0, mScrollOffsetY, 0, velocity,
                    0, 0, mMinFlingY, mMaxFlingY);
            mScroller.setFinalY(mScroller.getFinalY() +
                    computeDistanceToEndPoint(mScroller.getFinalY() % mItemHeight));
            mHandler.post(mScrollerRunnable);
            mTracker.recycle();
            mTracker = null;
            break;
    }
    return true;
}

private int computeDistanceToEndPoint(int remainder) {
    if (Math.abs(remainder) > mItemHeight / 2) {
        if (mScrollOffsetY < 0) {
            return -mItemHeight - remainder;
        } else {
            return mItemHeight - remainder;
        }
    } else {
        return -remainder;
    }
}

private Runnable mScrollerRunnable = new Runnable() {
    @Override
    public void run() {
        if (mScroller.computeScrollOffset()) {
            int scrollerCurrY = mScroller.getCurrY();
            mScrollOffsetY = scrollerCurrY;
            postInvalidate();
            mHandler.postDelayed(this, 16);
        }
    }
}
复制代码

弗林的效果主要实现的英文用的VelocityTracker状语从句:Scroller,网上已经有很多资料了,这里就不再说明了。这里主要就是获取滚轮当前滚动的值,加入然后到偏移量mScrollOffsetY后请求重新绘制。

这里有一个finalY的修正计算当动画停止的时候,停止的位置如果随缘的话,就会经常在停在这种位置:

图片

因为要保证当滑动停止的时候,要保证项目正好在中间也就是这样:

图片

修正的方法就是:。滚动的值只能为项目高度的整数这样就能保证,滚动结束中间的项目只能在正中间利用这个原理,上边的手指滑动,也能在手指离开屏幕后来修正位置。

这里还要考虑一个问题,滚动的时候可能会超过给定的数据集的大小,就是当滚动的值超过最后一个数据后,当手指松开后返回到最后一个数据的位置,即下图的效果:

图片

方法还是在ACTION_UP的时候判断是否超过数据集的大小即可代码如下:

protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    ...
    mMinFlingY = - mItemHeight * (mDataList.size() - 1);
    mMaxFlingY = 0;
}


@Override
public boolean onTouchEvent(MotionEvent event) {
    ...
    
    case MotionEvent.ACTION_UP:
        mTracker.computeCurrentVelocity(1000, mMaximumVelocity);
        int velocity = (int) mTracker.getYVelocity();
        mScroller.fling(0, mScrollOffsetY, 0, velocity,
                0, 0, mMinFlingY, mMaxFlingY);
        mScroller.setFinalY(mScroller.getFinalY() +
                computeDistanceToEndPoint(mScroller.getFinalY() % mItemHeight));
                
        if (mScroller.getFinalY() > mMaxFlingY) {
            mScroller.setFinalY(mMaxFlingY);
        } else if (mScroller.getFinalY() < mMinFlingY) {
            mScroller.setFinalY(mMinFlingY);
        }
    
    ...
}
复制代码

###循环滚动 接下来要考虑循环滚动的问题。

首先上边的mMinFlingY和mMaxFlingY在循环滚动的时候就要设置成Integer的极限值,如下

mMinFlingY = mIsCyclic ? Integer.MIN_VALUE : - mItemHeight * (mDataList.size() - 1);
mMaxFlingY = mIsCyclic ? Integer.MAX_VALUE : 0;
复制代码

然后考虑绘制的时候取值问题,在上边的onDraw()方法的里面有一个安全值判断,如果超过数据集的大小就跳过此条项绘制,代码如下:

protected void onDraw(Canvas canvas) {
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        if (drawDataPos < 0 || drawDataPos > mDataList.size() - 1) {
            continue;
        }
        ...
    }
}
复制代码

我们要改动的就是这里。当循环滚动的时候上下滚动的极限都为无穷,所以- mScrollOffsetY / mItemHeight;得到的值应该也在正负无穷之间,我们要把值都映射到数据集中。 假设数据集有10条数据,当前滚动的位置为POS:

当pos> 10时,假设当pos = 10时,我们想让其回到第一个数据,对其取余即可:pos % 10。

当pos <0时,假设当pos = -1时,应该要展示最后一个数据,对其数据增加正数的修正为:10 + (pos % 10) 所以上边的代码就可以改变成:

protected void onDraw(Canvas canvas) {
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        int pos = drawDataPos;
        if  (mIsCyclic) {
            if (pos < 0) {
                pos = mDataList.size() + (pos % mDataList.size());
            }
            if (pos >= mDataList.size()){
                pos = pos % mDataList.size();
            }
        } else {
            if (drawDataPos < 0 || drawDataPos > mDataList.size() - 1) {
                continue;
            }
        }
        ...
    }
}
复制代码

###幕布 这个就比较简单了,在中间画一个矩形就好了,不过要在绘制滚轮之前绘制,否则会遮盖住文字,代码如下:

protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    ...
    mDrawnRect.set(getPaddingLeft(), getPaddingTop(),
        getWidth() - getPaddingRight(), getHeight() - getPaddingBottom());
    mSelectedItemRect.set(getPaddingLeft(), mItemHeight * mHalfVisibleItemCount,getWidth() - getPaddingRight(), mItemHeight + mItemHeight * mHalfVisibleItemCount);
}
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mPaint.setTextAlign(Paint.Align.CENTER);
    //是否绘制幕布
    if (mIsShowCurtain) {
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(mCurtainColor);
        canvas.drawRect(mSelectedItemRect, mPaint);
    }
    //是否绘制幕布边框
    if (mIsShowCurtainBorder) {
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setColor(mCurtainBorderColor);
        canvas.drawRect(mSelectedItemRect, mPaint);
        canvas.drawRect(mDrawnRect, mPaint);
    }
    ...
}
复制代码

字体颜色渐变

这个分三个部分:

1.透明度渐变:

涂料在绘制文字的时候可以设置阿尔法来设置透明度,阿尔法的比例计算方法:

如下图所示,计算“03”的比例:

图片

主要代码如下:

@Override
protected void onDraw(Canvas canvas) {
    ...
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        int pos = drawDataPos;
        if  (mIsCyclic) {
            if (pos < 0) {
                pos = mDataList.size() + (pos % 10);
            } else {
                pos = pos % mDataList.size();
            }
        } else {
            if (drawDataPos < 0 || drawDataPos > mDataList.size() - 1) {
                continue;
            }
        }
        
        T data = mDataList.get(pos);
        int itemDrawY = mFirstItemDrawY + (drawDataPos + mHalfVisibleItemCount) * mItemHeight + mScrollOffsetY;
        //距离中心的Y轴距离
        int distanceY = Math.abs(mCenterItemDrawnY - itemDrawY);
        
        float alphaRatio;
        if (itemDrawY > mCenterItemDrawnY) {
            alphaRatio = (mDrawnRect.height() - itemDrawY) /
                    (float) (mDrawnRect.height() - (mCenterItemDrawnY));
        } else {
            alphaRatio = itemDrawY / (float) mCenterItemDrawnY;
        }
        mPaint.setAlpha((int) (alphaRatio * 255));
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
        ...
    }
}
复制代码

###2.文字颜色渐变 当距离中心绘制点的距离小于一个ItemHeight时,进行文字颜色渐变。 首先需要一个线性颜色渐变的工具,思路就是指定一个开始颜色和结束颜色,传入比例获取颜色比较简单,直接看代码:

public class LinearGradient {

    private int mStartColor;
    private int mEndColor;
    private int mRedStart;
    private int mBlueStart;
    private int mGreenStart;
    private int mRedEnd;
    private int mBlueEnd;
    private int mGreenEnd;
    
    public LinearGradient(@ColorInt int startColor, @ColorInt int endColor) {
        mStartColor = startColor;
        mEndColor = endColor;
        updateColor();
    }
    
    
    public void setStartColor(@ColorInt int startColor) {
        mStartColor = startColor;
        updateColor();
    }
    
    public void setEndColor(@ColorInt int endColor) {
        mEndColor = endColor;
        updateColor();
    }
    
    private void updateColor() {
        mRedStart = Color.red(mStartColor);
        mBlueStart = Color.blue(mStartColor);
        mGreenStart = Color.green(mStartColor);
        mRedEnd = Color.red(mEndColor);
        mBlueEnd = Color.blue(mEndColor);
        mGreenEnd = Color.green(mEndColor);
    }
    
    public int getColor(float ratio) {
        int red = (int) (mRedStart + ((mRedEnd - mRedStart) * ratio + 0.5));
        int greed = (int) (mGreenStart + ((mGreenEnd - mGreenStart) * ratio + 0.5));
        int blue = (int) (mBlueStart + ((mBlueEnd - mBlueStart) * ratio + 0.5));
        return Color.rgb(red, greed, blue);
    }
}
复制代码

接下来就是计算文字颜色渐变了,和上边的计算透明度的类似,代码如下:

protected void onDraw(Canvas canvas) {
    ...
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        ...
        
        T data = mDataList.get(pos);
        int itemDrawY = mFirstItemDrawY + (drawDataPos + mHalfVisibleItemCount) * mItemHeight + mScrollOffsetY;
        //距离中心的Y轴距离
        int distanceY = Math.abs(mCenterItemDrawnY - itemDrawY);
        
        //计算文字颜色渐变
        //文字颜色渐变要在设置透明度上边,否则透明度会被覆盖
        if (distanceY < mItemHeight) {
            float colorRatio = 1 - (distanceY / (float) mItemHeight);
            mPaint.setColor(mLinearGradient.getColor(colorRatio));
        } else {
            mPaint.setColor(mTextColor);
        }
        
        //计算透明度渐变
        float alphaRatio;
        if (itemDrawY > mCenterItemDrawnY) {
            alphaRatio = (mDrawnRect.height() - itemDrawY) /
                    (float) (mDrawnRect.height() - (mCenterItemDrawnY));
        } else {
            alphaRatio = itemDrawY / (float) mCenterItemDrawnY;
        }
        mPaint.setAlpha((int) (alphaRatio * 255));
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
        ...
    }
}
复制代码

###3.文字大小渐变: 这个和文字颜色渐变实现思路一模一样,变化的项由文字的颜色变为大小也是在中心位置一个ItemHeigh的距离进行计算,直接上代码:

protected void onDraw(Canvas canvas) {
    ...
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        ...
        //计算透明度渐变
        float alphaRatio;
        if (itemDrawY > mCenterItemDrawnY) {
            alphaRatio = (mDrawnRect.height() - itemDrawY) /
                    (float) (mDrawnRect.height() - (mCenterItemDrawnY));
        } else {
            alphaRatio = itemDrawY / (float) mCenterItemDrawnY;
        }
        mPaint.setAlpha((int) (alphaRatio * 255));
        
        //靠近中心的Item字体放大
        if (distanceY < mItemHeight) {
            float addedSize = (mItemHeight - distanceY) / (float) mItemHeight * (mSelectedItemTextSize - mTextSize);
            mPaint.setTextSize(mTextSize + addedSize);
        } else {
            mPaint.setTextSize(mTextSize);
        }
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
        ...
    }
}
复制代码

###指示器文字实现 指示器文字就是直接在中间的项目后边绘制一个文字,只需要计算一下要绘制的坐标即可,在直接onDraw()方法的的求最后加入就好了代码如下:

protected void onDraw(Canvas canvas) {
    ...
    mPaint.setTextAlign(Paint.Align.CENTER);
    int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
    for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
        ...
        canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
    }
    
    mPaint.setTextAlign(Paint.Align.LEFT);
    canvas.drawText(mIndicatorText, mFirstItemDrawX + mTextMaxWidth / 2, mCenterItemDrawnY, mPaint);
}
复制代码

###小结 到此,一个滚轮选择器主要功能就已现现了。接下来只要完善一些细节部分比如监听器,点击效果等部分,就完成了。接下来根据需要利用滚轮选择器就能很轻松的实现日期选择器,省市选择器等控件。

详细代码请加群获取:### 有兴趣的朋友可以留言讨论或者加群交流技术【Android技术交流】:jq.qq.com/?_wv=1027&a…。进群有免费领取安卓进阶学习视频,源码,面试资料,群内有大牛一起交流讨论技术;点击链接加入群聊(包括java基础与原理,自定义控件、NDK、架构设计、混合式开发(Flutter,Weex)、性能优化、完整商业项目开发等)

你可能感兴趣的:(Android 滚轮选择器的实现详解)