1. 程式人生 > >自定義View之炫酷的水滴ViewPageIndicator

自定義View之炫酷的水滴ViewPageIndicator

 *本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出 

        去年在是某個Android群了看到有人發了一個設計圖,覺得很好。想自己實現一下,到上網搜了一些資料,比如參考,這位兄弟已經把如何繪製一個彈性的圓寫的很詳細了,在此對他表示感謝。不過他沒有完整實現這個自定義控制元件,所以還是自己動手實現一個,但是我覺得效果和原設計還有差距,一直沒寫部落格。這幾天抽時間把裡面的效果在改了改,順便也把部落格寫了。

原始碼地址放在最後,先上效果圖:

下面開始分析寫得思路,先來個方法截圖:



        用貝塞爾曲線繪製一個圓需要12個點,如上圖所示。然後在繪製時用mPath.cubicTo()依次連線,canvas.drawPath(mPath, mPaint)就能繪製一個完整的圓了,彈性圓就是在此基礎上調整p的引數。比如{p2,p3,p4},增加X座標,會使圓向右凸起。

程式碼中XPoint為x相同的一組點:p2,p3,p4和p8,p9,p10,YPoint 同理。程式碼中的mc對應圖中的M,繪製圓時這個值是固定的,理論參考:How to create circle with Bézier curves?。p1={p5,p6,p7}.,p3={p11,p0,p1},p2={p2,p3,p4},p4={p8,p9,p10},radius為圓半徑。

private XPoint p2, p4;
private YPoint p1, p3;
private void resetP() {
    p1.setY(radius);
p1.setX(0);
p1.setMc(mc
); p3.setY(-radius); p3.setX(0); p3.setMc(mc); p2.setY(0); p2.setX(radius); p2.setMc(mc); p4.setY(0); p4.setX(-radius); p4.setMc(mc); }
resetP()在完成選項切換時都需要呼叫一下,重置繪製的圓形形狀,不然有時候會繪製不規則的圓,造成這個的原因是view重新整理頻率是有限的,有些臨界狀態直接就跳過了,導致引數沒跟著變化就繪製了影象。

下面根據兩種切換viewpager的方式分析:

第一種情況,點選indicator切換:

在onTouchEvent計算將要切換的位置,呼叫startAniTo(int currentPos, int toPos),  animator監聽setTouchAble(!animating)是禁止動畫未結束使用者又去手動滑動viewpager切換。

private boolean startAniTo(int currentPos, int toPos) {
    this.currentPos = currentPos;
    this.toPos = toPos;
    if (currentPos == toPos)
        return true;
startColor = roundColors[(this.currentPos) % 4];
endColor = roundColors[(toPos) % 4];
resetP();
startX = div + radius + (this.currentPos) * (div + 2 * radius);
distance = (toPos - this.currentPos) * (2 * radius + div) + (toPos > currentPos ? -radius : radius);
    if (animator == null) {
        animator = ValueAnimator.ofFloat(0, 1.0f);
animator.setDuration(duration);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
public void onAnimationUpdate(ValueAnimator animation) {
                mCurrentTime = (float) animation.getAnimatedValue();
invalidate();
}
        });
animator.addListener(new Animator.AnimatorListener() {
            @Override
public void onAnimationStart(Animator animation) {
                animating = true;
setTouchAble(!animating);
}

            @Override
public void onAnimationEnd(Animator animation) {
                goo();
animating = false;
setTouchAble(!animating);
}

            @Override
public void onAnimationCancel(Animator animation) {
                goo();
animating = false;
setTouchAble(!animating);
}

            @Override
public void onAnimationRepeat(Animator animation) {

            }
        });
}
    animator.start();
    if (mViewPager != null) {
        mViewPager.setCurrentItem(toPos);
}
    return true;
}

下面是dispatchDraw方法,為了更簡單看懂,我就擷取position從左向右的情況;處理臨界情況很重要,沒處理好你會發現繪製出來的是什麼鬼!

@Override
protected void dispatchDraw(Canvas canvas) {
    canvas.save();
mPath.reset();
tabNum = getChildCount();
    for (int i = 0; i < tabNum; i++) {
        canvas.drawCircle(div + radius + i * (div + 2 * radius), startY, radius, mPaintCircle);
}
    if (mCurrentTime == 0) {
        resetP();
canvas.drawCircle(div + radius + (currentPos) * (div + 2 * radius), startY, 0, mClickPaint);
mPaint.setColor(startColor);
canvas.translate(startX, startY);
p2.setX(radius);
}
    if (mCurrentTime > 0 && mCurrentTime <= 0.2) {
        if (animating)
            canvas.drawCircle(div + radius + (toPos) * (div + 2 * radius), startY, radius * 1.0f * 5 * mCurrentTime, mClickPaint);
canvas.translate(startX, startY);
p2.setX(radius + 2 * 5 * mCurrentTime * radius / 2);
} else if (mCurrentTime > 0.2 && mCurrentTime <= 0.5) {
        canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f, startY);
p2.setX(2 * radius);
p1.setX(0.5f * radius * (mCurrentTime - 0.2f) / 0.3f);
p3.setX(0.5f * radius * (mCurrentTime - 0.2f) / 0.3f);
p2.setMc(mc + (mCurrentTime - 0.2f) * mc / 4 / 0.3f);
p4.setMc(mc + (mCurrentTime - 0.2f) * mc / 4 / 0.3f);
} else if (mCurrentTime > 0.5 && mCurrentTime <= 0.8) {
        canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f, startY);
p1.setX(0.5f * radius + 0.5f * radius * (mCurrentTime - 0.5f) / 0.3f);
p3.setX(0.5f * radius + 0.5f * radius * (mCurrentTime - 0.5f) / 0.3f);
p2.setMc(1.25f * mc - 0.25f * mc * (mCurrentTime - 0.5f) / 0.3f);
p4.setMc(1.25f * mc - 0.25f * mc * (mCurrentTime - 0.5f) / 0.3f);
} else if (mCurrentTime > 0.8 && mCurrentTime <= 0.9) {
        p2.setMc(mc);
p4.setMc(mc);
canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f, startY);
p4.setX(-radius + 1.6f * radius * (mCurrentTime - 0.8f) / 0.1f);
} else if (mCurrentTime > 0.9 && mCurrentTime < 1) {
        p1.setX(radius);
p3.setX(radius);
canvas.translate(startX + distance, startY);
p4.setX(0.6f * radius - 0.6f * radius * (mCurrentTime - 0.9f) / 0.1f);
}
    if (mCurrentTime == 1) {
        lastCurrentTime = 0;
mPaint.setColor(endColor);
p1.setX(radius);
p3.setX(radius);
canvas.translate(startX + distance, startY);
p4.setX(0);
currentPos = toPos;
resetP();
canvas.translate(radius, 0);
}
    mPath.moveTo(p1.x, p1.y);
mPath.cubicTo(p1.right.x, p1.right.y, p2.bottom.x, p2.bottom.y, p2.x, p2.y);
mPath.cubicTo(p2.top.x, p2.top.y, p3.right.x, p3.right.y, p3.x, p3.y);
mPath.cubicTo(p3.left.x, p3.left.y, p4.top.x, p4.top.y, p4.x, p4.y);
mPath.cubicTo(p4.bottom.x, p4.bottom.y, p1.left.x, p1.left.y, p1.x, p1.y);
    if (mCurrentTime > 0 && mCurrentTime < 1)
        mPaint.setColor(getCurrentColor(mCurrentTime, startColor, endColor));
canvas.drawPath(mPath, mPaint);
canvas.restore();
    super.dispatchDraw(canvas);
}
mCurrentTime 是動畫變化時重新整理的值,從0到1,根據這個值重繪時計算圓的座標。我將mCurrentTime 分為下列幾種狀態:
mCurrentTime == 0: 
        這個狀態就是根據position繪製正常的圓。
