Android自定義view之ViewPager指示器

Android自定義view之ViewPager指示器。

ViewPager應該是我們平日裡接觸很多的控件,它允許我們在一個Activity中容納多個界面,可以來回切換。ViewPager這個是在support v4包中,但是我們切換頁面的時候總想知道我們現在在哪個頁面、或者當前頁面的標題,也就是我們需要一個指示器(Indicator)。可是官方好像一直都沒有一個像樣的這樣的控件來做這件事。其實要實現的方法也很多。完全可以自己用xml佈局文件寫出來,不過這樣就不好動態改變頁面瞭,或者幹脆指示器也用一個ViewPager來寫,但ViewPager一次隻顯示一個界面也有點別扭。那我們就自己動手實現一個吧。

首先來看一下效果:
ViewPagerIndicator效果
可以看到,指示器中的橫線以及文字是會隨著ViewPager的變化而變化,並且指示器可以容納超出自己邊界的tags,並在適當的時候移動。點擊對應的tag,ViewPager也會發生對應的變化。
接下來我們一步一步開始寫。

1. 制定參數

每個View都有可以設置的參數,來設置View的外觀和行為。對於一個指示器,我們應該有以下一些可自行定制的參數:
(1) 指示器的文字和橫線顏色(文字顏色包括被選中的和未被選中的)
(2) 文字大小以及橫線的高度
(3) tag之間的距離
(4) 佈局模式,分為平衡模式和間距佈局模式。平衡模式是為瞭所有tag的長度和不足以填滿指示器時,將tags平均地進行分佈
(5) 除此之外,還包括其他的一些常規view的熟悉,比如padding等
接下來我們就可以制定View的屬性瞭,首先在values文件夾下新建attrs_text_indicator.xml。內容如下:



    
        
        
        
        
        
        
        
        
        
        
        
        
        
            
            
        
        
        
    

註意的是,某些屬性由於時間的關系,隻是想到瞭但並沒有實現它,日後有空瞭再完善。
另外很中要的是我們指示器的佈局原則:(1)指示器橫線緊貼指示器底部,不考慮指示器的paddingBottom影響。(2)顯示文字的TextView寬和高都是WRAP_CONTENT,並且在指示橫線的頂部到指示器的頂部這片空間中居中佈局。(3)指示器中的橫線的寬度應該和當前對應的tag寬度一致。(4)平衡佈局應用的情況應該是子View的寬度和比指示器的寬度小,此時子view的左右邊界不會超出指示器左右邊界。如果在相反的情況下仍然應用平衡佈局,子view會緊挨著彼此,並且會超出邊界進行佈局,有可能會出現不期望的行為,因此不應該在這種情況下使用平衡佈局。

2. 開始編寫

在之前的文章中,我們已經談到過,對於自定義一個View,我們大概能有4種選擇。1:完全重寫一個View。2:繼承特定的View並重寫其中的一些方法。3:完全重寫一個ViewGroup。4:繼承特定的ViewGroup並重寫某些方法。以我們目前的需求,是對文字的操作,並且其中還涉及到對一條橫線的操作。顯然不需要用View,我們就繼承ViewGroup。思路就是將文字放在TextView中,然後將TextView按照要求在這個View中佈局。
新建一個類:

public class TextViewPagerIndicator extends ViewGroup
{
    MyLog log = new MyLog("TextViewPagerIndicator", true);
    /**
     * 佈局顯示相關的參數
     * */
    private int textColor = Color.parseColor("#000000");
    private int backgroundColor = Color.parseColor("#ffffff");
    private int textSize = 10;
    private int indicatorColor = Color.parseColor("#ffffff");
    private int indicatorHeight = 3;
    /*tag之間的間隔*/
    private int interval = 10;
    private int textPadding = 0;
    private int textPaddingTop = 0;
    private int textPaddingBottom = 0;
    private int textPaddingLeft = 0;
    private int textPaddingRight = 0;
    private int selectedTextColor;
    private boolean balanceLayout = false;


    private enum IndicatorStyle
    {
        line, background
    }

    private IndicatorStyle indicatorStyle = IndicatorStyle.line;
    private int indicatorDrawable = -1;




    private ArrayList tags = new ArrayList<>();
    private HashMap tagMap = new HashMap<>();
    private ImageView indicatorLine;
    /*目前選中的位置*/
    private int currentPosition = 0;

