1. 程式人生 > >那些年我們熬夜打造一可收縮流式標籤控制元件

那些年我們熬夜打造一可收縮流式標籤控制元件

一、前言

時間匆匆,一眨眼來廈門已經一個多月了。似乎已經適應了這邊的生活,喜歡這邊的風,溫和而舒適,還有淡淡海的味道 。。。

還在再次跟大家致個歉意,部落格的更新又延期了。新的環境,新的工作加上專案大改版,基本每天都有大量的事情,週末也不得空閒。

非常感謝大家的陪伴,一路有你們,生活會充滿美好。

標籤控制元件

本文還是繼續講解自定義控制元件,近期在專案中有這樣一個控制元件。

實現可收縮的流式標籤控制元件,具體效果圖如下:

flow

  • 支援多選,單選,反選

  • 子 View 定製化

效果圖不是很清晰,文章後面會提供下載地址。

主要實現功能細分如下:

  • 實現流式佈局(第一個子 View 始終位於首行的最右邊)

  • 佈局可定製化(採取適配模式)

  • 實現控制元件的收縮

主要有這三個小的功能組成。第一個流式佈局實現需要注意的是,第一個元素(子 View)需要固定在首行的最右邊,採取的解決方案是首先繪製第一個元素且繪製在最右邊;第二個佈局可定製化,怎麼來理解這句話呢?我希望實現的子 View 不單單是圓角控制元件,而是高度定製的所有控制元件,由使用者來決定,採取的解決方案是採用了適配模式;第三個控制元件的收縮,這個實現起來就比較簡單了,完成了第一步就可以獲取到控制元件的高度,採用屬性動畫來動態改變控制元件的高度。具體我們一起來往下面看看。

流式佈局

效果圖一欄:

flow

實現效果圖的流式佈局,有兩種方案。一、直接使用 recyclerView ;二、自定義繼承 ViewGroup。本文采用第二種方案,相信大家一定非常熟悉自定義 View 三部曲 ->onMeasure() ->onLayout() ->onDraw() ,吐血推薦以下文章:

onMeasure()測量

要實現標籤流式佈局,需要涉及到以下幾個問題:

(1)、【下拉按鈕】 的測量和佈局

flow

標籤佈局當中【下拉按鈕】始終固定在首行的最右邊,如果依次繪製子 View 可能導致【下拉按鈕】處於第二行,或未處於最右邊(與最右邊還有一定的間距)。為了滿足需求,優先測量和佈局【下拉按鈕】並把第一個 View 作為【下拉按鈕】。

(2)、何時換行

如果當前行已經放不下下一個控制元件,那麼就需要把這個控制元件移到下一行顯示。所以我們要有個變數記錄當前行已經佔據的寬度,以判斷剩下的空間是否還能容得下下一個控制元件。

(3)、如何得到佈局的寬度

為了得到佈局的寬度,我們記錄每行的高度取最大值。

(4)、如何得到佈局的高度

記錄每行的高度,佈局的高度就是所有行高度之和。

宣告的變數如下:

    int lineWidth = 0; //記錄每行的寬度
    int lineHeight = 0; //記錄每行的高度
    int height = 0; //佈局高度
    int width = 0; //佈局寬度
    int count = getChildCount(); //所有子控制元件數量
    boolean firstLine = true; //是否處於第一行
    firstLineCount = 0; //第一行子 View 個數

然後開始測量(貼出 onMeasure 的全部程式碼,再細講):

    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        //測量子View
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
        int childWidth = 0;
        int childHeight = 0;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

        if (lineWidth + childWidth > measureWidth) {
            //需要換行
            width = Math.max(lineWidth, width);
            height += lineHeight;
            //需要換行,而將此控制元件調到下一行,所以將此控制元件的高度和寬度初始化給lineHeight、lineWidth
            lineHeight = childHeight;
            lineWidth = childWidth;
            firstLine = false;
        } else {
            // 否則累加值lineWidth,lineHeight取最大高度
            lineHeight = Math.max(lineHeight, childHeight);
            lineWidth += childWidth;
            if (firstLine) {
                firstLineCount++;
                firstLineHeight = lineHeight;
            }
        }
        //注意這裡是用於新增尾部收起的佈局,寬度為父控制元件寬度。所以要單獨處理
        if (i == count - 1) {
            height += lineHeight;
            width = Math.max(width, lineWidth);
            if (firstLine) {
                firstLineCount = 1;
            }
        }
    }
    //如果未超過一行
    if (mFirstHeight) {
        measureHeight = height;
    }
    setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth
            : width, (measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight
            : height);

