自定義View——仿騰訊TIM下拉重新整理View

蘆葦科技App技術團隊發表於2019-01-05

一 概述

自定義 View 是 Android 開發裡面的一個大學問。偶然間看到 TIM 郵箱介面的重新整理 View 還挺好玩的,於是就自己動手實現了一個,先看看 TIM 裡邊的效果圖:

TIM_refresh.gif

二 需求分析

看到上面的動圖,大概也知道我們需要實現的功能:

  • 根據拖動的進度來移動小球的位置
  • 小球移動過程的動畫

三 功能實現

新建一個 RefreshView 類繼承自 View ,然後我們再在 RefreshView 裡面新建一個內部實體類: Circle 來看一下 Circle類的程式碼

#Cirlce.java

 class Circle {
        int x;
        int y;
        int r;
        int color;

        public Circle(int x, int y, int r, int color) {
            this.x = x;
            this.y = y;
            this.r = r;
            this.color = color;
        }
    }
複製程式碼

這是一個實體類,裡面提供了 x , y , r , color 屬性分別代表圓心座標的 x值,y值,圓的半徑 r 跟顏色。 藉助此類來儲存小圓球的相關屬性。

接下來就是我們平時自定義 View 經常要重寫的三大方法了,先看 onMeasure()

#RefreshView.java

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(mWidth, heightSize);
        } else if (widthMeasureSpec == MeasureSpec.EXACTLY && heightMeasureSpec == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, mHeight);
        } else if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize);
        } else {
            setMeasuredDimension(mWidth, mHeight);
        }
    }
複製程式碼

為了適配佈局檔案中的 wrap_content 引數,我們需要重寫此方法(此方法不是本文的研究重點,不明白的可以百度或者google一下,或者參考《Android開發藝術探索》裡面的相關章節)。

接著看 onLayout() 方法:

#RefreshView.java

 @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        initContentAttr(getMeasuredWidth(), getMeasuredHeight());
        resetCircles();
    }
複製程式碼

在此方法中呼叫了 initContentAttr() 方法來初始化內容大小與 resetCircles() 來初始化(重置)三個小球的屬性。分別看下這兩個方法:

#RefreshView.java

    private void initContentAttr(int width, int height) {
        mContentWidth = width - getPaddingLeft() - getPaddingRight();
        mContentHeight = height - getPaddingTop() - getPaddingBottom();
    }
複製程式碼

這方法很簡單,就是進行了 padding 的處理,得出真正的佈局大小。如果不處理 padding 的話那麼使用者設定了 padding 將失效。再看 resetCircles()

#RefreshView.java

    public static final int STATE_ORIGIN = 0;
    public static final int STATE_PREPARED = 1;
    private int mOriginState = STATE_ORIGIN;

    private void resetCircles() {
        if (mCircles.isEmpty()) {
            int x = mContentWidth / 2;
            int y = mContentHeight / 2;
            mGap = x - mMinRadius;   //初始化相鄰圓心間的最大間距
            Circle circleLeft = new Circle(x, y, mMinRadius, 0xffff7f0a);
            Circle circleCenter = new Circle(x, y, mMaxRadius, Color.RED);
            Circle circleRight = new Circle(x, y, mMinRadius, Color.GREEN);
            mCircles.add(LEFT, circleLeft);
            mCircles.add(RIGHT, circleRight);
            mCircles.add(CENTER, circleCenter);
        }
        if (mOriginState == STATE_ORIGIN) {
            int x = mContentWidth / 2;
            int y = mContentHeight / 2;
            for (int i = 0; i < mCircles.size(); i++) {
                Circle circle = mCircles.get(i);
                circle.x = x;
                circle.y = y;
                if (i == CENTER) {
                    circle.r = mMaxRadius;
                } else {
                    circle.r = mMinRadius;
                }
            }
        } else {
            prepareToStart();
        }
    }

複製程式碼

此方法用於初始化和重置小球,方法裡面進行的兩個大的 if...else 語句判斷,第一個 if 用於判斷是否應該初始化小球,第二個語句則是用於判斷小球的初始化時候的形態。可以在外部呼叫 setOriginState() 方法來指定小球的初始化形態,如不指定,則預設為 NOMAL,即三球重合。

#RefreshView.java

 /**
     * 設定圓球初始狀態
     * {@link #STATE_ORIGIN}為原始狀態(三個小球重合),
     * {@link #STATE_PREPARED}為準備好可以重新整理的狀態,三個小球間距最大
     */
    public void setOriginState(int state) {
        if (state == 0) {
            mOriginState = STATE_ORIGIN;
        } else {
            mOriginState = STATE_PREPARED;
        }
    }
複製程式碼

最後就是最有趣的方法 onDraw() 了:

#RefreshView.java

    @Override
    protected void onDraw(Canvas canvas) {
        for (Circle circle : mCircles) {
            mPaint.setColor(circle.color);
            canvas.drawCircle(circle.x + getPaddingLeft(), circle.y + getPaddingTop(), circle.r, mPaint);
        }
    }
複製程式碼

這方法很簡單,就是將 mCircles 列表裡面的圓畫出來而已(裡面進行了 padding 的處理)。

