简介
最近用一个日期选择控件,感觉官方的DatePicker操作有点复杂,而且不同的Android版本样式也都不一样。后来发现小米日历的日期选择控件蛮好看的,于是自己尝试仿写一个,感觉效果还不错。GitHub: https://github.com/ycuwq/DatePicker
效果图:
预览1
预览2
功能分析
- 滚轮:首先绘制一列文本,然后添加一个偏移量,在onDraw中根据手指滑动,改变偏移量并重新绘制这一列文本,这样就实现了滑动的效果。
- Fling:这个应该很常见了,用
VelocityTracker
和Scroller
来实现。 - 循环滚动:当滚动超过数据集的大小后,从头继续获取数据即可。
- 幕布效果:在中心区域绘制一个矩形。
- 字体颜色渐变:
- 从中心到两边,逐渐将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()
的代码。这边有两个问题:
- 上下两边要多绘制一个出来,因为在滚动的时候,实际在容器内的item要比原定的item要多一个。
- 定位中间向位于数据集的位置,用偏移量 / 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效果的实现主要是用的VelocityTracker
和Scroller
,网上已经有很多资料了,这里就不再说明了。这里主要就是获取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);
}
...
}
循环滚动
接下来要考虑循环滚动的问题。
首先 上边的mMinFlingY
和mMaxFlingY
在循环滚动的时候就要设置成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中提出您的问题或意见。
网友评论