首先我們迴圈遍歷每個子控制元件,計算每個子控制元件的寬度和高度,程式碼如下:

        View child = getChildAt(i);
        //測量子View
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
        int childWidth = 0;
        int childHeight = 0;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

需要注意 child.getMeasuredWidth() , child.getMeasuredHeight() 能夠獲取到值,必須先呼叫 measureChild() 方法;同理呼叫 onLayout() 後,getWidth() 才能獲取到值。以下以子控制元件所佔寬度來講解:

childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;

子控制元件所佔寬度=子控制元件寬度+左右的 Margin 值 。還得注意一點為了獲取到子控制元件的左右 Margin 值,需要重寫以下方法:

    @Override
    protected LayoutParams generateLayoutParams(LayoutParams lp) {
        return new MarginLayoutParams(lp);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.MATCH_PARENT,
                LayoutParams.MATCH_PARENT);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

下面就是計算是否需要換行,以及計算父控制元件的寬高度:

    if (lineWidth + childWidth > measureWidth) {
        //需要換行
        width = Math.max(lineWidth, width);
        height += lineHeight;
        //因為由於盛不下當前控制元件,而將此控制元件調到下一行,所以將此控制元件的高度和寬度初始化給lineHeight、lineWidth
        lineHeight = childHeight;
        lineWidth = childWidth;
        firstLine = false; //控制元件超過了一行
    } else {
        // 否則累加值lineWidth,lineHeight取最大高度
        lineHeight = Math.max(lineHeight, childHeight);
        lineWidth += childWidth;
        if (firstLine) { //控制元件未超過一行
            firstLineCount++; //記錄首行子控制元件個數
            firstLineHeight = lineHeight;//獲取第一行控制元件的高度
        }
    }

由於 lineWidth 表示當前行已經佔據的寬度,所以 lineWidth + childWidth > measureWidth,加上下一個子控制元件的寬度大於了父控制元件的寬度,則說明當前行已經放不下當前子控制元件,需要放到下一行;先看 else 部分,在未換行的情況 lineHeight 為當前行子控制元件的最大值,lineWidth 為當前行所有控制元件寬度之和。

在需要換行時,首先將當前行寬 lineWidth 與目前的最大行寬 width 比較計算出最新的最大行寬 width,作為當前父控制元件所佔的寬度,還要將行高 lineHeight 累加到height 變數上,以便計算出父控制元件所佔的總高度。

        width = Math.max(lineWidth, width);
        height += lineHeight;

在需要換行時,需要對當前行寬,高進行賦值。

        lineHeight = childHeight;
        lineWidth = childWidth;

我們還需要處理一件事情,記錄首行子控制元件的個數以及首行的高度。

        if (firstLine) { //控制元件未超過一行
            firstLineCount++; //記錄首行子控制元件個數
            firstLineHeight = lineHeight;//獲取第一行控制元件的高度
        }

如果超過了一行 firstLine 賦值為 false 。

最後一個子控制元件我們需要單獨處理,獲取最終的父控制元件的寬高度。

        //最後一行是不會超出width範圍的,所以要單獨處理
        if (i == count - 1) {
            height += lineHeight;
            width = Math.max(width, lineWidth);
            if (firstLine) {
                firstLineCount = 1;
            }
        }

最後就是呼叫 setMeasuredDimension() 方法,設定到系統中。

        setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth : width, (measureHeightMode ==
                MeasureSpec.EXACTLY) ? measureHeight : height);