    private boolean expanded = false;
    /*最前面的TextView的左邊與指示器左邊的偏離值,為0時代表對準指示器左側,小於0代表在指示器左側的左邊,大於0時相反*/
    private int textOffset = 0;
    /*tag的點擊監聽器*/
    private ArrayList onTagClickedListeners = new ArrayList<>();


    /**
     * 事件相關參數
     * */

    private float newX, newY, lastX, lastY, dx, dy, downX, downY;
    /*判斷是否是滑動的閾值*/
    private int touchSlop = 5;
}

註意有一些屬性並沒有應用,是為以後升級所預留的。比如IndicatorStyle等。
然後是構造函數,自定義View時是要求自己寫構造函數的,而在構造函數中,我們就可以將用戶在佈局xml文件中設置的屬性拿到手,然後對我們的屬性進行賦值或初始化工作。

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

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

    public TextViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public TextViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TextViewPagerIndicator);
        textColor = a.getColor(R.styleable.TextViewPagerIndicator_textColor, textColor);
        backgroundColor = a.getColor(R.styleable.TextViewPagerIndicator_backgroundColor, backgroundColor);
        indicatorColor = a.getColor(R.styleable.TextViewPagerIndicator_indicatorColor, indicatorColor);
        textSize = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_textSize, textSize);
        indicatorHeight = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_indicatorHeight, indicatorHeight);
        interval = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_intervalBetweenTags, interval);
        selectedTextColor = a.getColor(R.styleable.TextViewPagerIndicator_selectedTextColor, indicatorColor);
        balanceLayout = a.getBoolean(R.styleable.TextViewPagerIndicator_balanceLayout, balanceLayout);
        a.recycle();
        indicatorLine = new ImageView(context);
        indicatorLine.setBackgroundColor(indicatorColor);
        this.addView(indicatorLine);

        touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
        this.setClickable(true);
        textOffset = getPaddingLeft();

        if(isInEditMode())
        {
            for(int i = 0; i < 5; i++)
            {
                addTag("item" + i);
            }
        }
    }

拿到佈局文件中設置的屬性,我們需要TypedArray對象。然後按照我們之前的attrs中定義的屬性來獲取屬性值,R.styleable.TextViewPagerIndicator就是我們之前的attrs中定義的。然後就如同取普通的鍵值對存儲一樣,傳入屬性名稱和默認值。如果在佈局文件中設置瞭,那我們就能拿到,否則就返回我們傳入的默認值。
isInEditMode()這是自定義View的一個預覽方法。返回true代表當前是在佈局的編輯模式中。自定義的View中如果沒有正確處理這個流程,就無法在編輯的時候實時預覽。我們在這裡添加瞭5個tag,在編輯的時候就能夠實時查看效果瞭。
touchSlop是一個閾值。在處理觸摸事件時有用。之前已經說過,單指觸摸事件是由ACTION_DOWN開始,中間有若幹ACTION_MOVE,結尾是ACTION_UP。基本所有的觸摸事件,哪怕是很快地點擊屏幕,都會有ACTION_MOVE事件發生。所以不能單純地以ACTION的類型來判斷事件性質。因此設置一個閾值,當ACTION_MOVE發生時,我們判斷移動距離是否超過閾值,如果是,則代表用戶現在確實要進行滑動操作。否則我們就認為這個ACTION_MOVE是意外發生的,用戶沒有要滑動。而這個閾值不可過大也不可過小,過小會導致作用不明顯,過大則會導致滑動時有卡頓感(尤其是在慢速滑動時)。因此一般設置為4或5即可。

3. Measure過程

