美文网首页Android自定义控件AndroidAndroid开发
Android 滚轮选择器的实现详解

Android 滚轮选择器的实现详解

作者: smile_ce | 来源:发表于2018-01-19 16:19 被阅读943次

    简介

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

    效果图:


    预览1
    预览2

    功能分析

    • 滚轮:首先绘制一列文本,然后添加一个偏移量,在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);
            }
        }
    

    滚轮的绘制

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

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

    @Override
    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);
        }
    }
    

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

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

    @Override
    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()的代码。这边有两个问题:

    1. 上下两边要多绘制一个出来,因为在滚动的时候,实际在容器内的item要比原定的item要多一个。
    2. 定位中间向位于数据集的位置,用偏移量 / item的高度即可。由于手指的坐标是以左上角为原点的,这里要注意坐标正负问题。
      代码如下:
    @Override
    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);
        }
    }
    

    Fling的效果

    先上主要代码:

    @Override
    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);
            }
        }
    }
    

    Fling效果的实现主要是用的VelocityTrackerScroller,网上已经有很多资料了,这里就不再说明了。这里主要就是获取Scroller当前滚动的值,然后加入到偏移量mScrollOffsetY后请求重新绘制。

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


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


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

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


    161098a4ab2fdbe1.gif

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

    @Override
    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);
            }
        
        ...
    }
    

    循环滚动

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

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

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

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

    @Override
    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)
      所以上边的代码就可以改变成:
    @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 % mDataList.size());
                }
                if (pos >= mDataList.size()){
                    pos = pos % mDataList.size();
                }
            } else {
                if (drawDataPos < 0 || drawDataPos > mDataList.size() - 1) {
                    continue;
                }
            }
            ...
        }
    }
    

    幕布

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

    @Override
    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. 透明度渐变:

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

    $$ \frac{绘制点到端点距离}{中心绘制点到端点距离} $$

    如下图所示,计算“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);
        }
    }
    

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

    @Override
    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的距离进行计算,直接上代码:

    @Override
    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);
            ...
        }
    }
    

    指示器文字实现

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

    @Override
    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);
    }
    

    小结

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

    详细代码请移步我的GitHub: https://github.com/ycuwq/DatePicker
    里边还封装了一个DatePicker和DatePicker从下方弹出的Dialog,使用方法请移步GitHub。

    最后,非常欢迎到GitHub中提出您的问题或意见。

    相关文章

      网友评论

      • KingZd:楼主 我在你代码的基础上修改了加入到我的项目 组件我上传到github 注明里面使用了你的可以吗
      • leilifengxingmw:文章写得相当好。去克隆代码好好研究研究:blush:
        smile_ce:@humanheima 感谢支持

      本文标题:Android 滚轮选择器的实现详解

      本文链接:https://www.haomeiwen.com/subject/kfepxxtx.html