onLayout()佈局

佈局所有的子控制元件,由於控制元件要後移和換行,所以我們要標記當前控制元件的 left 座標和 top 座標,申明的幾個變數如下:

    int count = getChildCount();
    int lineWidth = 0;//累加當前行的行寬
    int lineHeight = 0;//當前行的行高
    int top = 0, left = 0;//當前座標的top座標和left座標
    int parentWidth = getMeasuredWidth(); //父控制元件的寬度

首先我們需要佈局第一個子控制元件,使它位於首行的最右邊。呼叫 child.layout 進行子控制元件的佈局。layout 的函式如下,分別計算 l , t , r , b

layout(int l, int t, int r, int b)

l = 父控制元件的寬度 - 子控制元件的右Margin - 子控制元件高度

t = 子控制元件的頂部Margin

r = l + 子控制元件寬度

b = t + 子控制元件高度

具體佈局程式碼如下:

   if (i == 0) {
       child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
               .rightMargin, lp.topMargin + child.getMeasuredHeight());
       firstViewWidth = childWidth;
       firstViewHeight = childHeight;
       continue;
   }

接著按著順序對子控制元件進行佈局,先計算出子控制元件的寬高:

    View child = getChildAt(i);
    MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
    //寬度(包含margin值和子控制元件寬度)
    int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
    //高度同上
    int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

然後判斷當前佈局子控制元件是否為首行最後佈局的控制元件,並對 lineWidthlineHeight 再次計算:

    if (firstLineCount == (i + 1)) {
        lineWidth += firstViewWidth;
        lineHeight = Math.max(lineHeight, firstViewHeight);
    }

然後根據是否要換行來計算當行控制元件的 top 座標和 left 座標:

if (childWidth + lineWidth >getMeasuredWidth()){  
    //如果換行,當前控制元件將跑到下一行,從最左邊開始,所以left就是0,而top則需要加上上一行的行高,才是這個控制元件的top點;  
    top += lineHeight;  
    left = 0;  
     //同樣,重新初始化lineHeight和lineWidth  
    lineHeight = childHeight;  
    lineWidth = childWidth;  
}else{  
    // 否則累加值lineWidth,lineHeight取最大高度  
    lineHeight = Math.max(lineHeight,childHeight);  
    lineWidth += childWidth;  
}  

在計算好 left,top 之後,然後分別計算出控制元件應該佈局的上、下、左、右四個點座標,需要非常注意的是 margin 不是 padding,margin 的距離是不繪製的控制元件內部的,而是控制元件間的間隔。

   //計算childView的left,top,right,bottom
   int lc = left + lp.leftMargin;
   int tc = top + lp.topMargin;
   int rc = lc + child.getMeasuredWidth();
   int bc = tc + child.getMeasuredHeight();
   child.layout(lc, tc, rc, bc);
   //將left置為下一子控制元件的起始點
   left += childWidth;

最後在 onLayout 方法當中,我們需要儲存當前父控制元件的高度來實現收縮,展開效果。

   if (mFirstHeight) {
       contentHeight = getHeight();
       mFirstHeight = false;
       if (mListener != null) {
           mListener.onFirstLineHeight(firstLineHeight);
       }
   }

