尺子从一,分为四的故事(BooheeRuler的创造和重构思路)

Posted 炎之铠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了尺子从一,分为四的故事(BooheeRuler的创造和重构思路)相关的知识,希望对你有一定的参考价值。

尺子从一,分为四的故事(BooheeRuler的创造和重构思路)

本文出处
炎之铠csdn博客:http://blog.csdn.net/totond
炎之铠邮箱:yanzhikai_yjk@qq.com
本项目Github地址:https://github.com/totond/BooheeRuler
本文原创,转载请注明本出处!

前言

  整整一个月没写博客了,因为最近工作非常忙和周末不加班的话有一些其他的事(其实就是懒了),这篇文章其实很早就想写了,不过我看到网络上很多类似的尺子分析文章,我发现它们都说了大部分了,所以就没好意思重复写了,最新因为BooheeRuler经历大幅度重构,所以我才想着把这些经验记录分享一下。

  本文主要是讲述BooheeRuler从只能横向使用发展到拥有四个形态的过程,如上图。

  BooheeRuler是在扔物线凯哥的一个自定义View仿写活动里面仿写薄荷健康APP的里面的一个尺子,结束之后,没想到兄弟们这么热情,在issue上提建议和发邮件和我交流,非常感谢,而也是因为有不少兄弟发邮件让我做纵向的尺子,我才能在这个项目非常忙的十一月把它做出来。在现在这个项目休整期,我在这里记录分享一下。

本文章主要是分享BooheeRuler重构思路和一些实现细节,如果有不了解这个控件的可以先去Github上的README扫两眼,相信会对理解下面的内容更有帮助^_^。

更新过程

  活动结束之后,BooheeRuler经历了多次的更新,把这些更新列出来,才好具体说明我做了什么。下面是直到现在的更新List:

  • 2017/10/23 version 0.0.5:

    • 修改了画刻度的方法,改为只画当前屏幕显示的刻度
    • 增加了count属性,用于设置一个大刻度格子里面的小刻度格子数,默认是10
  • 2017/10/30 version 0.0.6:

    • 加入VelocityTracker的回收处理(之前只是clear并没有recycle),提高性能。
    • 加入属性paddingStartAndEnd,用于控制尺子两端的padding。
    • 让刻度的绘制前后多半大格,这样可以提前显示出下面的数字,让过渡不会变得那么突兀。
    • 取消了一些不必要的log输出。
  • 2017/10/30 version 0.0.7:

    • 之前VelocityTracker重复使用了addMovement,现在取消掉了。
  • 2017/11/15 version 0.1.0:

    • 重构代码,将尺子分为4个形态。
    • 对细节有一些小改动:如背景设置换成以InnerRuler为主体,优化Padding等。
  • 2017/11/16 version 0.1.1:
    • 修复KgNumberLayout修改单位会出错的bug
  • 2017/11/28 version 0.1.2:
    • 修复触发ACTION_CANCEL事件会令刻度停在非整点地方的bug
    • 增加边缘效果
  • 2017/12/13 version 0.1.3:
    • 性能优化:BooheeRuler的onLayout之前没有利用change属性,导致每次刷新都会重新Layout,现在不会。
    • 性能优化:修改了画刻度的逻辑,现在在刻度范围很大的情况下还可以顺畅滑动。
    • 修复了goToScale()重复回调导致显示刻度不准确的问题。

实现思路

特点

  BooheeRuler具有以下的一些特点:
- 触摸滑动后是有惯性滚动的。
- 光标保持在中间,不随尺子滚动。
- 光标选中的刻度只会是0.1整点,所以触摸滑动、惯性滚动完了之后,尺子会回滚到最近的整点刻度。
- 滑动到边缘的时候有边缘的效果提示。
- 可以有4种样式的尺子选择。

涉及知识点

  BooheeRuler涉及但不止于以下的一些知识点:
- 让控件滑动的知识,如scrollTo()的使用,OverScroller的使用等。
- 触摸控制的知识,如重写onTouchEvent()实现触摸控制滑动。
- 自定义View的封装知识,如自定义属性的设置与使用等。
- 自定义ViewGroup的简单使用,如BooheeRuler包裹InnerRuler、实现padding等。
- 自定义View的绘画知识,如刻度的绘画。
- 边缘效果的实现。
- 设计模式的使用,如通过策略模式来将尺子分成4种形态。

结构

  一开始是只有头向上的尺子的(只有BooheeRuler加InnerRuler,如果想了解具体的话可以参考2017/10/30前的commit),为了实现4种尺子,首先就是构建抽象类,把4种尺子共同的逻辑抽象出来,下面是描述了部分属性和方法的UML类图 :

  这样能大大减少重复的代码,缺点就是逻辑分开了,想通过代码看实现一个尺子的逻辑就要跳来跳去有点不方便。在一开始给出这个结构图,是为了给出一个目录,让我们在后面提到跳转的时候知道目的地。
  下面我将会以头向上的TopHeadRuler为例,将尺子的实现特点一个一个地讲述出来,可能有些特点会跨越几个类。如果是是一个一个类的介绍,我觉得这样思路反而更加不清晰。

其实这个重构思路和我之前写的YMenu重构过程有点类似,这个是应用了模板方法模式的重构,有兴趣的可以看下,打个广告^_^。

封装

  从上面的结构可以看到BooheeRuler实际是一个外壳,其实它一个ViewGroup,把InnerRuler包裹住,这样做的原因主要是因为InnerRuler的滑动是使用scrollTo()方法(scrollBy()方法其实最后也是调用scrollTo()方法而已),这样会导致整个画面移动。而因为中间的选定光标是一个Drawable(为了灵活性),Drawable改变自己的位置的方法setBound()比较耗时,会导致尺子滑动比较卡,所以不能采用让光标随着画面滑动而自己也改变绘画位置来保持在中间的方法。最后还是保持了这个使用外壳的方法。
  然后,这个外壳也理所当然充当了尺子和外部交流的中介了,用户决定的属性也是传入到这里,而InnerRuler里面则是用mParent.getXXX()的方法获取这些属性。
  用户通过设置rulerStyle来选择尺子的形态:

//这里是BooheeRuler

    private void initRuler(Context context) 
        mContext = context;
        switch (mStyle)
            case TOP_HEAD:
                mInnerRuler = new TopHeadRuler(context, this);
                paddingHorizontal();
                break;
            case BOTTOM_HEAD:
                mInnerRuler = new BottomHeadRuler(context, this);
                paddingHorizontal();
                break;
            case LEFT_HEAD:
                mInnerRuler = new LeftHeadRuler(context, this);
                paddingVertical();
                break;
            case RIGHT_HEAD:
                mInnerRuler = new RightHeadRuler(context, this);
                paddingVertical();
                break;
        

        //设置全屏,加入InnerRuler
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        mInnerRuler.setLayoutParams(layoutParams);
        addView(mInnerRuler);
        //设置ViewGroup可画
        setWillNotDraw(false);

        initPaint();
        initDrawable();
        initRulerBackground();
    

  这里就是策略模式的实现,让用户选择不同的策略来决定使用不同形态的尺子。

刻度的绘制

  从最简单的刻度绘制入手,这里以头向上的TopHeadRuler为例:

    //画刻度和字
    private void drawScale(Canvas canvas) 
        //计算开始和结束刻画时候的刻度
        float start = (getScrollX() - mDrawOffset) / mParent.getInterval() + mParent.getMinScale();
        float end = (getScrollX() + canvas.getWidth() + mDrawOffset) / mParent.getInterval() + mParent.getMinScale();
        for (float i = start; i <= end; i++) 
            //将要刻画的刻度转化为位置信息
            float locationX = (i - mParent.getMinScale()) * mParent.getInterval();

            if (i >= mParent.getMinScale() && i <= mParent.getMaxScale()) 
                if (i % mCount == 0) 
                    canvas.drawLine(locationX, 0, locationX, mParent.getBigScaleLength(), mBigScalePaint);
                    canvas.drawText(String.valueOf(i / 10), locationX, mParent.getTextMarginHead(), mTextPaint);
                 else 
                    canvas.drawLine(locationX, 0, locationX, mParent.getSmallScaleLength(), mSmallScalePaint);
                
            
        
        //画轮廓线
        canvas.drawLine(getScrollX(), 0, getScrollX() + canvas.getWidth(), 0, mOutLinePaint);

    

  上面涉及到的一些在BooheeRuler的属性介绍如下:

属性名称意义类型默认值
minScale尺子的最小刻度值integer464(在尺子上显示就是46.4)
maxScale尺子的最大刻度值integer2000(在尺子上显示就是200.0)
smallScaleLength尺子小刻度(0.1)的刻度线长度dimension30px
smallScaleWidth尺子小刻度(0.1)的刻度线宽度/粗细dimension3px
bigScaleLength尺子大刻度(1.0)的刻度线长度dimension60px
bigScaleWidth尺子大刻度(1.0)的刻度线宽度/粗细dimension5px
scaleInterval尺子每条刻度线之间的距离dimension18px
textMarginHead尺子数字文字距离边界距离dimension120px

  到这里就比较清晰了,主要思路就是:

  • 让i从最小刻度到最大刻度遍历,然后把i转化为对应的滑动偏移量locationX(相当于i这个刻度对应所在的scrollX)。
  • 如果locationX是处在可绘画范围内,也就是if (locationX > getScrollX() - mDrawOffset && locationX < (getScrollX() + canvas.getWidth() + mDrawOffset))
    为true的时候,就开始画刻度啦。

    这里非常感谢littlezan提出的性能优化的建议。0.1.3版本后,BooheeRuler通过动态改变刻度线刻画的循环条件来减少循环次数,大大提高了刻度范围较大的时候的尺子性能。

  • 先将可绘画范围转为为刻度值start和end。

  • 然后遍历里面的整数,让里面的刻度转化为位置信息,来绘制刻度。
  • 这个可绘画范围就是尺子的当前屏幕显示范围加上两边的mDrawOffset距离,这个mDrawOffset值是半个大格子刻度的长度,这样做是因为有时候尺子边缘滑到一个快到整数点的时候如70.9,按照现实,下面的数字71应该会显示出一小半的身形了,但是如果不加mDrawOffset这个提前刻画量的话,这里是不会显示出71了,而是让边缘滑到71.0的时候再一整个冒出来,非常突兀,所以这里就加上了mDrawOffset属性让尺子预绘画半大格(version 0.0.6更新)。

触摸控制与滑动

  在4种尺子中,这部分的逻辑是有共通点的,所以可以把它们分为横向和纵向的两类,这里以TopHeadRuler实现横向的滑动为例,主要是它的父类HorizontalRuler实现。
  在HorizontalRuler重写onTouchEvent()方法,来处理触摸事件:

触摸控制

  实现触摸控制是很简单的,只需要记录手指滑动的横向距离,让尺子滑动相同的横向距离就行了:

//这里是HorizontalRuler

    //处理滑动,主要是触摸的时候通过计算现在的event坐标和上一个的位移量来决定scrollBy()的多少
    @Override
    public boolean onTouchEvent(MotionEvent event) 
        float currentX = event.getX();
        //...

        switch (event.getAction()) 
            case MotionEvent.ACTION_DOWN:
                //...

                mLastX = currentX;
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = mLastX - currentX;
                mLastX = currentX;
                scrollBy((int) (moveX), 0);
                break;
            //...
        
        return true;
    

  通过这些代码,就能实现触摸控制,但是控制之后的惯性滑动才是难点:

惯性滑动

  首先这里重写了scrollTo()方法:

//这里是HorizontalRuler

    //重写滑动方法,设置到边界的时候不滑,并显示边缘效果。滑动完输出刻度。
    @Override
    public void scrollTo(@Px int x, @Px int y) 
        if (x < mMinPosition) 
            goStartEdgeEffect(x);
            x = mMinPosition;
        
        if (x > mMaxPosition) 
            goEndEdgeEffect(x);
            x = mMaxPosition;
        
        if (x != getScrollX()) 
            super.scrollTo(x, y);
        

        //输出刻度
        mCurrentScale = scrollXtoScale(x);
        if (mRulerCallback != null) 
            mRulerCallback.onScaleChanging(Math.round(mCurrentScale));
        
    

  在手指滑动的时候计算速度,然后手指离开的时候按照这个速度来计算后面的惯性滑动:

//这里是HorizontalRuler

    //处理滑动,主要是触摸的时候通过计算现在的event坐标和上一个的位移量来决定scrollBy()的多少
    //滑动完之后计算速度是否满足Fling,满足则使用OverScroller来计算Fling滑动
    @Override
    public boolean onTouchEvent(MotionEvent event) 
        float currentX = event.getX();
        //开始速度检测
        if (mVelocityTracker == null) 
            mVelocityTracker = VelocityTracker.obtain();
        
        mVelocityTracker.addMovement(event);

        switch (event.getAction()) 
            case MotionEvent.ACTION_DOWN:
                if (!mOverScroller.isFinished()) 
                    mOverScroller.abortAnimation();
                

                mLastX = currentX;
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = mLastX - currentX;
                mLastX = currentX;
                scrollBy((int) (moveX), 0);
                break;
            case MotionEvent.ACTION_UP:
                //处理松手后的Fling
                mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                int velocityX = (int) mVelocityTracker.getXVelocity();
                if (Math.abs(velocityX) > mMinimumVelocity) 
                    fling(-velocityX);
                 else 
                    scrollBackToCurrentScale();
                
                //VelocityTracker回收
                if (mVelocityTracker != null) 
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                
                releaseEdgeEffects();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (!mOverScroller.isFinished()) 
                    mOverScroller.abortAnimation();
                
                //回滚到整点刻度
                scrollBackToCurrentScale();
                //VelocityTracker回收
                if (mVelocityTracker != null) 
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                
                releaseEdgeEffects();
                break;
        
        return true;
    

    private void fling(int vX) 
        mOverScroller.fling(getScrollX(), 0, vX, 0, mMinPosition - mEdgeLength, mMaxPosition + mEdgeLength, 0, 0);
        invalidate();
    

  惯性滑动使用的是OverScroller,从上面逻辑可以看出,当手指松开时(ACTION_UP),将会检测当前滑动速度是否大于mMinimumVelocity,大于的话就会使用mOverScroller来进行fling惯性滑动,否则就是回滚到当前的整点刻度scrollBackToCurrentScale()

惯性滑动结束的判断

  要使用OverScroller,就要重写computeScroll()方法,由于这里的横向纵向滑动逻辑都是一样,都是滑完之后回滚到最近的刻度点,所以computeScroll()方法就放到父类InnerRuler里面写了:

//这里是InnerRuler

    @Override
    public void computeScroll() 
        if (mOverScroller.computeScrollOffset()) 
            scrollTo(mOverScroller.getCurrX(), mOverScroller.getCurrY());

            //这是最后OverScroller的最后一次滑动,如果这次滑动完了mCurrentScale不是整数,则把尺子移动到最近的整数位置
            if (!mOverScroller.computeScrollOffset() && mCurrentScale != Math.round(mCurrentScale))
                //Fling完进行一次检测回滚
                scrollBackToCurrentScale();
            
            invalidate();
        
    

    protected abstract void scrollBackToCurrentScale();

  这里的判断条件if (!mOverScroller.computeScrollOffset() && mCurrentScale != Math.round(mCurrentScale))是一种比较巧妙的方法,以后下面会提到scrollBackToCurrentScale()方法也是使用里惯性滑动的方式来让尺子回弹,所以如果不加上检测mCurrentScale是否已经为整数的话,这个方法会在惯性滑动的时候进入死循环(惯性滑动结束后又执行惯性滑动),加入了这个判断就可以尺子只回滚一次。

