Android ViewPager 指示器控制元件的最佳實現

鍾鐘的專欄發表於2015-02-14

為什麼我說它是最實用的 ViewPager 指示器控制元件呢?它有以下幾個特點:

  • 1、通過自定義 View 來實現,程式碼簡單易懂
  • 2、使用起來非常方便
  • 3、通用性高,大部分涉及到 ViewPager 指示器的地方都能使用此控制元件
  • 4、實現了兩種指示器效果(具體請看效果圖)

一、先來看效果圖

傳統版指示器的效果圖:

流行版指示器的效果

二、分析

如果單純的要實現此功能,相信,大家都能實現,而我也不會拿出來這裡講了,這裡我是要把它打造成一個控制元件,通俗一點講就是,在以後可以直接拿來用,而不需要修改程式碼。

控制元件,那就離不開自定義 View,我在前面也講了一篇關於自定義 View 的文章 Android自定義View,你必須知道的幾點 ,雖然講的很淺,但我覺得還是非常有用處的,有興趣的可以閱讀一下,對理解這篇文章很有幫助。額,跑題了! 回顧下那兩張效果圖,整個 View 需要的資源其實只有兩張圖片;唯一的難點,就是對圖片繪製的位置如何計算;既然是實現通用型易用的控制元件,那就不能再 ViewPager 的 OnPagerChangerListener 中來改變指示器的狀態,所以這個時候,就得把 ViewPager 傳入到這個控制元件中,到這裡,分析的差不多了;

三、編碼實現功能

像白飯要一口一口的吃,這裡就得先建立一個類,然後讓他繼承之 View,前期步驟跟我的上一篇 blog 很像,就不累贅了,直接上程式碼

public class IndicatorView extends View implements ViewPager.OnPageChangeListener{

    //指示器圖示,這裡是一個 drawable,包含兩種狀態,
    //選中和飛選中狀態
    private Drawable mIndicator;

    //指示器圖示的大小,根據圖示的寬和高來確定,選取較大者
    private int mIndicatorSize ;

    //整個指示器控制元件的寬度
    private int mWidth ;

    /*圖示加空格在家 padding 的寬度*/
    private int mContextWidth ;

    //指示器圖示的個數,就是當前ViwPager 的 item 個數
    private int mCount ;
    /*每個指示器之間的間隔大小*/
    private int mMargin ;
    /*當前 view 的 item,主要作用,是用於判斷當前指示器的選中情況*/
    private int mSelectItem ;
    /*指示器根據ViewPager 滑動的偏移量*/
    private float mOffset ;
    /*指示器是否實時重新整理*/
    private boolean mSmooth ;
    /*因為ViewPager 的 pageChangeListener 被佔用了,所以需要定義一個
    * 以便其他呼叫
    * */
    private ViewPager.OnPageChangeListener mPageChangeListener ;

    public IndicatorView(Context context) {
        this(context, null);
    }

    public IndicatorView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public IndicatorView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //通過 TypedArray 獲取自定義屬性
        TypedArray typedArray = getResources().obtainAttributes(attrs, R.styleable.IndicatorView);
        //獲取自定義屬性的個數
        int N = typedArray.getIndexCount();
        for (int i = 0; i < N; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndicatorView_indicator_icon:
                    //通過自定義屬性拿到指示器
                    mIndicator = typedArray.getDrawable(attr);
                    break;
                case R.styleable.IndicatorView_indicator_margin:
                    float defaultMargin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5,getResources().getDisplayMetrics());
                    mMargin = (int) typedArray.getDimension(attr , defaultMargin);
                    break ;
                case R.styleable.IndicatorView_indicator_smooth:
                    mSmooth = typedArray.getBoolean(attr,false) ;
                    break;
            }
        }
        //使用完成之後記得回收
        typedArray.recycle();
        initIndicator() ;
    }

    private void initIndicator() {
        //獲取指示器的大小值。一般情況下是正方形的,也是時,你的美工手抖了一下,切出一個長方形來了,
        //不用怕,這裡做了處理不會變形的
        mIndicatorSize = Math.max(mIndicator.getIntrinsicWidth(),mIndicator.getIntrinsicHeight()) ;
        /*設定指示器的邊框*/
        mIndicator.setBounds(0,0,mIndicator.getIntrinsicWidth(),mIndicator.getIntrinsicWidth());
    }
}

