簡(jiǎn)介
最近用一個(gè)日期選擇控件裤园,感覺官方的DatePicker操作有點(diǎn)復(fù)雜,而且不同的Android版本樣式也都不一樣。后來發(fā)現(xiàn)小米日歷的日期選擇控件蠻好看的力喷,于是自己嘗試仿寫一個(gè)足删,感覺效果還不錯(cuò)。GitHub: https://github.com/ycuwq/DatePicker
效果圖:
功能分析
- 滾輪:首先繪制一列文本匣距,然后添加一個(gè)偏移量,在onDraw中根據(jù)手指滑動(dòng)哎壳,改變偏移量并重新繪制這一列文本董栽,這樣就實(shí)現(xiàn)了滑動(dòng)的效果嗤谚。
- Fling:這個(gè)應(yīng)該很常見了,用
VelocityTracker
和Scroller
來實(shí)現(xiàn)从媚。 - 循環(huán)滾動(dòng):當(dāng)滾動(dòng)超過數(shù)據(jù)集的大小后歹撒,從頭繼續(xù)獲取數(shù)據(jù)即可潘鲫。
- 幕布效果:在中心區(qū)域繪制一個(gè)矩形凡纳。
- 字體顏色漸變:
- 從中心到兩邊疏虫,逐漸將Paint的透明度變小。
- 從中心相鄰項(xiàng)到中心特石,字體顏色漸變盅蝗。
- 中心選項(xiàng)文字變大: 從中心相鄰項(xiàng)到中心,字體大小漸變姆蘸。
- 指示器文字风科,在中間的Item后邊繪制一個(gè)文字。
到這里乞旦,所有的功能點(diǎn)的思路大概就清晰了。
實(shí)現(xiàn)方法
測(cè)量控件大小
這里主要是測(cè)量wrap_content模式的大小题山。
首先兰粉,要確定單個(gè)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);
}
然后確定布局的大小玖姑,布局的寬度就等于測(cè)量的mTextMaxWidth,高度為測(cè)量的mTextMaxHeight * itemCount
慨菱。這里寬高中可以加入一個(gè)額外的Space焰络,要不然文字就會(huì)擠到一起,比較難看符喝。
@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);
}
}
滾輪的繪制
一般滾輪被選中的位置都是在中間闪彼,所以顯示的設(shè)置為奇數(shù)比較合適。為了方便,定義mHalfVisibleItemCount
作為顯示的個(gè)數(shù)的一半畏腕,總顯示個(gè)數(shù)為mHalfVisibleItemCount * 2 + 1
缴川,這樣就可以保證選中的item在正中間。
首先最簡(jiǎn)單的描馅,不考慮滾動(dòng)的情況把夸,直接繪制一列文字,這里為了方便計(jì)算铭污,設(shè)置中間的為數(shù)據(jù)集的第0個(gè)恋日,第一個(gè)item就為0-mHalfVisibleItemCount
,最后一個(gè)則為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);
}
}
接下來加入滾動(dòng)岂膳,獲取手指滑動(dòng)的值, 然后繪制的時(shí)候添加偏移量就好了刁绒。
手指滑動(dòng)闷营,這個(gè)應(yīng)該都懂,這里就不廢話了知市,直接上代碼:
@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; //滑動(dòng)的偏移量
mLastDownY = (int) event.getY();
invalidate();
break;
case MotionEvent.ACTION_UP:
break;
}
更改onDraw()
的代碼傻盟。這邊有兩個(gè)問題:
- 上下兩邊要多繪制一個(gè)出來,因?yàn)樵跐L動(dòng)的時(shí)候嫂丙,實(shí)際在容器內(nèi)的item要比原定的item要多一個(gè)娘赴。
- 定位中間向位于數(shù)據(jù)集的位置,用偏移量 / item的高度即可跟啤。由于手指的坐標(biāo)是以左上角為原點(diǎn)的诽表,這里要注意坐標(biāo)正負(fù)問題。
代碼如下:
@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效果的實(shí)現(xiàn)主要是用的VelocityTracker
和Scroller
隅肥,網(wǎng)上已經(jīng)有很多資料了竿奏,這里就不再說明了。這里主要就是獲取Scroller當(dāng)前滾動(dòng)的值腥放,然后加入到偏移量mScrollOffsetY
后請(qǐng)求重新繪制泛啸。
這里有一個(gè)finalY的修正計(jì)算.當(dāng)動(dòng)畫停止的時(shí)候,停止的位置如果隨緣的話秃症,就會(huì)經(jīng)常在停在這種位置:
因?yàn)橐WC當(dāng)滑動(dòng)停止的時(shí)候候址,要保證item正好在中間。也就是這樣:
修正的方法就是:滾動(dòng)的值只能為item高度的整數(shù)种柑。這樣就能保證岗仑,滾動(dòng)結(jié)束中間的item只能在正中間。利用這個(gè)原理聚请,上邊的手指滑動(dòng)荠雕,也能在手指離開屏幕后來修正位置。
這里還要考慮一個(gè)問題,滾動(dòng)的時(shí)候可能會(huì)超過給定的數(shù)據(jù)集的大小舞虱,就是當(dāng)滾動(dòng)的值超過最后一個(gè)數(shù)據(jù)后欢际,當(dāng)手指松開后返回到最后一個(gè)數(shù)據(jù)的位置,即下圖的效果:
方法還是在ACTION_UP的時(shí)候判斷是否超過數(shù)據(jù)集的大小即可矾兜。代碼如下:
@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);
}
...
}
循環(huán)滾動(dòng)
接下來要考慮循環(huán)滾動(dòng)的問題损趋。
首先 上邊的mMinFlingY
和mMaxFlingY
在循環(huán)滾動(dòng)的時(shí)候就要設(shè)置成Integer的極限值,如下
mMinFlingY = mIsCyclic ? Integer.MIN_VALUE : - mItemHeight * (mDataList.size() - 1);
mMaxFlingY = mIsCyclic ? Integer.MAX_VALUE : 0;
然后考慮繪制的時(shí)候取值問題椅寺,在上邊onDraw()方法的里面有一個(gè)安全值判斷浑槽,如果超過數(shù)據(jù)集的大小就跳過此條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;
}
...
}
}
我們要改動(dòng)的就是這里返帕。當(dāng)循環(huán)滾動(dòng)的時(shí)候上下滾動(dòng)的極限都為無窮桐玻,所以- mScrollOffsetY / mItemHeight;
得到的值應(yīng)該也在正負(fù)無窮之間,我們要把值都映射到數(shù)據(jù)集中荆萤。
假設(shè)數(shù)據(jù)集有10條數(shù)據(jù)镊靴,當(dāng)前滾動(dòng)的位置為pos:
- 當(dāng)pos>10時(shí),假設(shè)當(dāng)pos = 10時(shí)链韭,我們想讓其回到第一個(gè)數(shù)據(jù)偏竟,對(duì)其取余即可:
pos % 10
。 - 當(dāng)pos<0時(shí)敞峭,假設(shè)當(dāng)pos = -1時(shí)踊谋,應(yīng)該要展示最后一個(gè)數(shù)據(jù),對(duì)其數(shù)據(jù)增加正數(shù)的修正為:
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;
}
}
...
}
}
幕布
這個(gè)就比較簡(jiǎn)單了旋讹,在中間畫一個(gè)矩形就好了,不過要在繪制滾輪之前繪制殖蚕,否則會(huì)遮蓋住文字,代碼如下:
@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);
}
...
}
字體顏色漸變
這個(gè)分三個(gè)部分:
1. 透明度漸變:
Paint在繪制文字的時(shí)候可以設(shè)置Alpha來設(shè)置透明度沉迹,Alpha的比例計(jì)算方法:
$$ \frac{繪制點(diǎn)到端點(diǎn)距離}{中心繪制點(diǎn)到端點(diǎn)距離} $$
如下圖所示睦疫,計(jì)算“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. 文字顏色漸變
當(dāng)距離中心繪制點(diǎn)的距離小于一個(gè)ItemHeight時(shí),進(jìn)行文字顏色漸變鞭呕。
首先需要一個(gè)線性顏色漸變的工具笼痛,思路就是指定一個(gè)開始顏色和結(jié)束顏色,傳入比例獲取顏色琅拌。比較簡(jiǎn)單,直接看代碼:
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);
}
}
接下來就是計(jì)算文字顏色漸變了摘刑,和上邊的計(jì)算透明度的類似进宝,代碼如下:
@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);
//計(jì)算文字顏色漸變
//文字顏色漸變要在設(shè)置透明度上邊,否則透明度會(huì)被覆蓋
if (distanceY < mItemHeight) {
float colorRatio = 1 - (distanceY / (float) mItemHeight);
mPaint.setColor(mLinearGradient.getColor(colorRatio));
} else {
mPaint.setColor(mTextColor);
}
//計(jì)算透明度漸變
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.文字大小漸變:
這個(gè)和文字顏色漸變實(shí)現(xiàn)思路一模一樣枷恕,變化的項(xiàng)由文字的顏色變?yōu)榇笮〉辰R彩窃谥行奈恢靡粋€(gè)ItemHeigh的距離進(jìn)行計(jì)算,直接上代碼:
@Override
protected void onDraw(Canvas canvas) {
...
int drawnSelectedPos = - mScrollOffsetY / mItemHeight;
for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
...
//計(jì)算透明度漸變
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);
...
}
}
指示器文字實(shí)現(xiàn)
指示器文字就是直接在中間的item后邊繪制一個(gè)文字,只需要計(jì)算一下要繪制的坐標(biāo)即可未玻,直接在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);
}
小結(jié)
到此, 一個(gè)滾輪選擇器主要功能就實(shí)現(xiàn)了扳剿。接下來只要完善一些細(xì)節(jié)部分比如監(jiān)聽器旁趟、點(diǎn)擊效果等部分,就完成了庇绽。接下來根據(jù)需要利用滾輪選擇器就能很輕松的實(shí)現(xiàn)日期選擇器锡搜,省市選擇器等控件。
詳細(xì)代碼請(qǐng)移步我的GitHub: https://github.com/ycuwq/DatePicker瞧掺。
里邊還封裝了一個(gè)DatePicker和DatePicker從下方彈出的Dialog耕餐,使用方法請(qǐng)移步GitHub。
最后辟狈,非常歡迎到GitHub中提出您的問題或意見肠缔。