先看一个效果图:
1、
2、
首先我们来分析一下,这个自定义控件是由几部分组成的?最直观的来说就是上下结构,上边一个TextView用来显示用户选择好的日期,下边是滚轮形式的年、月、日三纵列结构,其中最中间的年月日是默认选择的日期用一个矩形来表示,结构大概就是这样,那么很显然我们在自定义这个viewGroup的时候也是按照这个结构划分来实现
所以我们应该将年月日分成三部分来做,然后拼成一个布局,那么我们应该也可以发现一个问题就是年月日这三部分除了数字不同之外,其余部分不论是滑动效果也好,字体颜色变化也好,字号大小变化也好,是完全相同的,那么根据Java的继承特性,我们可以提取出来父类,在子类中使用对应的年月日数字来赋值,完成这个日期选择器的自定义,so我们可以造wheelPicker基类,让YearPicker、MonthPicker、DayPicker去继承基类,然后将这三个CustomerView拼接在一起
那么首先就是写出wheelPicker基类
我们对照着效果图一,首先来自定义这个控件,自定义View的步骤也可以回忆一下
- 生成三个构造方法
- 在构造方法中初始化一些参数
- 然后就是measure,layout,onDraw这些步骤
这里着重回忆attribute属性的用法:
- 在res->values->attrs下的文件下创建<declare-styleable><attrs name = "" format = "integer"/></declare-styleable>
- 在构造方法中使用context.obtainStyledAttributes(attrs,R.styleable.xxx),拿到对应的属性值
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WheelPicker);
mTextSize = a.getDimensionPixelSize(R.styleable.WheelPicker_itemTextSize,
getResources().getDimensionPixelSize(R.dimen.WheelItemTextSize));
mTextColor = a.getColor(R.styleable.WheelPicker_itemTextColor,
Color.BLACK);
初始化画笔:
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setTextAlign(Paint.Align.CENTER);
mPaint.setColor(mTextColor);
mPaint.setTextSize(mSelectedItemTextSize);
setMeasureDismension方法
@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));
}
然后在onSizeChangeed()方法中,设置整个展示View的矩形控件Rect矩形绘制,
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
/**
* 设置整个控件的绘制矩形,如何确定这个控件的宽高,画矩形是传递 l,t,r,b四个参数
* 经过测试我发现我们在布局文件中设置的margin值不会对自定义view产生任何影响,
* 想要的间距它不管你是不是自定义的,都可以完美展示,而当我们设定Padding值的时候,
* 如果我们设置Rect.set(0,0,getWidth(),getHeight())view会显示的特别丑,所以在绘制矩形的时候
* 特意减去padding值,这样的话下列的值就是:
* l = getPaddingLeft()
* t = getPaddingTop()
* r = getWidth() - getPaddingRight()
* b = getHeight() - getPaddingBottom()
* */
mDrawnRect.set(getPaddingLeft(), getPaddingTop()
, getWidth() - getPaddingRight(), getHeight() - getPaddingBottom());
/**
* 我们要精确的知道每一个Item高度是多少,这个参数在后边用的非常多,那么应该怎么来计算?
* 很简单,刚刚我们画出了自定义的View矩形控件,那么它必然是有一个确定的高度的,view.Heigth()
* 然后,我们这个矩形控件在屏幕可见区域准备要展示多少条Item
* 例如,屏幕可见区域总共10cm,要显示5条数据
* 显然,每一个Item高度 = 10/5 = 2cm
* 这里也是一样的,屏幕可见区域矩形的高度 view.Height我们拿到了
* 下面来计算要显示几条数据,在res-attrs文件夹中,我们声明了自定义属性,其中有一条halfVisibleItemCount,
* 翻译过来就是可见Item的一半,很显然,总数就是 halfVisibleItemCount * 2 + 1 ,为什么要加 1 ?
* 因为这个halfVisibleItemCount说的是可见屏幕从中间砍一半的条数,中间正好中刀的那一条也要加上的
* */
mItemHeight = mDrawnRect.height() / getVisibilityItemCount();//整个控件中每个Item矩形的高度
//这俩应该是 mDrawnRect 矩形的 对角线中心点
// mPaint.ascent()文字top方向边缘 mPaint.descent()文字bottom方向边缘
mFirstItemDrawX = mDrawnRect.centerX();
mFirstItemDrawY = (int) ((mItemHeight - (mPaint.ascent() + mPaint.descent())) / 2);
/**
* 这是选中的矩形区域,也是传入 l,t,r,b四个参数
* l 和 r 与整体的矩形一样的逻辑
* 需要注意的就是默认选中的矩形 t 和 b 这个参数传什么?
* 很简单,我们这里说的默认选中Item就是mDrawnRect矩形中从可见屏幕中间砍一半正好砍中
* 的屏幕最中间的Item,那么,这个矩形距离最上边 top 是多少,就是halfVisibleItemCount * mItemHeight的值
* so:t = halfVisibleItemCount * mItemHeight的值,
* b 的值是多少? 它就是比 t 多了一个Item
* 所以 b = (halfVisibleItemCount + 1) * mItemHeight
* */
mSelectedItemRect.set(getPaddingLeft(), mItemHeight * mHalfVisibleItemCount
, getWidth() - getPaddingRight(), mItemHeight * (mHalfVisibleItemCount + 1));
//这个没什么 如果设定循环滚动,就重置一下Item的position
computeFlingLimitY();
mCenterItemDrawnY = mFirstItemDrawY + mItemHeight * mHalfVisibleItemCount;
//滑动了多少Y轴的距离
mScrollerOffsetY = -mItemHeight * mCurrentItemPosition;
}
onDraw()--
接下来就是在画布上画这些控件,画好之后就可以在屏幕上展示了,只是我们现在还没有处理Touch事件,所以它是不能滑动日期的
@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);
}
//当前滑动了几个矩形控件
int drawnSelectedPos = -mScrollerOffsetY / mItemHeight;
mPaint.setStyle(Paint.Style.FILL);
//在最大和最小之间留一个缓冲,多绘制一个矩形控件
for (int drawDataPos = drawnSelectedPos - mHalfVisibleItemCount - 1;
drawDataPos <= drawnSelectedPos + mHalfVisibleItemCount + 1; drawDataPos++) {
int position = drawDataPos;
if (mIsCyclic) {
position = fixItemPosition(position);
} else {
if (position < 0 || position > mDataList.size() - 1) {
continue;
}
}
//在中间位置的Item作为被选中的条目
if (drawnSelectedPos == drawDataPos) {
mPaint.setColor(mSelectedItemTextColor);
} else {
mPaint.setColor(mTextColor);
}
T data = mDataList.get(position);
//当前Item距离坐标原点的distance
int itemDrawY = mFirstItemDrawY + (drawDataPos + mHalfVisibleItemCount) * mItemHeight + mScrollerOffsetY;
//当前Item中心点距离坐标原点的distance
int distanceY = Math.abs(mCenterItemDrawnY - itemDrawY);
if (mIsTextGradual) {
//计算文字颜色渐变
//文字颜色渐变要设置在透明度上边,否则会被覆盖
if (distanceY < mItemHeight) {
float colorRatio = 1 - (distanceY / (float) mItemHeight);
mPaint.setColor(mLinearGradint.getColor(colorRatio));
} else {
mPaint.setColor(mTextColor);
}
//透明度
float alphaRatio;
if (itemDrawY > mCenterItemDrawnY) {
alphaRatio = (mDrawnRect.height() - itemDrawY) /
(float) (mDrawnRect.height() - mCenterItemDrawnY);
} else {
alphaRatio = itemDrawY / (float) mCenterItemDrawnY;
}
alphaRatio = alphaRatio < 0 ? 0 : alphaRatio;
mPaint.setAlpha((int) (alphaRatio * 255));
} else {
mPaint.setAlpha(255);
mPaint.setColor(mSelectedItemTextColor);
}
//开启此选项,会将越靠近中心的Item字体放大
if (mIsZoomInSelectedItem) {
if (distanceY < mItemHeight) {
float addedSize = (mItemHeight - distanceY) / (float) (mItemHeight * (mSelectedItemTextSize - mTextSize));
mPaint.setTextSize(addedSize + mTextSize);
} else {
mPaint.setTextSize(mTextSize);
}
} else {
mPaint.setTextSize(mTextSize);
}
if (mDataFormat != null) {
canvas.drawText(mDataFormat.format(data), mFirstItemDrawX, itemDrawY, mPaint);
} else {
canvas.drawText(data.toString(), mFirstItemDrawX, itemDrawY, mPaint);
}
}
}
接下来我们就处理Touch事件
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mTracker == null) {
mTracker = VelocityTracker.obtain();
}
mTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
/**
* 按下这个touch事件,我们需要分析当前的状态
* 1、当前view正在高速滑动中,
* 2、当前view在静止状态
* */
if (!mScroller.isFinished()) {
//当前view正在高速滑动中,
mScroller.abortAnimation();
mIsAbortScroller = true;
} else {
//当前view在静止状态,点击了一下Item
mIsAbortScroller = false;
}
mTracker.clear();
mTouchDownY = mLastDownY = (int) event.getY();
mTouchSlopFlag = true;
break;
case MotionEvent.ACTION_MOVE:
/**
* 在第一次move,首次按下的时候,滑动的距离要大于View规定的常量才算是滑动,
* 但是这样的判断会出现一个问题,我们会来回的拖动view,上下反复的拖动,
* 如果只使用距离作为唯一的判断依据,这种情况下就无法展示出来,
* 所以我们加一个flag,当我们在move的时候保证逻辑程序不会走进这个判断(即使滑动距离小于view常量)
* */
if (mTouchSlopFlag && Math.abs(mTouchDownY - event.getY()) < mTouchSlop) {
break;
}
mTouchSlopFlag = false;
//移动的距离,终点 - 起点 Y轴坐标
float move = event.getY() - mLastDownY;
//实时更新Y轴滑动的距离
mScrollerOffsetY += move;
mLastDownY = (int) event.getY();
invalidate();
break;
case MotionEvent.ACTION_UP:
/**
* 判断view是在高速滑动还是静止状态,并且对比收拖动的最后距离与Down时间中的滑动位置
* 以此来判断当前view滚动的状态,是滚动还是静止时拖动了几个单位的Item距离
* */
if (!mIsAbortScroller && mTouchDownY == mLastDownY) {
//当前view是静止状态,用户点击了Item来选择数据
//模拟手指点击view控件
performClick();
//判断点击的Item是在默认选中Item的上方还是下方
if (event.getY() > mSelectedItemRect.bottom) {
//点击是默认选中Item的下方,这时view的状态应该是向上滑
//判断点击的Item距离默认Item的Y轴值,所以在sctoller.startScroll方法中传的参数dy
//是负数
int scrollItem = (int) ((event.getY() - mSelectedItemRect.bottom) / mItemHeight + 1);
//因为是向上滑的状态,也就是说view的Y轴是从大变小的状态,
mScroller.startScroll(0, mScrollerOffsetY, 0, -scrollItem * mItemHeight);
} else if (event.getY() < mSelectedItemRect.top) {
//在这个判断的范围内,点击的是默认选中Item的上方,这时view的状态应当是向下滑,
//也就是说这时候view的Y轴是从小变大的过程,所以在sctoller.startScroll方法中传的参数dy
//是正数
int scrollItem = (int) ((mSelectedItemRect.top - event.getY()) / mItemHeight + 1);
mScroller.startScroll(0, mScrollerOffsetY, 0, scrollItem * mItemHeight);
}
} else {
/**
* 当前view是高速滑动状态,我们要去判断,
* 当前滑动的速度(使用VelocityTracker获取到的真实世界手指滑屏物理速度)有没有我们设定的最小速度值低
* 如果比手动设定最小值还低,说明这不是滑动,是用户在点击某一些Item来切换数据源
* 如果比它大,说明是高速滑动,走下一步:
* */
mTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int yVelocity = (int) mTracker.getYVelocity();
if (Math.abs(yVelocity) > mMinimumVelocity) {
/**
* 如果比它大,说明是高速滑动,走下一步:
* 在这个时候如果不做任何处理,在用力滑动Item的时候,
* 数据并不会随着手指的快速滑动而飞速切换,而是如同界面卡死一样一点一点的移动,
* 这样的用户体验非常差,在这里我们使用velocityTracker类拿到物理屏幕上滑动的速度,
* 传入scroller类中的fling方法,数据就可以飞速的滑动,
* */
mScroller.fling(0, mScrollerOffsetY, 0, yVelocity,
0, 0, mMinFlingY, mMaxFlingY);
//光使用scroller.fling方法 可以正常滑动view实现功能,
//但是我们很可能将中间默认选中的item位置滑动成俩个数据源的中间,例如年份1908和1909的中间,
//不属于任何一个年份,所以我们要去判断,出现上述问题的时候通过计算返回对应的view滑动distance
//所以这里应当使用Scroller.setFinalY方法,将最终位置定死在对应的数据Item上
mScroller.setFinalY(mScroller.getFinalY() + computeDistanceToEndPoint(mScroller.getFinalY() % mItemHeight));
} else {
/**
* 如果比手动设定最小值还低,说明这不是滑动,是用户在点击某一些Item来切换数据源
* 这样就很简单了,Scroller.startScroll方法滑动就好,在传入dy参数的时候,同样参考Scroll滑动的规则
* 中间默认选中的item位置滑动成俩个数据源的中间,例如年份1908和1909的中间,
* 不属于任何一个年份,所以我们要去判断,出现上述问题的时候通过计算返回对应的view滑动distance
* */
mScroller.startScroll(0, mScrollerOffsetY, 0, computeDistanceToEndPoint(mScrollerOffsetY % mItemHeight));
}
}
mHandler.post(mScrollerRunnable);
mTracker.recycle();
mTracker = null;
break;
}
return true;
}
这样之后呢,滑动是可以滑动了,但是当用户不滑动,点击屏幕上可见的条目的时候,就不能滑动了,所以我们要另外去处理,就是在Handler中去执行这个消息了
private Runnable mScrollerRunnable = new Runnable() {
@Override
public void run() {
if (mScroller.computeScrollOffset()) {
mScrollerOffsetY = mScroller.getCurrY();
invalidate();
mHandler.postDelayed(this, 16);
}
if (mScroller.isFinished()) {
if (onWheelChangeListener == null) {
return;
}
if (mItemHeight == 0) {
return;
}
//计算位置
int position = -mScrollerOffsetY / mItemHeight;
position = fixItemPosition(position);//如果是循环的话修正position值
Log.d("11111OffsetY", -mScrollerOffsetY + "");
Log.d("111mItemHeight", mItemHeight + "");
Log.d("11111mmPosition", mCurrentItemPosition + "");
Log.d("11111position", position + "");
if (mCurrentItemPosition != position) {
mCurrentItemPosition = position;
onWheelChangeListener.onWheelSelected(mDataList.get(position), position);
}
}
}
};
这样一来,用一个类去继承这个wheel,设置好数据,就可以使用日期滚轮了,这个实现原理感觉就是系统ListView控件的实现原理