這裡需要注意一點的就是 Drawable mIndicator這個成員變數,它是在 drawable 資料夾下定義的一個 drawable 檔案,包含了選中和為選中兩張圖片。

接著是測量工作

 /**
     * 測量View 的大小,這個方法我前面的 blog 講了很多了,
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(measureWidth(widthMeasureSpec),measureHeight(heightMeasureSpec));
    }

    /**
     * 測量寬度,計算當前View 的寬度
     * @param widthMeasureSpec
     * @return
     */
    private int measureWidth(int widthMeasureSpec){
        int mode = MeasureSpec.getMode(widthMeasureSpec) ;
        int size = MeasureSpec.getSize(widthMeasureSpec) ;
        int width ;
        int desired = getPaddingLeft() + getPaddingRight() + mIndicatorSize*mCount + mMargin*(mCount -1) ;
        mContextWidth = desired ;
        if(mode == MeasureSpec.EXACTLY){
            width = Math.max(desired, size)  ;
        }else {
            if(mode == MeasureSpec.AT_MOST){
                width = Math.min(desired,size) ;
            }else {
                width = desired ;
            }
        }
        mWidth = width ;
        return width ;
    }

    private int measureHeight(int heightMeasureSpec){
        int mode = MeasureSpec.getMode(heightMeasureSpec) ;
        int size = MeasureSpec.getSize(heightMeasureSpec) ;
        int height ;
        if(mode == MeasureSpec.EXACTLY){
            height = size ;
        }else {
            int desired = getPaddingTop() + getPaddingBottom() + mIndicatorSize ;
            if(mode == MeasureSpec.AT_MOST){
                height = Math.min(desired,size) ;
            }else {
                height = desired ;
            }
        }

        return height ;
    }

測量完了,就到了繪製 View 的階段了。這裡重點看看 onDraw()方法,先說一下,大致流程:

首先,繪製所有為選中的指示器,這裡是繪製 Drawable,所以需要用到 Canvas中的某些方法來平移畫布,讓其順序的繪製所有的 Drawable,這裡特別注意的一點就是 Canvas.restore() 方法,這個方法是在繪製完成之後,想要回到原來的位置和狀態呼叫,但它必須配合Canvas.save()來配套使用。Canvas.save()就是記錄當前畫布的狀態,所以這裡,我覺得這個方法的名字應該換成 record()是不是更符合我們的理解呢?這裡純屬個人見解,理解了就好,如何命名不妨礙我們的工作,下面是 onDraw()的程式碼,註釋很詳細

/**
     * 繪製指示器
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {

        /*
        * 首先得儲存畫布的當前狀態,如果位置行這個方法
        * 等一下的 restore()將會失效,canvas 不知道恢復到什麼狀態
        * 所以這個 save、restore 都是成對出現的,這樣就很好理解了。
        * */
        canvas.save() ;
        /*
        * 這裡開始就是計算需要繪製的位置,
        * 如果不好理解,請按照我說的做,拿起
        * 附近的紙和筆,在紙上繪製一下,然後
        * 你就一目瞭然了,
        *
        * */
        int left = mWidth/2 - mContextWidth/2 +getPaddingLeft() ;
        canvas.translate(left,getPaddingTop());
        for(int i = 0 ; i < mCount ; i++){
            /*
            * 這裡也需要解釋一下,
            * 因為我們額 drawable 是一個selector 檔案
            * 所以我們需要設定他的狀態,也就是 state
            * 來獲取相應的圖片。
            * 這裡是獲取未選中的圖片
            * */
            mIndicator.setState(EMPTY_STATE_SET) ;
            /*繪製 drawable*/
            mIndicator.draw(canvas);
            /*每繪製一個指示器,向右移動一次*/
            canvas.translate(mIndicatorSize+mMargin,0);
        }
        /*
        * 恢復畫布的所有設定,也不是所有的啦,
        * 根據 google 說法,就是matrix/clip
        * 只能恢復到最後呼叫 save 方法的位置。
        * */
        canvas.restore();
        /*這裡又開始計算繪製的位置了*/
        float leftDraw = (mIndicatorSize+mMargin)*(mSelectItem + mOffset);
        /*
        * 計算完了,又來了,平移,為什麼要平移兩次呢?
        * 也是為了好理解。
        * */
        canvas.translate(left,getPaddingTop());
        canvas.translate(leftDraw,0);
        /*
        * 把Drawable 的狀態設為已選中狀態
        * 這樣獲取到的Drawable 就是已選中
        * 的那張圖片。
        * */
        mIndicator.setState(SELECTED_STATE_SET) ;
        /*這裡又開始繪圖了*/
        mIndicator.draw(canvas);

    }