滑动结束的回滚

  当尺子停止滑动时,需要回滚到最近的刻度点,就需要用到下面的方法:

//这里是HorizontalRuler

    //把滑动偏移量scrollX转化为刻度Scale
    private float scrollXtoScale(int scrollX) 
        return ((float) (scrollX - mMinPosition) / mLength) * mMaxLength + mParent.getMinScale();
    

    //把Scale转化为ScrollX
    private int scaleToScrollX(float scale) 
        return (int) ((scale - mParent.getMinScale()) / mMaxLength * mLength + mMinPosition);
    


    //把移动后光标对准距离最近的刻度,就是回弹到最近刻度
    @Override
    protected void scrollBackToCurrentScale() 
        //渐变回弹
        mOverScroller.startScroll(getScrollX(), 0, scaleToScrollX(Math.round(mCurrentScale)) - getScrollX(), 0, 500);
        invalidate();

        //立刻回弹
//        scrollTo(scaleToScrollX(mCurrentScale),0);
    

  还记得前面重写scrollTo()方法的最后,就把当前的scrollX值转化为mCurrentScale了,因为每一个滑动操作都会调用scrollTo()方法,所以在scrollBackToCurrentScale()这里就可以直接将mCurrentScale的四舍五入值转化为目标scrollX,让尺子在500ms之内回滚过去了。

边缘效果实现

  这是0.1.2版本新增的效果,在API大于等于21(android5.0)的时候是这样的:

  在API小于21(Android5.0)的时候是这样的:

  实现这个效果是用了EdgeEffect类,其实谷歌给了它的封装类EffectCompat来让我们使用,但是无奈的是我没有在这个类的源码里面找出设置颜色的API(这是谷歌考虑到API版本适配的问题还是我眼花?)所以就只好直接用EdgeEffect类了。
  因为所有尺子都是有两个边缘(start和end),所以初始化操作写在InnerRuler里面:

//这里是InnerRuler

    //初始化边缘效果
    public void initEdgeEffects()
        if (mParent.canEdgeEffect()) 
            if (mStartEdgeEffect == null || mEndEdgeEffect == null) 
                mStartEdgeEffect = new EdgeEffect(mContext);
                mEndEdgeEffect = new EdgeEffect(mContext);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) 
                    mStartEdgeEffect.setColor(mParent.getEdgeColor());
                    mEndEdgeEffect.setColor(mParent.getEdgeColor());
                
                mEdgeLength = mParent.getCursorHeight() + mParent.getInterval() * mParent.getCount();
            
        
    

  当滑动到边缘时,再滑动(也就相当于scroll值超出滑动范围),就会触发边缘效果:

//这里是HorizontalRuler

    //重写滑动方法,设置到边界的时候不滑,并显示边缘效果。滑动完输出刻度。
    @Override
    public void scrollTo(@Px int x, @Px int y) 
        if (x < mMinPosition) 
            goStartEdgeEffect(x);
            x = mMinPosition;
        
        if (x > mMaxPosition) 
            goEndEdgeEffect(x);
            x = mMaxPosition;
        
        if (x != getScrollX()) 
            super.scrollTo(x, y);
        

        mCurrentScale = scrollXtoScale(x);
        if (mRulerCallback != null) 
            mRulerCallback.onScaleChanging(Math.round(mCurrentScale));
        

    

  上面再次列出重写的scrollTo()方法,然后下面就是触发边缘效果的代码:

//这里是TopHeadRuler

    //头部边缘效果处理
    private void goStartEdgeEffect(int x)
        if (mParent.canEdgeEffect()) 
            if (!mOverScroller.isFinished()) 
                mStartEdgeEffect.onAbsorb((int) mOverScroller.getCurrVelocity());
                mOverScroller.abortAnimation();
             else 
                mStartEdgeEffect.onPull((float) (mMinPosition - x) / (mEdgeLength) * 3 + 0.3f);
                mStartEdgeEffect.setSize(mParent.getCursorHeight(), getWidth());
            
            postInvalidateOnAnimation();
        
    

    //尾部边缘效果处理
    private void goEndEdgeEffect(int x)
        if(mParent.canEdgeEffect()) 
            if (!mOverScroller.isFinished()) 
                mEndEdgeEffect.onAbsorb((int) mOverScroller.getCurrVelocity());
                mOverScroller.abortAnimation();
             else 
                mEndEdgeEffect.onPull((float) (x - mMaxPosition) / (mEdgeLength) * 3 + 0.3f);
                mEndEdgeEffect.setSize(mParent.getCursorHeight(), getWidth());
            
            postInvalidateOnAnimation();
        
    

    //取消边缘效果动画
    private void releaseEdgeEffects()
        if (mParent.canEdgeEffect()) 
            mStartEdgeEffect.onRelease();
            mEndEdgeEffect.onRelease();
        
    

  EdgeEffect的onPull()就是让手指拖动过界会触发边缘效果,而onAbsorb()方法就是让惯性滑动触发边缘效果。最后就是要在4种尺子里面各自的onDraw()方法里面画效果了:

    @Override
    protected void onDraw(Canvas canvas) 
        super.onDraw(canvas);
        drawScale(canvas);
        drawEdgeEffect(canvas);
    

    //画边缘效果
    private void drawEdgeEffect(Canvas canvas) 
        if (mParent.canEdgeEffect()) 
            if (!mStartEdgeEffect.isFinished()) 
                int count = canvas.save();
                //旋转位移Canvas来使EdgeEffect绘画在正确的地方
                canvas.rotate(270);
                canvas.translate(-mParent.getCursorHeight(), 0);
                if (mStartEdgeEffect.draw(canvas)) 
                    postInvalidateOnAnimation();
                
                canvas.restoreToCount(count);
             else 
                mStartEdgeEffect.finish();
            
            if (!mEndEdgeEffect.isFinished()) 
                int count = canvas.save();
                canvas.rotate(90);
                canvas.translate(0, -mLength);
                if (mEndEdgeEffect.draw(canvas)) 
                    postInvalidateOnAnimation();
                
                canvas.restoreToCount(count);
             else 
                mEndEdgeEffect.finish();
            
        
    

  画EdgeEffect是一件很有dan趣teng的事情,因为EdgeEffect固定是要画在Canvas的头部,所以
要通过旋转平移来调整它的位置,而旋转的原点是在Canvas的零点,整个坐标轴还是要跟着旋转的,一开始的时候我没意识到这两点导致调了好久。

总结

  上面就是实现BooheeRuler和重构的思路了,只是大概地说了一下流程,了解了这个流程,就应该大概知道BooheeRuler的运作方式了,还有一些比较细的地方(如Padding、回调之类的)没有具体描述,如果有兴趣的话可以到Github上了解下源码。
  这篇文章主要介绍了BooheeRuler经历重构之后的实现思路,相比之前一整个在一块的时候,思路比较分散了吧,但是也很轻松地复用了相同的逻辑,比重新写3种尺子快多了。

后话

  不断地学习和总结能让自己的技术得到稳定的提升,在这里非常感谢为我提Bug和提需求的兄弟们,给了我前进的动力。另外小弟不才,如果文章有什么说错或者对BooheeRuler有什么意见和建议,欢迎评论或者在Github上提出issue,请多多指教。
  最后,读到这里,就是希望喜欢的话大哥大姐们能赏个Star啦^_^!

以上是关于尺子从一,分为四的故事(BooheeRuler的创造和重构思路)的主要内容,如果未能解决你的问题,请参考以下文章

故事板中的红线从一开始就添加了所有控件——Xcode 6.3

从一只骡子与测试员的故事,分析性能压力和容量测试的区别

屏幕尺子(电脑尺子) 1.0 免费绿色版

《构建之法》

Android UI-薄荷健康尺子

用尺子在googlemapv3上计算距离