三级联动源码解析个人总结

关于三级联动源码

项目中前几天用到了仿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思想,而且现在也还在维护,所以还是挺值得一看的。

好了,到这总结一下大概的流程:

首先是BasePickerView这个类,这个类中会做一些初始化操作,主要是view以及动画的初始化,show()方法也定义在其中,当调用show方法时做的操作就是将预定义的layout_basepickerview布局add到decorview中,并做一个从底部出来的动画,decorView为PhoneWindow里的最顶层的布局,而在其子类OptionsPickerView中初始化时,会将实际显示的布局pickerview_options挂载到父类的contentContainer中(此控件为一个FrameLayout),pickerview_options中包含自定义的WheelView,这个自定义view就是那个仿iOS的滚动控件,客户端中只需要初始化控件,并在点击事件中执行show()方法即可弹出控件。

推荐阅读更多精彩内容