三大方法都講完了,可是這只是畫出了幾個小圓球而已,我們需求分析裡的需求還沒實現呢,上面的方法已經把 View 的基礎搭起來了,要實現這個也就不難了。接下來就是大家期待的需求實現了:

  • 根據拖動的進度來移動小球的位置

    實現程式碼如下:

    #RefreshView.java

        public void drag(float fraction) {
            if (mOriginState == STATE_PREPARED) {
                return;
            }
            if (mAnimator != null && mAnimator.isRunning()) {
                return;
            }
            if (fraction > 1) {
                return;
            }
            mCircles.get(LEFT).x = (int) (mMinRadius + mGap * (1f - fraction));
            mCircles.get(RIGHT).x = (int) (mContentWidth / 2 + mGap * fraction);
            postInvalidate();
        }
    複製程式碼

    在方法裡面進行三次判斷,如果初始狀態是 STATE_PREPARED (三小球距離最大,沒必要再變動了)、動畫正在進行或者進度大於1 都不進行移動。然後修改小球的屬性,再重繪。

  • 小球移動過程的動畫

    這個是這個自定義 View 最難的部分了,需要一些數學的小運算,有點繁瑣。

    我們先來理清實現動畫的邏輯,看了開篇的gif,應該可以瞭解到,剛準備開始動畫時,左邊的小球應該是處於最左端,中間的小球處於中間,右邊的處於最右端。我們一個個小球來分析。

    1. 左邊小球:動畫開始後,左邊的小球向右移動,並且逐漸變大,直到小球運動到中點,過了中點後小球繼續往右移動,不過卻逐漸變小,到了終點後小球將消失(消失過程為先縮小再消失,下同),接著又從左邊出現(出現過程也是從小到大的漸變,下同),然後重複上述過程。

    2. 中間小球:中間的小球先向右移動,逐漸縮小,然後消失,後來再從左邊出現,最後移動到中間,其間逐漸變大。後面就是重複的上述動作。

    3. 右邊小球:右邊的小球則是先消失,再從左邊出現,接著移動到中間,其間逐漸變大,然後再從中點移動到末端,其間逐漸縮小。

    理清小球的移動過程對程式碼的實現很有幫助,我們可以分析出:

    1)每個小球對於座標系的移動特點是一樣的。 2)每個小球對於動畫的進度的移動特點是不一樣的。

    聽起來好像有點拗口,我們用人話來解釋一下: 1)每個小球對於座標系的移動特點是一樣的:左邊的小球在座標的最左邊是先出現,然後再向右移動,那麼中間和右邊的小球呢?其實是同樣的,它們在座標軸最左邊的時候都是先出現,再向右移動,無論哪個小球,它們在座標軸的同一點上的動作和形態應該是一致的。 2)每個小球對於動畫的進度的移動特點是不一樣的:左邊的小球在動畫剛開始時是處於最左端,而中間的小球卻在中間位置,右邊的則在最右端。當動畫開始後,比如進行了一半,這時候左邊的小球應該移動到了中點附近,而中間的確是在末端(消失),右邊的小球就會出現在中間附近。

    按照上面分析的邏輯,我把動畫的總進度分為6份,為什麼是6份呢?通過上面的動畫分析,知道小球應該經歷一下過程(不分時間先後):

    1. 出現 (從無漸變到初始大小)
    2. 從最左端移動到中點(期間變大)
    3. 從中點移動到末端(期間縮小)
    4. 消失 (從初始大小漸變到消失)

    為了讓小球之間的間隔保持一個優美的狀態(動畫開始後小球間不會重疊,相鄰小球的間隔基本一致),就把1、4出現和消失階段分別設為 1/6 的動畫週期,中間2、3兩個階段分別佔用 1/3 個動畫週期。

    座標.png
    這樣一來,出現跟消失佔用了 1/3 動畫進度,其他兩個部分分別佔用了 1/3 動畫進度。舉個例子:剛開始動畫時,設最左邊的小球為 1,中間的小球為 2,最右端的小球為 3 。

    小球1 移動到中點時,這時動畫進行了 1/3 ,那麼此時的 小球2 就應該移動到末端,小球3 則剛好經歷消失和出現過程,於是應該出現於座標軸的起點。

    由此可以看到又恢復到了剛開始時候的情況(一個小球在最左,一個在中,一個在最右),只不過是顏色不同了而已。以此類推,無限迴圈,就可以形成優美的動畫了。

    分析出這些有什麼用呢?我發現用座標來確定小球的移動實現起來會有點小問題,所以就用動畫的進度來實現,下面看具體實現。

    需要實現小球的無限運動,最實用的就是用動畫來實現,這裡我用了屬性動畫。先初始化 Animotor 類:

    #RefreshView.java

        private void initAnimator() {
            ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
            animator.setDuration(1500);
            animator.setRepeatCount(-1);
            animator.setRepeatMode(ValueAnimator.RESTART);
            animator.setInterpolator(new LinearInterpolator());
            animator.addListener(new Animator.AnimatorListener() {
                @Override
                public void onAnimationStart(Animator animation) {
                    prepareToStart();  //確保View達到可以重新整理的狀態
                }
    
                @Override
                public void onAnimationEnd(Animator animation) {
    
                }
    
                @Override
                public void onAnimationCancel(Animator animation) {
                }
    
                @Override
                public void onAnimationRepeat(Animator animation) {
                }
            });
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    for (Circle circle : mCircles) {
                        updateCircle(circle, mCircles.indexOf(circle), animation.getAnimatedFraction());
                    }
                    postInvalidate();
                }
            });
            mAnimator = animator;
        }
    複製程式碼

    可以看到,這是一個無限迴圈的動畫,如果不手動停止,它就會一直迴圈下去。對於 mAnimator ,還新增了一個監聽器,當開始動畫是就呼叫 prepareToStart() 方法,這個方法看起來是不是有點眼熟,沒錯,它就是我們上面 resetCircles() 裡面判斷小球形態為 STATE_PREPARED 是呼叫過,此方法將確保小球達到重新整理的臨界點。我們主要看看 UpdateLisener 中的 onAnimationUpdate() 方法裡面的 updateCircle() 方法:

    #RefreshView

        private void updateCircle(Circle circle, int index, float fraction) {
            float progress = fraction;  //真實進度
            float virtualFraction;      //每個小球內部的虛擬進度
            switch (index) {
                case LEFT:
                    if (fraction < 5f / 6f) {
                        progress = progress + 1f / 6f;
                    } else {
                        progress = progress - 5f / 6f;
                    }
                    break;
                case CENTER:
                    if (fraction < 0.5f) {
                        progress = progress + 0.5f;
                    } else {
                        progress = progress - 0.5f;
                    }
                    break;
                case RIGHT:
                    if (fraction < 1f / 6f) {
                        progress += 5f / 6f;
                    } else {
                        progress -= 1f / 6f;
                    }
                    break;
            }
            if (progress <= 1f / 6f) {
                virtualFraction = progress * 6;
                appear(circle, virtualFraction);
                return;
            }
            if (progress >= 5f / 6f) {
                virtualFraction = (progress - 5f / 6f) * 6;
                disappear(circle, virtualFraction);
                return;
            }
            virtualFraction = (progress - 1f / 6f) * 3f / 2f;
            move(circle, virtualFraction);
        }
    複製程式碼

    我用了一個 virtualFraction 來表示每個小球的虛擬進度(相當於上面座標圖中的下值,即座標百分比),例如當動畫的總進度為 0 時,左小球的虛擬進度就應該是 1/6+0 (預設已經經過了出現過程,消耗了 1/6),中間小球的虛擬進度為 1/6+1/3+0 = 1/2 (預設經歷了出現,移動到中間過程),最右邊小球的虛擬進度為 1/6+1/3+1/3+0 = 5/6 。然後動畫的總進度到 1/3 時,左小球的虛擬進度就為 1/2 (中間位置)......

    下面再看下 move()appear()disapear() 方法:

    #RefreshView

        private void appear(Circle circle, float fraction) {
            circle.r = (int) (mMinRadius * fraction);
            circle.x = mMinRadius;
        }
    
        private void disappear(Circle circle, float fraction) {
            circle.r = (int) (mMinRadius * (1 - fraction));
        }
    
        private void move(Circle circle, float fraction) {
            int difference = mMaxRadius - mMinRadius;
            if (fraction < 0.5) {
                circle.r = (int) (mMinRadius + difference * fraction * 2);
            } else {
                circle.r = (int) (mMaxRadius - difference * (fraction - 0.5) * 2);
            }
            circle.x = (int) (mMinRadius + mGap * 2 * fraction);
        }
    複製程式碼

    這個三個方法都很簡單,根據座標的佔比來計算出小球的座標跟大小。

    以上就是整個 RefershView 的實現了,如果需要看原始碼的可以拉到文末。

四 使用及效果

看下怎麼使用:

#MainActivity

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mRefreshView = findViewById(R.id.refresh_view);
//        mRefreshView.setOriginState(RefreshView.STATE_PREPARED);
        Button start = findViewById(R.id.start);
        Button stop = findViewById(R.id.stop);
        SeekBar seekBar = findViewById(R.id.seek_bar);
        seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            @Override
            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
                mRefreshView.drag(progress / 100f);
            }

            @Override
            public void onStartTrackingTouch(SeekBar seekBar) {

            }

            @Override
            public void onStopTrackingTouch(SeekBar seekBar) {

            }
        });
        start.setOnClickListener(this);
        stop.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.start:
                mRefreshView.start();
                break;
            case R.id.stop:
                mRefreshView.stop();
                break;
        }
    }
複製程式碼

效果圖:

RefreshViewDemo.gif
由於錄製軟體的問題,綠色的小球顯示效果不太好,在手機或虛擬機器上顯示是正常的。再看個專案裡的實際運用效果:
BirdNewsDemo.gif
錄屏軟體對綠色好像過敏,將就看一下吧。 此文到此就結束了,感謝閱讀,喜歡的動動小手點個贊。

Demo 地址:github.com/gminibird/R…

相關文章