measure過程的主要作用就是測量自己和子view的大小,而自身的大小又和子view的大小息息相關。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        log.v("onMeasure");
        /*如果這個View不進行任何繪制操作,則設置為true,以便系統進行優化*/
        setWillNotDraw(true);

        /*獲取父view傳遞給我們的寬和高的SpecMode和SpecSize*/
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        /*設置用來測量TextView寬度的MeasureSpec,由於tag是一定要完整的單行顯示,因此我們將寬度的SpecMode設置為UNSPECIFIED,即
        * 要多大給多大*/
        int unspecifiedWidthMeasureSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.UNSPECIFIED);
        /*設置用來測量TextView高度的MeasureSpec,不同於寬度,高度上TextView不能比我們指示器的高度更大,還要減去padding值和預留給橫線的空間*/
        int atMostHeightMeasureSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.AT_MOST);

        /*寬度結果,要考慮paddingLeft和paddingRight*/
        int resultWidthSize = getPaddingLeft() + getPaddingRight();
        /*高度結果,要考慮paddingTop和paddingBottom,還有給橫線預留的位置*/
        int resultHeightSize = getPaddingTop() + getPaddingBottom() + indicatorHeight;

        for(String tag : tags)
        {
            /*依次對每一個TextView進行佈局,並將寬度累加到寬度結果裡*/
            TextView child = tagMap.get(tag);
            ViewGroup.LayoutParams childLayoutParams = child.getLayoutParams();
            int childWidthSpec = getChildMeasureSpec(unspecifiedWidthMeasureSpec, resultWidthSize, childLayoutParams.width);
            int childHeightSpec = getChildMeasureSpec(atMostHeightMeasureSpec, resultHeightSize, childLayoutParams.height);
            child.measure(childWidthSpec, childHeightSpec);
            resultWidthSize += child.getMeasuredWidth();

        }

        /*最終完全確定寬度和高度結果。註意到如果我們不是平衡佈局,那麼寬度結果還要加上tag之間的距離。對於高度結果,我們隻要隨便
        * 取一個已經測量過的TextView將其高度加進去即可*/
        if(tags != null && tags.size() != 0)
        {
            resultHeightSize += tagMap.get(tags.get(0)).getMeasuredHeight();
            if(!balanceLayout)
            {
                resultWidthSize += (tags.size() - 1) * interval;
            }
        }

        /*結合父view傳給我們的SpecMode來確定我們這個指示器layout的最終大小。如果是AT_MOST,那大小不能超過父View傳給我們的SpecSize,
        * 如果是EXACTLY,那就直接將SpecSize作為我們的結果,而不管我們之前測量的寬度和高度結果*/
        if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST)
        {
            setMeasuredDimension(resultWidthSize > widthSize ? widthSize : resultWidthSize,
                    resultHeightSize > heightSize ? heightSize : resultHeightSize);
        }else if(widthMeasureSpec == MeasureSpec.AT_MOST)
        {
            setMeasuredDimension(resultWidthSize > widthSize ? widthSize : resultWidthSize, heightSize);
        }else if(heightMeasureSpec == MeasureSpec.AT_MOST)
        {
            setMeasuredDimension(widthSize, resultHeightSize > heightSize ? heightSize : resultHeightSize);
        }else
        {
            setMeasuredDimension(widthSize, heightSize);
        }


    }

測量的主要思路就是先測量所有子view的大小,並且計算所有子view的寬度和、tag之間的距離以及指示器的padding值等。由於我們指示器暫時隻支持橫向佈局,因此高度上我們隻要考慮任意一個子view的高、指示器橫線的高度以及指示器本身的padding值即可。
需要註意的是getChildMeasureSpec(int spec, int padding, int childDimension)這個方法,它是ViewGroup的靜態方法,可以依據父View傳遞給我們的MeasureSpec、padding值以及childDimension(子view的寬或高)來生成子view的MeasureSpec。此處我們通過偽造瞭父View傳遞給我們(就是指示器本身,要註意到在measure方法裡會有兩種MeasureSpec,一種是父View傳給我們的,另一種是我們生產的用於測量子View的)的Spec來測量子view,寬度偽造成UNSPECIFIED的,而高度偽造成AT_MOST。其實對於UNSPECIFIED的MeasureSpec,SpecSize傳入多少都沒關系,因為它就代表子View想要多大要多大,不必理會父容器(也就是我們的指示器本身)的尺寸。padding值其實並不是單純的padding值,而是父容器在這個方向上已經被用掉的尺寸,比如高度上,我們除瞭考慮paddingTop和paddingBottom,還要考慮到橫線的高度。而childDimension是我們取自TextView的LayoutParams中的值,此時它並不是具體的尺寸,而是WRAP_CONTENT,即-2,因為我們在new一個TextView時給它設置的就是WRAP_CONTENT。這個在後面會看到。關於getChildMeasureSpec(int spec, int padding, int childDimension)這個方法更多的說明以及它如何生產子View的MeasureSpec,可以看我的文章《Android自定義view之measure、layout、draw三大流程 》。
如果已經瞭解瞭Measure的詳細流程,其實這裡生產子View的MeasureSpec壓根不用這麼麻煩,也不用偽造指示器的MeasureSpec,隻要直接制造子View的MeasureSpec即可。寬度上我們TextView一定要單行完整顯示,那麼可以直接寫childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);高度上就比較麻煩瞭,因為我們對於TextView的高度是有限制的,即childHeight <= height - paddingTop - paddingBottom - indicatorLine.getHeight(),由於後面3個屬於已知的值,因此我們主要要確定height值。這要根據父View傳給指示器的heightMeasureSpec來確定。可分為兩種情況:
1. SpecMode == UNSPECIFIED,此時我們也無法確定指示器的高度,所以直接構造TextView的spec為childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)即可。
2. SpecMode == AT_MOST 或 SpecMode == EXACTLY:這個時候我們可以知道指示器最大的高度值就是父View傳遞給指示器的MeasureSpec中的SpecSize,而TextView的高都是WRAP_CONTENT的,因此隻要設置子View的SpecMode為AT_MOST即可。即childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(remainingSize, MeasureSpec.AT_MOST),其中remainingSize是指示器在高度上能留給TextView空間。在這裡,可以設置為remainingSize = height - paddingTop - paddingBottom - indicatorLine.getHeight()。