mCurrentTime > 0 && mCurrentTime <= 0.2:
        這個此時圓向右凸起,但是原本的canvas.translate和上個狀態不變,所以是圓停止在當前位置並且慢慢凸起的效果。
mCurrentTime > 0.2 && mCurrentTime <= 0.5:
        這時圓開始平移,canvas.translate(startX + (mCurrentTime - 0.2f) * distance / 0.7f, startY);那為啥是除以0.7呢?因為0到0.2沒平移,0.2到0.9平移完成,0.9到1處理回彈。平移時間只有0.9-0.2=0.7,這段時間要完成一個distance的距離的平移。同時之前圓向右凸起時,p2組的點x座標總共增加了一個radius(這個決定凸起程度)。現在要把它弄回對稱橢圓,所以p1組和p3組的點要右移半個radius,同時mc調整一下使橢圓不那麼尖;
mCurrentTime > 0.5 && mCurrentTime <= 0.8:
        p1和p3的X座標繼續往右移,mc逐漸重置為原來大小,效果就是圓的最右端固定不變,左邊的凸起縮回去,
mCurrentTime > 0.8 && mCurrentTime <= 0.9:
        左邊的p4.組點往右平移過頭,圓形成凹陷,
mCurrentTime > 0.9 && mCurrentTime < 1:
        這個階段是處理回彈,p4.組點x逐漸恢復正常。表現為回彈恢復為標準圓。
mCurrentTime == 1:
        position此時真實改變了,重置為正常的圓。
        以上的每個階段在進入下個階段時,都需要重置一下p座標,因為view重新整理頻率是有限的,有些結束的臨界狀態值直接就跳過了,導致引數沒跟著變化就繪製了影象。

第二種情況,拖動viewpager切換:

viewPager.addOnPageChangeListener,在onPageScrolled中呼叫 updateDrop(position, positionOffset, positionOffsetPixels),更新位置。這裡需要注意的是點選indicator也會回撥, 若不進行判斷會造成重複的移動,所以之前在動畫開啟的監聽時設定boolean animating值。

private void updateDrop(int position, float positionOffset, int positionOffsetPixels) {
    if (animator != null)
        animator.cancel();
    if ((position + positionOffset) - currentPos > 0)
        direction = true;
    else if ((position + positionOffset) - currentPos < 0)
        direction = false;
    if (direction)
        toPos = currentPos + 1;
    else
toPos = currentPos - 1;
startColor = roundColors[(currentPos) % 4];
endColor = roundColors[(currentPos + (direction ? 1 : -1)) % 4];
startX = div + radius + (currentPos) * (div + 2 * radius);
distance = direction ? ((2 * radius + div) + (direction ? -radius : radius)) : (-(2 * radius + div) + (direction ? -radius : radius));
mCurrentTime = position + positionOffset - (int) (position + positionOffset);
    if (!direction)
        mCurrentTime = 1 - mCurrentTime;
    if (Math.abs(lastCurrentTime - mCurrentTime) > 0.2) {//突變時根據接近0或1更改為0或1;
if (lastCurrentTime < 0.1)
            mCurrentTime = 0;
        else if (lastCurrentTime > 0.9)
            mCurrentTime = 1;
}
    lastCurrentTime = mCurrentTime;
invalidate();
}
        這裡我用mCurrentTime = position + positionOffset - (int) (position + positionOffset);然而這樣計算是有問題的,比如向左滑動,它是從0到0.9幾,然後突變為0,為了這個判斷添加了一個lastCurrentTime ,根據接近接近0或1更改為0或1。

        總結一下,需要注意的是mCurrentTime 狀態的劃分、臨界狀態的處理、以及在合適的位置重置p座標,在寫的過程幾次碰到繪製的影象莫名其妙,這是p的座標問題,查詢原因一般也是狀態沒重置。

       寫在最後:昨天投稿了,今天收到郭神的回覆,會幫我釋出文章,但是由於稿件的積壓,需要等上一段時間,還是很開心,感謝郭神。