onLayout 的完整程式碼如下:

    private void buildLayout() {
        int count = getChildCount();
        int lineWidth = 0;//累加當前行的行寬
        int lineHeight = 0;//當前行的行高
        int top = 0, left = 0;//當前座標的top座標和left座標

        int parentWidth = getMeasuredWidth(); //父控制元件的寬度

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (i == 0) {
                child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
                        .rightMargin, lp.topMargin + child.getMeasuredHeight());
                firstViewWidth = childWidth;
                firstViewHeight = childHeight;
                continue;
            }

            if (firstLineCount == (i + 1)) {
                lineWidth += firstViewWidth;
                lineHeight = Math.max(lineHeight, firstViewHeight);
            }

            if (childWidth + lineWidth > getMeasuredWidth()) {
                //如果換行
                top += lineHeight;
                left = 0;
                lineHeight = childHeight;
                lineWidth = childWidth;
            } else {
                lineHeight = Math.max(lineHeight, childHeight);
                lineWidth += childWidth;
            }
            //計算childView的left,top,right,bottom
            int lc = left + lp.leftMargin;
            int tc = top + lp.topMargin;

            int rc = lc + child.getMeasuredWidth();
            int bc = tc + child.getMeasuredHeight();

            child.layout(lc, tc, rc, bc);
            //將left置為下一子控制元件的起始點
            left += childWidth;
        }
        if (mFirstHeight) {
            contentHeight = getHeight();
            mFirstHeight = false;
            if (mListener != null) {
                mListener.onFirstLineHeight(firstLineHeight);
            }
        }
    }

佈局可定製化

為了實現佈局的可定製化,採用了適配模式,

    public void setAdapter(ListAdapter adapter) {
        if (adapter != null && !adapter.isEmpty()) {
            buildTagItems(adapter);//構建標籤列表項
        }
    }

先貼出構建標籤列表項的程式碼:

 private void buildTagItems(ListAdapter adapter) {
     //移除所有控制元件
     removeAllViews();
     //新增首view
     // addFirstView();
     for (int i = 0; i < adapter.getCount(); i++) {
         final View itemView = adapter.getView(i, null, this);
         final int position = i;
         if (itemView != null) {
             if (i == 0) {
                 firstView = itemView;
                 itemView.setVisibility(View.INVISIBLE);
                 itemView.setOnClickListener(new OnClickListener() {
                     @Override
                     public void onClick(View v) {
                         //展開動畫
                         expand();
                     }
                 });
             } else {
                 itemView.setOnClickListener(new OnClickListener() {
                     @Override
                     public void onClick(View v) {
                         if (mListener != null) {
                             //item 點選回撥
                             mListener.onClick(v, position);
                         }
                     }
                 });
             }
             itemView.setTag(TAG + i);
             mChildViews.put(i, itemView);
             //新增子控制元件
             addView(itemView);
         }
     }
     //新增底部收起試圖
     addBottomView();
 }

獲取子控制元件:

  final View itemView = adapter.getView(i, null, this);

針對第一個子控制元件,點選展開試圖:

    if (i == 0) {
        firstView = itemView;
        itemView.setVisibility(View.INVISIBLE);
        itemView.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                //展開
                expand();
            }
        });

然後新增子控制元件:

 addView(itemView);

最後新增底部:

    addBottomView(); 

原始碼在文章的末尾,文章有點長,希望各位繼續往後面看。

控制元件的展開和收縮

控制元件展開為例:

private void expand() {
    //屬性動畫
    ValueAnimator animator = ValueAnimator.ofInt(firstLineHeight, contentHeight);
    animator.setDuration(mDuration);
    animator.setInterpolator(new LinearInterpolator());
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            //獲取到屬性動畫值,並重新整理控制元件
            int value = (int) animation.getAnimatedValue();
            getLayoutParams().height = value;
            requestLayout();//重新佈局
        }
    });
    animator.addListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationStart(Animator animation) {
            if (mListener != null) { //主要對蒙層的處理
                mListener.showMask();
            }
            firstView.setVisibility(View.INVISIBLE);//第一個View不可見                
            bottomCollapseLayout.setVisibility(View.VISIBLE);//底部控制元件可見
        }
        @Override
        public void onAnimationEnd(Animator animation) {
        }
        @Override
        public void onAnimationCancel(Animator animation) {
        }
        @Override
        public void onAnimationRepeat(Animator animation) {
        }
    });
    animator.start();
}

如果你對屬性動畫還有疑問的話,請參考如下文章:

文章講到這裡差不多就要結束了,提前預祝大家【五一快樂】

第二種簡單實現方式,效果圖如下:

GIF.gif

如有什麼疑問,歡迎討論,以下是聯絡方式:

qq

原始碼地址