之後我們就可以使用剛制作的子view的寬度和高度的MeasureSpec來測量子view瞭,調用TextView的measure方法,並將MeasureSpec傳入即可。

接下來TextView有瞭測量寬和高(getMeasuredHeight()和getMeasuredWidth()可以取到有效值瞭)。然後每測量一個TextView,我們就把它的measuredWidth累加到resultWidth中。直到測量完所有的TextView,現在resultWidth是所有TextView的寬度之和,另外再加上指示器的paddingLeft和paddingRight。然後就要看是否是平衡佈局,如果不是平衡佈局,那麼我們還要加上tag間的間距。高度就簡單得多,因為是橫向佈局的,所以高度就是paddingTop、paddingBottom、一個TextView的高和橫線高度之和。

現在已經得到瞭resultWidth和resultHeight,接下來就是對指示器的大小做最終的決定,這時我們要結合指示器的SpecMode來決定。詳細的流程已經在以前文章中measure那一節講過瞭,代碼裡也很清楚。最後別忘瞭調用setMeasuredDimension將結果應用到View。

4. Layout過程

對於ViewGroup來說,最重要的就是layout過程,因為佈局涉及到視圖表現以及動畫效果等。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        if(changed)
        {
            layoutChildren(textOffset, currentPosition);
            tagMap.get(tags.get(currentPosition)).setTextColor(selectedTextColor);
        }


    }

在layout函數裡我們調用瞭layoutChildren(int textOffset, int index),這個函數是用於立馬完成佈局的,也就是根據textOffset和目前所選中的tag值的index立即完成佈局,不存在中間狀態。關於layout在前面的文章中也說過,layout函數在佈局發生改變時是會調用的,而changed則隻有在該view的位置或大小發生變化時才會為true,在第一次佈局時是為true的。這裡的結構表明瞭它隻會在第一次佈局時走if語句裡的流程。
接下來是layoutChildren(int textOffset, int index):

    private void layoutChildren(int textOffset, int index)
    {
        if(tags.size() != 0)
        {
            /*計算TextView的頂部到指示器頂部的距離和底部到橫線頂部的距離。由於我們的TextView是居中顯示的(不),所以如下計算*/
            int padding = (getMeasuredHeight() - getPaddingBottom() - getPaddingTop() - indicatorHeight - tagMap.get(tags.get(0)).getMeasuredHeight()) / 2;
            if(padding < 0)
            {
                padding = 0;
            }
            if(balanceLayout)
            {
                int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
                /*如果是平衡佈局,我們就要計算橫向所剩餘的空間,再將這些空間平分,作為tag之間的間距和tag與指示器前端和後端的距離*/
                int totalItemWidth = 0;
                for(int i = 0; i < tags.size(); i++)
                {
                    totalItemWidth += tagMap.get(tags.get(i)).getMeasuredWidth();
                }
                int space = (availableWidth - totalItemWidth) / (tags.size() + 1);
                space = space < 0 ? 0 : space;
                /*根據paddingLeft決定第一個TextView的偏離值*/
                textOffset = getPaddingLeft() + space;
                /*對子view進行佈局*/
                for(int i = 0; i < tags.size(); i++)
                {
                    TextView child = tagMap.get(tags.get(i));
                    int left = textOffset;
                    int right = textOffset + child.getMeasuredWidth();
                    child.layout(left,
                            getPaddingTop() + padding, right,
                            getPaddingTop() + child.getMeasuredHeight() + padding);
                    /*更新offset*/
                    textOffset += space + child.getMeasuredWidth();
                }
            }else
            {
                /*如果不是平衡佈局,直接按照傳入的textOffset來佈局,此時更新textOffset時使用tag之間的間距,即interval*/
                for(String s : tags)
                {
                    TextView child = tagMap.get(s);
                    child.layout(textOffset, getPaddingTop() + padding, textOffset + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight() + padding);
                    textOffset += child.getMeasuredWidth() + interval;
//                log.v(s + ": left=" + child.getLeft() + ", right=" + child.getRight() + ", top=" + child.getTop() + ", bottom=" + child.getBottom());
                }
            }

            /*最後對橫線進行佈局*/
            ViewGroup.LayoutParams layoutParams = indicatorLine.getLayoutParams();
            layoutParams.width = tagMap.get(tags.get(index)).getMeasuredWidth();
            indicatorLine.setLayoutParams(layoutParams);
            int indicatorOffset = tagMap.get(tags.get(index)).getLeft();
            indicatorLine.layout(indicatorOffset, getMeasuredHeight() - getPaddingBottom() - indicatorHeight ,
                    indicatorOffset + layoutParams.width, getMeasuredHeight() - getPaddingBottom());
        }

    }