現在我們的控制元件其實就差一步沒有實現了,就是在何時何地更新 View,一開始就分析了,這個 View 是需要傳入 ViewPager 的,傳入 ViewPager 的目的是什麼,其實有三個:

1、獲取 ViewPager 的 item 的個數,從而來確定指示器的個數;
2、獲取當前 ViewPager 選中的 item,也是確定指示器選中的 item;
3、獲取 OnPagerChangeListener,來控制 View 什麼時候需要重新整理;

 /**
     * 此ViewPager 一定是先設定了Adapter,
     * 並且Adapter 需要所有資料,後續還不能
     * 修改資料
     * @param viewPager
     */
    public void setViewPager(ViewPager viewPager){
        if(viewPager == null){
            return;
        }
        PagerAdapter pagerAdapter = viewPager.getAdapter() ;
        if(pagerAdapter == null){
            throw new RuntimeException("請看使用說明");
        }
        mCount = pagerAdapter.getCount() ;
        viewPager.setOnPageChangeListener(this);
        mSelectItem = viewPager.getCurrentItem() ;

        invalidate();
    }

    public void setOnPageChangeListener(ViewPager.OnPageChangeListener mPageChangeListener) {
        this.mPageChangeListener = mPageChangeListener;
    }

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        Log.v("zgy","========"+position+",===offset" + positionOffset) ;
        if (mSmooth){
            mSelectItem = position ;
            mOffset = positionOffset ;
            invalidate();
        }
        if(mPageChangeListener != null){
            mPageChangeListener.onPageScrolled(position,positionOffset,positionOffsetPixels);
        }
    }

    @Override
    public void onPageSelected(int position) {

        mSelectItem = position ;
        invalidate();

        if(mPageChangeListener != null){
            mPageChangeListener.onPageSelected(position);
        }
    }

    @Override
    public void onPageScrollStateChanged(int state) {

        if(mPageChangeListener != null){
            mPageChangeListener.onPageScrollStateChanged(state);
        }
    }

這個位置也有個點需要提一下,就是當 mSmooth 為 true 的時候,這個時候是需要實時重新整理的,所以需要在onPageScrolled(int position, float positionOffset, int positionOffsetPixels)呼叫 invalidate(),並把偏移量儲存起來,用於計算繪製指示器的位置。

好了,以上就是指示器控制元件的實現全過程

既然是一個控制元件,接下來看看在 xml 是如何引用的

  <com.gyzhong.viewpagerindicator.IndicatorView
        android:id="@+id/id_indicator"
        android:layout_centerHorizontal="true"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="5dp"
        zgy:indicator_icon="@drawable/indicator_selector"
        zgy:indicator_margin="5dp"/>

再來看看程式碼中的引用

mIndicatorView = (IndicatorView) findViewById(R.id.id_indicator) ;
mIndicatorView.setViewPager(mViewPager);

程式碼簡潔明瞭。

四、總結

整體來說,不是很難,程式碼量很少,主要用到的知識點,1、自定義屬性,2、如何測量 View,2、Cavans 中一些方法的使用。

相關文章