話說,在前面兩篇文章中,我們學習了BitmapShader、Path的基本使用,那麼這一篇文章,咱們接着來學習一下PathMeasure的用法。什麼,你沒聽說過PathMeasure?那你就要OUT咯~
項目效果圖
廢話不多說,在開始講解之前,先看下最終實現的效果。
效果一:
仿支付寶支付成功效果
效果二:
這兩個項目都是使用Path和PathMeature配合完成的,由其他項目改造而來
項目一是七叔寫的,我對代碼進行了大量改造。
項目二是不小心搜到的,然後進行了改造,原文請戳這裏
本文代碼請到這裏下載
PathMeasure介紹
PathMeasure這個類確實是不太常見的,關於這個類的介紹也是甚少,那麼這個類是用來幹嘛的呢?主要其實是配合Path,來計算Path裏面點的座標的,或者是給一個範圍,來截取Path其中的一部分的。
這麼說,你肯定也迷糊,咱們先簡單看一下有哪些方法,然後根據案例來進行講解更好一些。
構造方法有兩個,很好理解,不多解釋。
PathMeasure()
PathMeasure(Path path, boolean forceClosed)
重點看下常用方法:
- float getLength() 返回當前contour(解釋爲輪廓不太恰當,我覺得更像是筆畫)的長度,也就是這一個Path有多長
- boolean getPosTan(float distance, float[] pos, float[] tan) 傳入一個距離distance(0<=distance<=getLength()),然後會計算當前距離的座標點和切線,注意,pos會自動填充上座標,這個方法很重要
- boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo) 傳入一個開始和結束距離,然後會返回介於這之間的Path,在這裏就是dst,他會被填充上內容,這個方法很重要
- boolean nextContour() 移動到下一個筆畫,如果你的Path是由多個筆畫組成的話,那麼就可以使用這個方法
- void setPath(Path path, boolean forceClosed)這個方法也比較重要,用來設置新的Path對象的,算是對第一個構造函數的一個補充
仿支付寶實現原理解析
下面,我將介紹一下如何實現下面的這個效果
首先分析需求:
- 需要有三種狀態:加載中,成功,失敗
- 加載中時,需要不斷更換顏色
- 加載中狀態時,圓弧要不斷的變換長度和位置
- 成功狀態和失敗狀態,需要把√和×一筆一劃的畫出來
OK,基本就是這些需求,那麼對應着需求,咱們看一下解決方案
- 有三種狀態好說,用靜態常量或者是枚舉類型進行區分
- 不斷變換顏色也好說,只要改變Paint的顏色就可以啦
- 不斷的變化長度和位置,從效果圖上可以看出來,我們需要畫一段圓弧,那就要用下面的drawArc(),需要知道範圍,起始角度和繪製角度,由於需要不斷的變化長度,因此就需要用Animator,具體實現一會詳談
Canvas.drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter,Paint paint)
- 需要畫出來形狀,其實就是一些線段,那麼就需要用Path了,但是如何能一筆一劃的效果呢?那就要靠PathMeasure啦
下面開始講解代碼實現,最好參照着源代碼看下面的文章。
首先看怎麼用ConfirmView呢?很簡單,只需要調用animatedWithState()然後傳入一個枚舉類型即可
confirmView.animatedWithState(ConfirmView.State.Progressing);
這個枚舉類型在類的內部,代表三種狀態
public enum State {
Success, Fail, Progressing
}
再看構造函數,很簡單,只是進行了變量的初始化,這些變量的具體作用,我將在下面用到的時候重點介紹
public ConfirmView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mSuccessPath = new Path();
mPathMeasure = new PathMeasure(mSuccessPath, false);
mRenderPaths = new ArrayList<>();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0xFF0099CC);
mPaint.setStrokeWidth(STROKEN_WIDTH);
mPaint.setStrokeCap(Paint.Cap.ROUND);
oval = new RectF();
}
那麼調用了animatedWithState()之後,進行了什麼操作呢?
public void animatedWithState(State state) {
if (mCurrentState != state) {
mCurrentState = state;
if (mPhareAnimator != null && mPhareAnimator.isRunning()) {
stopPhareAnimation();
}
switch (state) {
case Fail:
case Success:
updatePath();
if (mCircleAnimator != null && mCircleAnimator.isRunning()) {
mCircleAngle = (Float) mCircleAnimator.getAnimatedValue();
mCircleAnimator.end();
}
if ((mStartAngleAnimator == null || !mStartAngleAnimator.isRunning() || !mStartAngleAnimator.isStarted()) &&
(mEndAngleAnimator == null || !mEndAngleAnimator.isRunning() || !mEndAngleAnimator.isStarted())) {
mStartAngle = 360;
mEndAngle = 0;
startPhareAnimation();
}
break;
case Progressing:
mCircleAngle = 0;
startCircleAnimation();
break;
}
}
}
結合着上面的代碼,我簡單解釋一下。
首先進行重複性的判斷,如果當前所處的狀態與要改變的狀態相同則不進行操作。
接下來,對動畫狀態進行了判斷,mPhareAnimator是用來實現√和×的動畫繪製效果的,如果正在運行,則停掉。
再往下的一個switch則是開始真正的操作了,updatePath()是更新Path,一會重點看下,mCircleAnimator這個則是實現外部弧形的偏移量的控制的,現在看不明白也沒事,重點看下下面的代碼,當mStartAngleAnimator和mEndAngleAnimator都不在運行狀態的時候(這兩個Animator是爲了控制外部弧形的起點和終點的),會進入下面的代碼,
mStartAngle = 360;
mEndAngle = 0;
startPhareAnimation();
mStartAngle和mEndAngle分別代表起點轉過的角度和終點轉過的角度,然後就startPhareAnimation(),這個時候,真正的繪製√和×的動畫纔開始執行。
如果是Progressing呢,則執行下面的代碼,重置mCircleAngle,startCircleAnimation()這個方法是繪製外部的弧形的動畫
mCircleAngle = 0;
startCircleAnimation();
至此,咱們知道了傳入不同狀態的枚舉類型會進行什麼操作,下面,開始看真正的操作。
咱先看一個簡單的,就是startCircleAnimation()到底做了什麼。
前面說過,這個方法是爲了繪製加載中狀態時,外部不斷變化的彩色弧形的,下面是代碼實現
public void startCircleAnimation() {
if (mCircleAnimator == null || mStartAngleAnimator == null || mEndAngleAnimator == null) {
initAngleAnimation();
}
mStartAngleAnimator.setDuration(NORMAL_ANGLE_ANIMATION_DURATION);
mEndAngleAnimator.setDuration(NORMAL_ANGLE_ANIMATION_DURATION);
mCircleAnimator.setDuration(NORMAL_CIRCLE_ANIMATION_DURATION);
mStartAngleAnimator.start();
mEndAngleAnimator.start();
mCircleAnimator.start();
}
首先前面的if語句是爲空判斷,從而進行初始化的操作,後面則是簡單的設置動畫的持續時間和開啓動畫。這裏一共出現了三個動畫,完成外部弧形的效果控制
- mStartAngleAnimator 控制圓弧起點
- mEndAngleAnimator 控制圓弧終點
- mCircleAnimator 控制圓弧的整體偏移量
這麼說,你可能還是不很明白,沒關係,咱們一點點的看代碼,首先,咱們看在初始化的時候,到底做了什麼操作,也就是initAngleAnimation()。
private void initAngleAnimation() {
mStartAngleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
mEndAngleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
mCircleAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
mStartAngleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (Float) animation.getAnimatedValue();
setStartAngle(value);
}
});
mEndAngleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (Float) animation.getAnimatedValue();
setEndAngle(value);
}
});
mStartAngleAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
if (mCurrentState == State.Progressing) {
if (mEndAngleAnimator != null) {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
mEndAngleAnimator.start();
}
}, 400L);
}
}
}
@Override
public void onAnimationEnd(Animator animation) {
if (mCurrentState != State.Progressing && mEndAngleAnimator != null && !mEndAngleAnimator.isRunning() && !mEndAngleAnimator.isStarted()) {
startPhareAnimation();
}
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
mEndAngleAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (mStartAngleAnimator != null) {
if (mCurrentState != State.Progressing) {
mStartAngleAnimator.setDuration(NORMAL_ANIMATION_DURATION);
}
colorCursor++;
if (colorCursor >= colors.length) colorCursor = 0;
mPaint.setColor(colors[colorCursor]);
mStartAngleAnimator.start();
}
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
mCircleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
setCircleAngle(value);
}
});
mStartAngleAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mEndAngleAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mCircleAnimator.setInterpolator(new LinearInterpolator());
mCircleAnimator.setRepeatCount(-1);
}
這段代碼雖然長,但是也沒有太大的難度,無非就是進行了初始化操作,ValueAnimator的範圍是0-1,這個在後面將用於計算角度。在值不斷的更新的過程中,分別調用了下面這三個方法,更新一些值
setStartAngle(value);
setEndAngle(value);
setCircleAngle(value);
在這三個方法裏面,都對成員變量進行了更新,並且!調用了invalidate()!看到這裏是不是激動了,改變一次就重繪一次,這三個值肯定和弧形的動畫效果有關啊!
private void setStartAngle(float startAngle) {
this.mStartAngle = startAngle;
invalidate();
}
private void setEndAngle(float endAngle) {
this.mEndAngle = endAngle;
invalidate();
}
private void setCircleAngle(float circleAngle) {
this.mCircleAngle = circleAngle;
invalidate();
}
咱知道了這個,先不着急去看onDraw(),仔細看下動畫的執行順序。
在mStartAngleAnimator執行之後,調用了下面的方法,這當然很簡單,就是說,mStartAngleAnimator執行了400毫秒之後,mEndAngleAnimator纔會執行,而且插值器設置的是AccelerateDecelerateInterpolator,爲啥呢?很簡單,因爲只有這樣,才能做出弧形長度先長後短的效果呀~
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
mEndAngleAnimator.start();
}
}, 400L);
而在mEndAngleAnimator執行結束之後,會調用下面的代碼
if (mStartAngleAnimator != null) {
if (mCurrentState != State.Progressing) {
mStartAngleAnimator.setDuration(NORMAL_ANIMATION_DURATION);
}
colorCursor++;
if (colorCursor >= colors.length) colorCursor = 0;
mPaint.setColor(colors[colorCursor]);
mStartAngleAnimator.start();
}
在這個設置mStartAngleAnimator的動畫時間,是爲了畫√或者是×的時候快一些效果更流暢。下面的代碼很簡單了吧,改變畫筆顏色,然後mStartAngleAnimator又開啓啦!這就是爲啥一直轉啊轉的原因。
但是說到這裏,咱們還沒看onDraw()做了什麼呢!
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
switch (mCurrentState) {
case Fail:
for (int i = 0; i < PATH_SIZE_TWO; i++) {
Path p = mRenderPaths.get(i);
if (p != null) {
canvas.drawPath(p, mPaint);
}
}
drawCircle(canvas);
break;
case Success:
Path p = mRenderPaths.get(0);
if (p != null) {
canvas.drawPath(p, mPaint);
}
drawCircle(canvas);
break;
case Progressing:
drawCircle(canvas);
break;
}
}
咱先看Progressing分支裏面的drawCircle(canvas),其他的先不要管
private void drawCircle(Canvas canvas) {
float offsetAngle = mCircleAngle * 360;
float startAngle = mEndAngle * 360;
float sweepAngle = mStartAngle * 360;
if (startAngle == 360)
startAngle = 0;
sweepAngle = sweepAngle - startAngle;
startAngle += offsetAngle;
if (sweepAngle < 0)
sweepAngle = 1;
canvas.drawArc(oval, startAngle, sweepAngle, false, mPaint);
}
是的,上面這段代碼就是繪製不斷變幻的環的代碼咯
float startAngle = mEndAngle * 360;是計算終點的位置,有人會感到奇怪,爲啥終點的位置叫startAngle啊!因爲終點的位置就是開始繪製的位置,所以不要奇怪了。
sweepAngle = sweepAngle - startAngle;則是計算要畫多少角度的弧線,因爲起點先跑到前面的,所以減去終點的位置,就是旋轉角度。
startAngle += offsetAngle;那麼這句是幹嘛的?這個就是所謂的偏移量,爲了要實現更隨性的從非固定點開始結束的效果。沒聽懂?我給你去掉你看下效果!
private void drawCircle(Canvas canvas) {
float offsetAngle = mCircleAngle * 360;
float startAngle = mEndAngle * 360;
float sweepAngle = mStartAngle * 360;
if (startAngle == 360)
startAngle = 0;
sweepAngle = sweepAngle - startAngle;
// startAngle += offsetAngle;
if (sweepAngle < 0)
sweepAngle = 1;
canvas.drawArc(oval, startAngle, sweepAngle, false, mPaint);
}
這下子明白了吧,去掉漂移量效果就沒有之前那麼隨性了~
ok,關於弧線的問題就說這麼多,下面就要說咱們今天的主角PathMeasure了。
在前面的代碼中,我們提到,成功和失敗狀態會執行updatePath()和startPhareAnimation(),那麼到底做了些什麼呢?
private void updatePath() {
int offset = (int) (mSignRadius * 0.15F);
mRenderPaths.clear();
switch (mCurrentState) {
case Success:
mSuccessPath.reset();
mSuccessPath.moveTo(mCenterX - mSignRadius, mCenterY + offset);
mSuccessPath.lineTo(mCenterX - offset, mCenterY + mSignRadius - offset);
mSuccessPath.lineTo(mCenterX + mSignRadius, mCenterY - mSignRadius + offset);
mRenderPaths.add(new Path());
break;
case Fail:
mSuccessPath.reset();
float failRadius = mSignRadius * 0.8F;
mSuccessPath.moveTo(mCenterX - failRadius, mCenterY - failRadius);
mSuccessPath.lineTo(mCenterX + failRadius, mCenterY + failRadius);
mSuccessPath.moveTo(mCenterX + failRadius, mCenterY - failRadius);
mSuccessPath.lineTo(mCenterX - failRadius, mCenterY + failRadius);
for (int i = 0; i < PATH_SIZE_TWO; i++) {
mRenderPaths.add(new Path());
}
break;
default:
mSuccessPath.reset();
}
mPathMeasure.setPath(mSuccessPath, false);
}
在updatePath()我們可以很清楚的看到,在這裏初始化了mSuccessPath,通過moveTo()和lineTo()首先勾勒除了√和×的形狀,至於這個座標是怎麼確定的,這個可以自己想法來,我就不介紹了。還要需要注意的是,Success中最後在mRenderPaths中添加了一個Path對象,而在Fail則添加了兩個對象,這個其實是和要繪製的圖形的筆畫數有關的,×是兩筆,所以是兩個,這裏添加的Path議會將用來紀錄每一筆畫的形狀。
最後,咱們的主角終於現身了
mPathMeasure.setPath(mSuccessPath, false);
調用完這個方法,會馬上調用下面的方法
public void startPhareAnimation() {
if (mPhareAnimator == null) {
mPhareAnimator = ValueAnimator.ofFloat(0.0F, 1.0F);
mPhareAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (Float) animation.getAnimatedValue();
setPhare(value);
}
});
mPhareAnimator.setDuration(NORMAL_ANIMATION_DURATION);
mPhareAnimator.setInterpolator(new LinearInterpolator());
}
mPhare = 0;
mPhareAnimator.start();
}
其實也很簡單,初始化了mPhareAnimator,然後開啓動畫,不斷調用setPhare(value),
private void setPhare(float phare) {
mPhare = phare;
updatePhare();
invalidate();
}
在這裏updatePhare(),然後重繪界面,那麼玄機應該都在updatePhare()了吧!
private void updatePhare() {
if (mSuccessPath != null) {
switch (mCurrentState) {
case Success: {
if (mPathMeasure.getSegment(0, mPhare * mPathMeasure.getLength(), mRenderPaths.get(0), true)) {
mRenderPaths.get(0).rLineTo(0, 0);
}
}
break;
case Fail: {
//i = 0,畫一半,i=1,畫另一半
float seg = 1.0F / PATH_SIZE_TWO;
for (int i = 0; i < PATH_SIZE_TWO; i++) {
float offset = mPhare - seg * i;
offset = offset < 0 ? 0 : offset;
offset *= PATH_SIZE_TWO;
Log.d("i:" + i + ",seg:" + seg, "offset:" + offset + ", mPhare:" + mPhare + ", size:" + PATH_SIZE_TWO);
boolean success = mPathMeasure.getSegment(0, offset * mPathMeasure.getLength(), mRenderPaths.get(i), true);
if (success) {
mRenderPaths.get(i).rLineTo(0, 0);
}
mPathMeasure.nextContour();
}
mPathMeasure.setPath(mSuccessPath, false);
}
break;
}
}
}
在這裏,一個很重要的方法調用了,那就是mPathMeasure.getSegment()
當Success的時候,會執行下面的代碼。mPhare就是動畫的百分比,從0到1,那麼,下面的這段代碼就很好理解了,這是爲了根據動畫的百分比,獲取畫出√的整個Path的一部分,然後把這部分,填充到了mRenderPaths.get(0)裏面,這裏面存放的就是在上面方法中添加進去的一個Path對象。mPhare不斷的變化,我們就能獲取到畫整個√形狀所需的所有Path對象,還記得這個方法之後是什麼嗎?invalidate()!所以,現在在onDraw()裏面肯定用這Path對象,畫出√的一部分,不斷的更新從mPhare,不斷繪製,從無到有,而出現了動畫效果。
mRenderPaths.get(0).rLineTo(0, 0);這個代碼則是爲了在4.4以下不能繪製出圖形BUG的解決方法,不要在意。
if (mPathMeasure.getSegment(0, mPhare * mPathMeasure.getLength(), mRenderPaths.get(0), true)) {
mRenderPaths.get(0).rLineTo(0, 0);
}
不信咱們看下onDraw(),是不是!那麼現在你應該知道×是怎麼畫出來的吧?
case Success:
Path p = mRenderPaths.get(0);
if (p != null) {
canvas.drawPath(p, mPaint);
}
drawCircle(canvas);
break;
來來來,咱們看下代碼!
case Fail: {
//i = 0,畫一半,i=1,畫另一半
float seg = 1.0F / PATH_SIZE_TWO;
for (int i = 0; i < PATH_SIZE_TWO; i++) {
float offset = mPhare - seg * i;
offset = offset < 0 ? 0 : offset;
offset *= PATH_SIZE_TWO;
Log.d("i:" + i + ",seg:" + seg, "offset:" + offset + ", mPhare:" + mPhare + ", size:" + PATH_SIZE_TWO);
boolean success = mPathMeasure.getSegment(0, offset * mPathMeasure.getLength(), mRenderPaths.get(i), true);
if (success) {
mRenderPaths.get(i).rLineTo(0, 0);
}
mPathMeasure.nextContour();
}
mPathMeasure.setPath(mSuccessPath, false);
}
break;
與繪製√相比,因爲×是兩筆,所以有些小複雜,但是也不難,offset *= PATH_SIZE_TWO;是爲了保證在mPhare從0-0.5過程中控制第一筆畫,0.5-1則控制第二條筆畫,你仔細看下代碼,這樣可以實現offset從0-1兩次。由於×是兩筆畫,所以在i=0取到第一筆畫的Path部分,存儲在mRenderPaths的第一個Path之後,調用了mPathMeasure.nextContour();切換到下一筆畫,再次完成相同的操作。
而由於PathMeasure只能往下找Contour,所以最後 mPathMeasure.setPath(mSuccessPath, false);回覆到最初狀態,然後我們看下onDraw()
for (int i = 0; i < PATH_SIZE_TWO; i++) {
Path p = mRenderPaths.get(i);
if (p != null) {
canvas.drawPath(p, mPaint);
}
}
drawCircle(canvas);
其實和Success差不多的,只不過是兩個Path,畫出兩筆。
OK,到這裏,這個效果就算是全部實現了,累死我了
“天女散花”實現效果解析
其實這個我並不打算詳細講,因爲一通百通,多說無益,更多的東西需要你自己研究代碼吸收,咱們就重點看下PathMeasure的用法。
其實這種效果實現的真相是這樣滴
YES!就是一些Bitmap對象沿着Path路徑移動!
那麼和PathMeasure有啥關係呢?
看下onDraw()!
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawFllower(canvas, fllowers1);
drawFllower(canvas, fllowers2);
drawFllower(canvas, fllowers3);
}
OK,再看下drawFllower()
private void drawFllower(Canvas canvas, List<Fllower> fllowers) {
for (Fllower fllower : fllowers) {
float[] pos = new float[2];
canvas.drawPath(fllower.getPath(), mPaint);
pathMeasure.setPath(fllower.getPath(), false);
pathMeasure.getPosTan(height * fllower.getValue(), pos, null);
canvas.drawBitmap(mBitmap, pos[0], pos[1] - top, null);
}
}
首先,遍歷一個Fllower集合,然後把每個Fllower所屬的Path畫出來,就是上面藍色的曲線,然後很眼熟了吧,給PathMeasure設置Path對象,然後呢,就是重點啦!height是屏幕的高度,fllower.getValue()也是一個百分比,從0-1,和前面的Animator作用相同,這句代碼就是說,我要距離爲height * fllower.getValue()處的點的座標,給我放在pos裏面!
好了,點的座標都有了,剩下的還需要說麼…
不行了,再不回家,就真回不去了,拜拜,同學們
更多參考資料
尊重原創,轉載請註明:From 凱子哥(http://blog.csdn.net/zhaokaiqiang1992) 侵權必究!
關注我的微博,可以獲得更多精彩內容