但是上面的隻能用於佈局確定狀態,無法佈局中間狀態,就是說我原來對應的是tag1,接下來轉換到tag2時,調用這個函數就會立馬對應的tag2,不能對中間漸變的過程進行佈局。接下來我們還需要一個能對中間狀態佈局的函數layoutChildren(int textOffset, int indicatorOffset, int indicatorLength)

    private void layoutChildren(int textOffset, int indicatorOffset, int indicatorLength)
    {

        log.v("layout children, textOffset = " + textOffset + ", indicatorOffset = " + indicatorOffset + ", indicatorLength = " + indicatorLength);
        if(tags.size() != 0)
        {
            /*計算TextView的頂部到指示器頂部的距離和底部到橫線頂部的距離。由於我們的TextView是居中顯示的(不),所以如下計算*/
            int padding = (getMeasuredHeight() - getPaddingBottom() - getPaddingTop() - indicatorHeight - tagMap.get(tags.get(0)).getMeasuredHeight()) / 2;
            if(padding < 0)
            {
                padding = 0;
            }
            if(balanceLayout)
            {
                /*如果是平衡佈局,我們就要計算橫向所剩餘的空間,再將這些空間平分,作為tag之間的間距和tag與指示器前端和後端的距離*/
                int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
                int totalItemWidth = 0;
                for(int i = 0; i < tags.size(); i++)
                {
                    totalItemWidth += tagMap.get(tags.get(i)).getMeasuredWidth();
                }
                int space = (availableWidth - totalItemWidth) / (tags.size() + 1);
                space = space < 0 ? 0 : space;
                textOffset = getPaddingLeft() + space;
                /*佈局子view*/
                for(int i = 0; i < tags.size(); i++)
                {
                    TextView child = tagMap.get(tags.get(i));
                    int left = textOffset;
                    int right = textOffset + child.getMeasuredWidth();
                    child.layout(left,
                            getPaddingTop() + padding, right,
                            getPaddingTop() + child.getMeasuredHeight() + padding);

                    textOffset += space + child.getMeasuredWidth();
                }
            }else
            {
                for(String s : tags)
                {
                    TextView child = tagMap.get(s);
                    child.layout(textOffset, getPaddingTop() + padding, textOffset + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight() + padding);
                    textOffset += child.getMeasuredWidth() + interval;
//                log.v(s + ": left=" + child.getLeft() + ", right=" + child.getRight() + ", top=" + child.getTop() + ", bottom=" + child.getBottom());
                }
            }

        }


        /*佈局指示器的橫線*/
        ViewGroup.LayoutParams layoutParams = indicatorLine.getLayoutParams();
        layoutParams.width = indicatorLength;
        indicatorLine.setLayoutParams(layoutParams);
        indicatorLine.layout(indicatorOffset, getMeasuredHeight() - getPaddingBottom() - indicatorHeight,
                indicatorOffset + indicatorLength, getMeasuredHeight() - getPaddingBottom());

    }

這個函數和上面那個相比,其實大體差不多,隻不過前者可以自動根據當前對應的tag來確定橫線的佈局,而後者則對橫線的佈局進行瞭更精準的控制,可以操作具體的位置和長度。

至此,佈局工作就做完瞭。接下來就是事件響應瞭。由於本篇篇幅有些過長,接下來的事件相應和其他一些完善工作將會放在下篇去講。

You May Also Like