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

关于三级联动源码

项目中前几天用到了仿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()方法即可弹出控件。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,345评论 0 17
  • 《ilua》速成开发手册3.0 官方用户交流:iApp开发交流(1) 239547050iApp开发交流(2) 1...
    叶染柒丶阅读 10,477评论 0 11
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,384评论 25 707
  • 01 已经发誓说今年不再买书了,结果昨天给孩子买书,又忍不住挑了几本自己要的。 今天早上一起来,看到连岳办的读书号...
    迅图阅读 5,660评论 183 242
  • XSS攻击 (1)反射型XSS: 就如上面的例子,也就是黑客需要诱使用户点击链接。也叫作”非持久型XSS“(Non...
    铁木真丫丫丫阅读 165评论 0 1