前言
github项目地址 pnikosis/materialish-progress 这是一个仿照 material progressbar 的开源项目,之前为了兼容5.0之前的手机版本使用过,这周突然有看光她的冲动~不过,这里先不贴她的动效,迫不及待想看的可以点击上面的链接,因为,我要贴的效果,是放慢了10倍,并且为了分析改动了部分代码后的效果,这样方便我们看清这个控件到底做了什么。
动效分析
很明显第一个控件是默认情况下的,而第二个就是我改动过的控件。
这个控件的制作,我们先着重来看动效部分,毕竟如果静止状态的话其实是有很多种方法来实现的,第一步我们先不去理睬静态这一部分。撇去细节,我们从大的方向入手,我把这个动效分为两个部分:
第一部分:圆弧的动态绘制。这一部分,只要看第二个 progress 就行了,这里要做的就是计算每一段时间内要绘制的部分,不管是最初的由最小变最大,还是之后的最大变最小。
第二部分:圆弧的滚动。其实准确来说,是对起始角度的计算。第一个 progress 融合了第一部分和第二部分,圆弧的大小和起始点的不断变化的结合就是一个完整的 material progressbar。
分析前的准备
API:
// 从开机到现在的毫秒数SystemClock.uptimeMillis()// 绘制圆弧canvas.drawArc(circleBounds, from, length, false, barPaint);
函数:
//在开始与结束的地方速率改变比较慢,在中间的时候加速(float)(Math.cos((input + 1) * Math.PI) / 2.0f) + 0.5f//在开始的地方速率改变比较慢,然后开始加速Math.pow(input, 2)//在开始的地方快然后慢1.0f - (1.0f - input) * (1.0f - input)
以上只是部分函数,如果想要看更多,可以看看我的这篇简单的插值器文章 Android动画-Interpolator(插值器)大全,里面介绍了常用的插值器,想要知道怎么实现的,看看源码就知道了,其实就是一些函数的使用,数学都还给我体育老师了,这个没法讲,希望有好文章的童鞋可以推荐下,不胜感激。
列出这些函数的意义其实就是想告诉看源码的童鞋,看源码不要纠结在细节上,有些东西确实不是一时半会儿能弄懂的。对于本章的含义就是,不要纠结于函数的由来,只需要知道这是用来做什么的。
重要的Attrs:
还有一些参数我就不一一介绍了,都是比较简单的参数,于逻辑无碍。到此,因该说基本的准备都OK了,接下来就是看源码了。
思路
从不熟悉控件源码的角度来讲,这里就讲思路确实不太合适,但是在接下来的源码解析中,却很难穿插思路的讲解,因为每一行代码都多少需要联系到其它的代码才能准确解读出代码的意义,而且还有很多细节的处理,那时候再来说思路就显得过于混乱了,如果能在这里明白控件的思路,我觉得看者完全可以自己去看源码,相信会比我这生涩的分析更加清晰。
这里的思路也是需要联系到前面介绍的参数,这样讲解会更清晰点,我们从控件伊始开始分析:
控件从最小圆弧角度 barLength 开始,经历了 pausedTimeWithoutGrowing ( pausedTimeWithoutGrowing >= pauseGrowingTime )的时间后,开始计算需要绘制的总角度:总角度 = barLength + 需要额外绘制的角度。而这个需要额外绘制的角度 = 已经经历的时间 × ( barMaxLength - barLength) / barSpinCycleTime
。而当到达最大角度之后,我们就需要减去相应的时间内对应的角度: 需要额外绘制的角度 = ( barMaxLength - barLength) - 已经经历的时间 × ( barMaxLength - barLength) / barSpinCycleTime
,前者的计算是为了圆弧逐渐增大,后者则是为了逐渐变小。最后,需要小小的强调下:起始角度的计算也非常重要。
源码分析
控件的全部代码只有 800 行,有效代码目测只有 1/3,这里我们就不看 onMeasure 和 onSizeChanged 这些准备工作的处理代码了,只是一些测量和初始化的工作代码,那么 onDraw 里面的代码以及涉及到的相关函数每一行都是必不可少的。
onDraw
protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 圆弧背景 canvas.drawArc(circleBounds, 360, 360, false, rimPaint); boolean mustInvalidate = false; if (!shouldAnimate) { return; } if (isSpinning) { //Draw the spinning bar mustInvalidate = true; long deltaTime = (SystemClock.uptimeMillis() - lastTimeAnimated); updateBarLength(deltaTime); float deltaNormalized = deltaTime * spinSpeed / 1000.0f;// mProgress += deltaNormalized; if (mProgress > 360) { mProgress -= 360f; // A full turn has been completed // we run the callback with -1 in case we want to // do something, like changing the color runCallback(-1.0f); } lastTimeAnimated = SystemClock.uptimeMillis(); float from = mProgress - 90; float length = barLength + barExtraLength; if (isInEditMode()) { from = 0; length = 135; } canvas.drawArc(circleBounds, from, length, false, barPaint); } else { float oldProgress = mProgress; if (mProgress != mTargetProgress) { //We smoothly increase the progress bar mustInvalidate = true; float deltaTime = (float) (SystemClock.uptimeMillis() - lastTimeAnimated) / 1000; float deltaNormalized = deltaTime * spinSpeed; mProgress = Math.min(mProgress + deltaNormalized, mTargetProgress); lastTimeAnimated = SystemClock.uptimeMillis(); } if (oldProgress != mProgress) { runCallback(); } float offset = 0.0f; float progress = mProgress; if (!linearProgress) { Log.d(TAG, "do factor"); float factor = 2.0f; offset = (float) (1.0f - Math.pow(1.0f - mProgress / 360.0f, 2.0f * factor)) * 360.0f; progress = (float) (1.0f - Math.pow(1.0f - mProgress / 360.0f, factor)) * 360.0f; } if (isInEditMode()) { progress = 360; } canvas.drawArc(circleBounds, offset - 90, progress, false, barPaint); } if (mustInvalidate) { invalidate(); } }
在参数准备的环节已经介绍了 isSpinning 这个参数,所以当 isSpinning 为 true 的时候,便是我们首要分析的代码部分 – 动效代码。
if ( isSpinning == true )
mustInvalidate = true; long deltaTime = (SystemClock.uptimeMillis() - lastTimeAnimated); updateBarLength(deltaTime); float deltaNormalized = deltaTime * spinSpeed / 1000.0f; mProgress += deltaNormalized; if (mProgress > 360) { mProgress -= 360f; // A full turn has been completed // we run the callback with -1 in case we want to // do something, like changing the color runCallback(-1.0f); } lastTimeAnimated = SystemClock.uptimeMillis(); float from = mProgress - 90; float length = barLength + barExtraLength; if (isInEditMode()) { from = 0; length = 135; } canvas.drawArc(circleBounds, from, length, false, barPaint);
在 onDraw 方法调用时,每次都初始化一个参数boolean mustInvalidate = false;
,这个参数便是判断是否重绘当前控件,在 isSpinning 为 true 的情况下,很明显这是一个死循环,在 onDraw 的最后几行:
if (mustInvalidate) { invalidate(); }
这里的判断保证了无线循环。
long deltaTime = (SystemClock.uptimeMillis() - lastTimeAnimated);
相对于之前绘制的时间,到现在的需要绘制的时间的时间差。如果你看了思路部分的文字的话,那么很方便就可以将这个变量理解为 已经历的时间。这个参数不但是用来计算 圆弧需要绘制角度 的重要参数,还是用来计算 圆弧起始角度 的重要参数。
lastTimeAnimated 的赋值
在控件显示的时候
@Override protected void onVisibilityChanged(View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); if (visibility == VISIBLE) { lastTimeAnimated = SystemClock.uptimeMillis(); } }
还有就是在完成了一系列的运算之后,最后给 lastTimeAnimated 赋值 lastTimeAnimated = SystemClock.uptimeMillis();
updateBarLength(deltaTime) 用于计算圆弧额外绘制角度的函数,非常重要!可以说看懂这个函数就看懂了一切。需要注意的是,这个函数的分析需要和onDraw方法结合看才行。
private void updateBarLength(long deltaTimeInMilliSeconds) { if (pausedTimeWithoutGrowing >= pauseGrowingTime) { timeStartGrowing += deltaTimeInMilliSeconds; if (timeStartGrowing > barSpinCycleTime) { // We completed a size change cycle // (growing or shrinking) timeStartGrowing -= barSpinCycleTime; //if(barGrowingFromFront) { pausedTimeWithoutGrowing = 0; //} barGrowingFromFront = !barGrowingFromFront; } float distance = (float) Math.cos((timeStartGrowing / barSpinCycleTime + 1) * Math.PI) / 2 + 0.5f; float destLength = (barMaxLength - barLength); if (barGrowingFromFront) { barExtraLength = distance * destLength; } else { float newLength = destLength * (1 - distance); mProgress += (barExtraLength - newLength); barExtraLength = newLength; } } else { pausedTimeWithoutGrowing += deltaTimeInMilliSeconds; } }
pausedTimeWithoutGrowing < pauseGrowingTime
显而易见 pausedTimeWithoutGrowing 作为 圆弧在 最大/最小 角度保持的时间,初始状态下默认值是 0,那么代码逻辑走入 else: pausedTimeWithoutGrowing += deltaTimeInMilliSeconds
。
调用了 updateBarLength,应该说现在圆弧还处在角度保持不变的状态下,但是 圆弧的起始绘制角度 却一刻不能停:
// spinSpeed 作为每秒转过的角度,作为每秒的角度速度,用当前的时间差 deltaTime 计算,在这段时间里面转过了多少角度float deltaNormalized = deltaTime * spinSpeed / 1000.0f;// mProgress 永远处于递增的状态mProgress += deltaNormalized;// 当 mProgress 超过 360 即超过一圈,那么减去一圈的角度 360。另外当超过一圈的时机,会给回调函数传入一个特殊的值 -1,至于回调函数的具体处理逻辑,这个就看项目需求了。if (mProgress > 360) { mProgress -= 360f; // A full turn has been completed // we run the callback with -1 in case we want to // do something, like changing the color runCallback(-1.0f); }//更新 lastTimeAnimated 上次绘制时间lastTimeAnimated = SystemClock.uptimeMillis();// 其实这一步刚刚开始看得有点迷惑性,后来一想,圆弧的 0 度起始点其实是从 右半水平半径开始的, 减去 90 度后,就会从圆的顶点开始。float from = mProgress - 90;// barExtraLength 在这个时段 barExtraLength 并没有被赋值那么,length 的值一直都是 16float length = barLength + barExtraLength;// isInEditMode 这个其实是为了修饰的代码,如果你添加在了 xml里面,那么圆弧的默认显示状态就是 0 - 135。这个没什么好说的if (isInEditMode()) { from = 0; length = 135;}// 最后一步绘制canvas.drawArc(circleBounds, from, length, false, barPaint);
那么以上的分析和注释都是在 pausedTimeWithoutGrowing < pauseGrowingTime 的状态下,源码中 pauseGrowingTime = 200
也就是这个圆弧大小保持不变的状态会保持 200 毫秒左右,超过了这个限制,那么就要开始递增或者递减了。
pausedTimeWithoutGrowing >= pauseGrowingTime
//timeStartGrowing从圆弧开始变化,记录下变化的总时间, timeStartGrowing += deltaTimeInMilliSeconds; /** * 这个判断可以先跳过,看完函数后面的代码,再来看,我感觉会清晰点,当然如果你能理解,那就直接看 * barSpinCycleTime 作为 递增/递减 规定的变化总是时间, * 当 timeStartGrowing 大于这个时间时,说明圆弧的下一个变化就需要有所改变了 * 所谓的改变就是 递增 -> 递减 或者 递减 -> 递增 * 这个改变的标记通过 改变 barGrowingFromFront 来实现 !barGrowingFromFront。 * 于此同是将相关参数重新初始化 pausedTimeWithoutGrowing * pausedTimeWithoutGrowing = 0,保证了最大/最小状态下 圆弧不会瞬间改变,而是有一个旋转并保持的过程 * 并且为了精确计算圆弧的长度,还需要计算 timeStartGrowing -= barSpinCycleTime * timeStartGrowing:源码中 barSpinCycleTime 为 460ms,如果 timeStartGrowing = 480ms,也就是说 圆弧的角度不应该是 16,而应该是 16 + (480 - 460)这段时间差对应的额外的角度 * */ if (timeStartGrowing > barSpinCycleTime) { // We completed a size change cycle // (growing or shrinking) timeStartGrowing -= barSpinCycleTime; //if(barGrowingFromFront) { pausedTimeWithoutGrowing = 0; //} barGrowingFromFront = !barGrowingFromFront; } /** * 在准备的环节,已经介绍过了,这是开始与结束慢,中间加速的函数 * timeStartGrowing / barSpinCycleTime 其实就是计算当前变化的总时间,占规定变化时间上限的比列 * 其实我做过实验,即便写成 float distance = timeStartGrowing / barSpinCycleTime;如果你不是拥有像素眼,那么基本是看不出来的,毕竟整个变化时间才 460ms,更不要说 中间那段加速的时间了... */ float distance = (float) Math.cos((timeStartGrowing / barSpinCycleTime + 1) * Math.PI) / 2 + 0.5f; /** *如果不考虑自己重新订制圆弧的最大角度和最小角度,那么 destLength 其实是个常数, *这种只需要进行一次的计算,我建议可以直接在 onSizeChanged 里面做,而不用每次都计算一次,个人优化建议 * destLength 代表着 额外需要绘制的角度 barExtraLength 的最大值 */ float destLength = (barMaxLength - barLength); if (barGrowingFromFront) { /** * barGrowingFromFront 的默认值就是 true,也就是默认情况下是递增的 * 之前的分析已经说过了 float length = barLength + barExtraLength,绘制的角度,其实就是 最小角度 + 额外需要绘制的角度。 * barExtraLength = 时间(其实也可以理解为角度)的百分比 * barExtraLength 的最大值,这样就可以得出 额外需要绘制的角度了 */ barExtraLength = distance * destLength; } else { /** * 当 barGrowingFromFront 为 false,也就是圆弧已经处于最大角度的状态了,递减! * newLength 计算的就是需要绘制的额外角度。 * 例:当前已经是 270度了,时间比例是 50%,递减的状态下,额外的角度 = 最大角度 - 已经过去的百分比数值。 * 并且这里需要特别注意的是 mProgress 的起始点也必须不断改变,递增 已经过去的百分比数值,否则无法做成首尾同步的协调递减变化效果。(这个真不知道怎么说,如果不太明白的同学可以把第二行注释掉,你就可以看到那个奇葩的效果。) */ float newLength = destLength * (1 - distance); mProgress += (barExtraLength - newLength); barExtraLength = newLength; }
到这里,其实基本说完了,onDraw 中 绘制动效的代码在比较简单的状态的分析过了,不过这里,还需要强调下 mProgress += deltaNormalized
,这行代码的重要性。其实我开篇动效的第二个控件,就是在改变了一些时间的值之后,把这行注释掉了,这一行保证了 起始点 的不断更新。至关重要。
疑惑
整个控件读下来,也花了我好几天的时间,主要是参数有点多,还有纠结与一些数学公式。其实直到现在,我还是有个地方不是很明白:
//linearProgress 代表着线性变化的标记。if (!linearProgress) { float factor = 2.0f; offset = (float) (1.0f - Math.pow(1.0f - mProgress / 360.0f, 2.0f * factor)) * 360.0f; progress = (float) (1.0f - Math.pow(1.0f - mProgress / 360.0f, factor)) * 360.0f; }
就是上面的数学公式,我到现在还不太明白它的作用,我也调试过,不过效果让我有点茫然,如果有知道的童鞋,请不吝赐教,不胜感激。
总结
这篇文章中,我并没有去分析静态状态下 onDraw 的代码,有兴趣的同学可以看看,很简单的一些代码,除了数学公式…如果又不明白的可以留言。 事实上,整个控件并没有用到多复杂、少见的API,就只有一个 drawArc 的普通API,难在效果的思考,还有逻辑的堆砌。
可是却依旧为对方擦去嘴角的油渍。