关于三级联动源码
项目中前几天用到了仿iOS的控件,因为这种控件自己写比较耗时,而项目比较急,所以就从github找了一个这个控件,用着还不错,所以就研究了下源码实现,做出了自己的个人总结。 排版较乱,看官看不懂的话可留言。源码中其实已经有很多注释了,大家可以直接到源码中查看。
先奉上原作者的github地址:
三级联动仿iOS
源码中用到的WheelView
参考的WheelView
首先,从BasePickerView这个类开始,此类是条件选择器跟时间选择器的父类,其中主要定义里初始化view以及动画的操作,当点击将时间控件show出来的时候,只需要将自定义好的布局添加到DecorView中并执行动画操作即可,逻辑比较简单。
protected void initViews() {
LayoutInflater layoutInflater = LayoutInflater.from(context);
decorView = (ViewGroup) ((Activity) context).getWindow().getDecorView().findViewById(android.R.id.content);
rootView = (ViewGroup) layoutInflater.inflate(R.layout.layout_basepickerview, decorView, false);
rootView.setLayoutParams(new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
));
contentContainer = (ViewGroup) rootView.findViewById(R.id.content_container);
contentContainer.setLayoutParams(params);
}
protected void init() {
inAnim = getInAnimation();
outAnim = getOutAnimation();
}
protected void initEvents() {
}
private void onAttached(View view) {
decorView.addView(view);
contentContainer.startAnimation(inAnim);
}
public void show() {
// 省略部分代码
if (isShowing()) {
return;
}
isShowing = true;
onAttached(rootView);
rootView.requestFocus();
}
initViews()与init()方法均在子类中调用,拿TimePickerView时间选择器来说,会在初始化时依次调用initViews()跟init()方法(其中初始化参数较多,用到了建造者模式),并将自定义的布局挂载到BasePickerView中的contentContainer中,其布局中使用到了WheelView,接下来继续分析WheelView。
WheelView继承View,是一个3d轮滚控件,我们从构造方法入手, 其中主要做的工作就是初始化自定义属性,行间距的判断,初始化handler,手势识别器,画笔等。
public WheelView(Context context, AttributeSet attrs) {
super(context, attrs);
textSize = getResources().getDimensionPixelSize(R.dimen.pickerview_textsize);//默认大小
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.pickerview, 0, 0);
mGravity = a.getInt(R.styleable.pickerview_pickerview_gravity, Gravity.CENTER);
textColorOut = a.getColor(R.styleable.pickerview_pickerview_textColorOut, textColorOut);
textColorCenter = a.getColor(R.styleable.pickerview_pickerview_textColorCenter, textColorCenter);
dividerColor = a.getColor(R.styleable.pickerview_pickerview_dividerColor, dividerColor);
textSize = a.getDimensionPixelOffset(R.styleable.pickerview_pickerview_textSize, textSize);
lineSpacingMultiplier = a.getFloat(R.styleable.pickerview_pickerview_lineSpacingMultiplier, lineSpacingMultiplier);
a.recycle();//回收内存
}
judgeLineSpae();
initLoopView(context);
}
/**
* 判断间距是否在1.0-2.0之间
*/
private void judgeLineSpae() {
if (lineSpacingMultiplier < 1.2f) {
lineSpacingMultiplier = 1.2f;
} else if (lineSpacingMultiplier > 2.0f) {
lineSpacingMultiplier = 2.0f;
}
}
private void initLoopView(Context context) {
this.context = context;
handler = new MessageHandler(this);
gestureDetector = new GestureDetector(context, new LoopViewGestureListener(this));
gestureDetector.setIsLongpressEnabled(false);
isLoop = true;
totalScrollY = 0;
initPosition = -1;
initPaints();
}
接下来继续分析onMeasure(),在这个方法里,主要是确定控件的measureHeight,因为整个控件是3d滚动的效果,所以计算得到最大的文字高度,并最大文字高度乘以当前可见的文字行数,得到一个半圆的周长,其实整个控件也可以理解为是一个圆柱体,然后再通过换算得到圆柱的直径,那么这个直径就作为当前控件的measureHeight,同时也确定了两条横线和控件中间点的位置。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
this.widthMeasureSpec = widthMeasureSpec;
remeasure();
setMeasuredDimension(measuredWidth, measuredHeight);
}
private void remeasure() {
if (adapter == null) {
return;
}
measureTextWidthHeight(); //该逻辑中主要获取文字的最大高度(
//最大Text的高度乘间距倍数得到 可见文字实际的总高度,半圆的周长
halfCircumference = (int) (itemHeight * (itemsVisible - 1));
//整个圆的周长除以PI得到直径,这个直径用作控件的总高度
measuredHeight = (int) ((halfCircumference * 2) / Math.PI);
//求出半径
radius = (int) (halfCircumference / Math.PI);
//控件宽度,这里支持weight
measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
//计算两条横线和控件中间点的Y位置
firstLineY = (measuredHeight - itemHeight) / 2.0F;
secondLineY = (measuredHeight + itemHeight) / 2.0F;
centerY = (measuredHeight + maxTextHeight) / 2.0F - (itemHeight-maxTextHeight)/4.0f;
//初始化显示的item的position,根据是否loop
if (initPosition == -1) {
if (isLoop) {
initPosition = (adapter.getItemsCount() + 1) / 2;
} else {
initPosition = 0;
}
}
preCurrentIndex = initPosition;
}
继续分析onDraw(),先上图,首先,整个控件可以看成是一个只显示一半的圆,而这个半圆的周长即为所有可见文字的高度之和,假如当前可见条目itemsVisible设置为9,那么半圆的长度即为halfCircumference = (int) (itemHeight * (itemsVisible - 1))
每个条目的高度乘以可见数减1,可理解为如图的分割;然后通过Y轴上滚动的距离,得到滚动的条目数,并计算出实际预选中的位置,并以preCurrentIndex为中间值,计算出相对数据源的index下标,并处理边界情况,当index小于0时,则以“”空字符串填充,反之,index大于adapter.getItemCount最大值时,以""填充,画出两条横线,然后计算每个item所占的弧度,根据弧度换算出角度,并通过如图所示,代码如下:
try {
//滚动中实际的预选中的item(即经过了中间位置的item) = 滑动前的位置 + 滑动相对位置
preCurrentIndex = initPosition + change % adapter.getItemsCount();
} catch (ArithmeticException e) {
Log.e("WheelView","出错了!adapter.getItemsCount() == 0,联动数据不匹配");
}
if (!isLoop) {//不循环的情况
if (preCurrentIndex < 0) {
preCurrentIndex = 0;
}
if (preCurrentIndex > adapter.getItemsCount() - 1) {
preCurrentIndex = adapter.getItemsCount() - 1;
}
} else {//循环
if (preCurrentIndex < 0) {//举个例子:如果总数是5,preCurrentIndex = -1,那么preCurrentIndex按循环来说,其实是0的上面,也就是4的位置
preCurrentIndex = adapter.getItemsCount() + preCurrentIndex;
}
if (preCurrentIndex > adapter.getItemsCount() - 1) {//同理上面,自己脑补一下
preCurrentIndex = preCurrentIndex - adapter.getItemsCount();
}
}
//跟滚动流畅度有关,总滑动距离与每个item高度取余,即并不是一格格的滚动,每个item不一定滚到对应Rect里的,这个item对应格子的偏移值
int itemHeightOffset = (int) (totalScrollY % itemHeight);
// 设置数组中每个元素的值
int counter = 0;
while (counter < itemsVisible) {
int index = preCurrentIndex - (itemsVisible / 2 - counter);//索引值,即当前在控件中间的item看作数据源的中间,计算出相对源数据源的index值
//判断是否循环,如果是循环数据源也使用相对循环的position获取对应的item值,如果不是循环则超出数据源范围使用""空白字符串填充,在界面上形成空白无数据的item项
if (isLoop) {
index = getLoopMappingIndex(index);
visibles[counter] = adapter.getItem(index);
} else if (index < 0) {
visibles[counter] = "";
} else if (index > adapter.getItemsCount() - 1) {
visibles[counter] = "";
} else {
visibles[counter] = adapter.getItem(index);
}
counter++;
}
//中间两条横线
canvas.drawLine(0.0F, firstLineY, measuredWidth, firstLineY, paintIndicator);
canvas.drawLine(0.0F, secondLineY, measuredWidth, secondLineY, paintIndicator);
接下来就是通过while循环依次绘制文字了,这也是我看了很久才看明白的地方,截取部分代码
while (counter < itemsVisible) {
canvas.save();
// L(弧长)=α(弧度)* r(半径) (弧度制)
// 求弧度--> (L * π ) / (π * r) (弧长X派/半圆周长)
/* float itemHeight = maxTextHeight * lineSpacingMultiplier;*/
double radian = ((itemHeight * counter - itemHeightOffset) * Math.PI) / halfCircumference;
省略代码
counter++;
}
float translateY = (float) (radius - Math.cos(radian) * radius - (Math.sin(radian) * maxTextHeight) / 2D)
就是这行代码,不过看过wheel的作者github中的图我才看明白,不过为了让自己印象更深,也就自己又画了一个(用的GeoGebra软件画的),如图中,radius表示圆的半径,radian表示角度,假设现在的counter=2,那么当前的radian的值则为如图所示45度,那么Math.cos(radian) * radius
结果就是蓝色的线段部分,而这句话(Math.sin(radian) * maxTextHeight) / 2D)
,得到的就是图中绿色的线,这样就计算出了画布Y轴上移动的距离,即文字起始的top位置,通过canvas.translate(0.0F, translateY)
,依次改变canvas的原点,并通过canvas.clipRect(0, 0, measuredWidth, (int) (itemHeight))
截取对应item的canvas高度,同时在对应的canvas大小中对canvas进行缩放canvas.scale(1.0F, (float) Math.sin(radian) * SCALECONTENT)
,其中会分四种情况,1,item经过第一条线时,2,item经过第二条线时,3,在两条线中间时,4,在两条线之外时(具体逻辑看代码),最后调用canvas.drawText(contentText, drawOutContentStart, maxTextHeight, paintOuterText)
对每一个item中的文字进行绘制,切记不能忘记canvas.save()
然后再canvas.restore()
回改变前的canvas。
接下来分析onTouchEvent,在这部分主要就是计算出totalScrollY的值,处理totalScrollY的边界情况,然后在手指抬起的逻辑中,通过手指点击控件的时间来判断执行拖拽逻辑还是点击的逻辑,在停止时,通过一个单线程的线程池结合handler实现平滑的动画滑动效果,原理即将要滑动的多余的距离realTotalOffset取十分之一滑动,然后invalidate引起重绘,接着在realTotalOffset = realTotalOffset - realOffset并继续发送重绘的消息,一直到realTotalOffset变成0,至于onFling的逻辑则是在LoopViewGestureListener中处理的,其中也是使用单线程的线程池结合handler实现平滑的动画滑动效果,与上面逻辑类似,代码就不贴了。
到这里基本WheelView的逻辑就分析的差不多了,当然里面还有好多东西值得挖掘的,比如在TimePickerView的初始化中用到了Builder模式,使用到了Adapter,当然这里的adapter只是将数据分离,并没有隔离UI的变化,还有各种接口回调什么的就不说了,同时此项目虽然是个小的开源控件,但是分包挺明确,同时用到MVC思想,而且现在也还在维护,所以还是挺值得一看的。
好了,到这总结一下大概的流程: