1. 程式人生 > >自定義控制元件三部曲檢視篇(三)——瀑布流容器WaterFallLayout實現

自定義控制元件三部曲檢視篇(三)——瀑布流容器WaterFallLayout實現

前言:只要在前行,夢想就不再遙遠

系列文章:

前面兩節講解了有關ViewGroup的onMeasure、onLayout的知識,這節我們深入性地探討一下,如何實現經常見到的瀑布流容器,本節將實現的效果圖如下:

這裡寫圖片描述

從效果圖中可以看出這裡要完成的幾個功能:

1、圖片隨機新增
2、在新增圖片時,總是將新圖片插入到當前最短的列中
3、每個Item後,會彈出當前Item的索引

一、初步實現WaterFallLayout

1.1 自定義控制元件WaterFallLayout

首先,我們自定義一個派生自ViewGroup的控制元件WaterFallLayout,然後再定義幾個變數:

public class WaterfallLayout extends ViewGroup {
    private int columns = 3;
    private int hSpace = 20;
    private int vSpace = 20;
    private int childWidth = 0;
    private int top[];

    public WaterfallLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        top = new
int[colums]; } public WaterfallLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public WaterfallLayout(Context context) { this(context, null); } ………… }

這裡定義了幾個變數:int columns用於指定當前的列數,這裡指定的是三列;hSpace與vSpace用於指定每個圖片間的水平間距和垂直間距。由於控制元件的寬度是一定的,當指定了列數以後,每個圖片的寬度都是相同的,所以childWidth表示當前每個圖片的寬度;由於每個圖片的寬高比不同,所以他們的寬度相同,而高度則不同的,需要單獨計算,也就沒必要寫成全域性變量了。在開篇時,我們已經提到,我們需要把新增的圖片放在容器最靠上的空白處,所以要有個top[columns]來儲存當前每列的高度,以實時找到最短的高度的位置,將新增的圖片放在那裡。

1.2 設定onMeasure結果

通過前兩篇我們知道對於ViewGroup而言onMeasure和onLayout的作用,onMeasure是告訴當前控制元件的父控制元件,它要佔用的大小,以便讓它的父控制元件給它預留。而onLayout則是佈局ViewGroup中的各個元素用的。

所以首先,我們需要先計算出整個ViewGroup所要佔據的大小,然後通過setMeasuredDimension()函式通知ViewGroup的父控制元件以預留位置,所以我們需要先求出控制元件所佔的寬和高。

1.2.1 計算每個圖片所佔的寬度

所以,我們需要先求出來控制元件所佔的寬度:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  int widthMode = MeasureSpec.getMode(widthMeasureSpec);
  int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
  measureChildren(widthMeasureSpec, heightMeasureSpec);

  childWidth = (sizeWidth - (columns - 1) * hSpace) / columns;
  …………
}  

首先,需要利用measureChildren(widthMeasureSpec, heightMeasureSpec);讓每個子控制元件先測量自己,只有測量過自己之後,再呼叫子控制元件的getMeasuredWidth()才會有值,所以我們在派生自ViewGroup的控制元件在onMeasure的時候,一般都會首先呼叫measureChildren()函式,以防在用到子控制元件的getMeasuredWidth方法的時候沒值。

然後,我們需要先求個每個子控制元件的寬度。根據widthMeasureSpec得到的sizeWidth,是父控制元件建議擺放的寬度,一般也就是我們最大擺放的寬度。所以,我們根據這個寬度求出在圖片擺放三列的情況下,每個控制元件的寬度,公式就是:

childWidth = (sizeWidth - (columns - 1) * hSpace) / columns;

由於每個圖片寬度相同,而且每兩個圖片間是有一定間距的,距離是hSpace;在columns列的情況下,有(columns - 1)個間距,因為每兩個控制元件的間距是hSpace,所以總的間距就是(columns - 1) * hSpace;所以計算原理就是根據總寬度減去總間距得到的就是所有子控制元件的總寬度和,然後除以列數,就得到了每個item的寬度。

1.2.2 求得控制元件總寬度

然後我們就可以根據子控制元件的數量是不是超過設定的列數來得到總的寬度,由於我們設定的每行的有三列,所以,如果所有子控制元件數並沒有超過三列,那麼總的控制元件寬度就是當前個數子控制元件的寬度總和組成。如果子控制元件數超過了三個,那說明肯定能撐滿一行了,寬度也就是父控制元件建議的sizeWidth寬度了

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ………………
    int wrapWidth;
    int childCount = getChildCount();
    if (childCount < columns) {
        wrapWidth = childCount * childWidth + (childCount - 1) * hSpace;
    } else {
        wrapWidth = sizeWidth;
    }
    …………
}   

1.2.3 求得控制元件總高度

在求得總寬度以後,我們要想辦法得到控制元件的總高度,難點在於,我們在擺放控制元件時,總是先找到最短的列,然後把新的控制元件擺放在這列中,如:

這裡寫圖片描述

很明顯,在這張圖片中,第三列是最短的,所以我們在新插入圖片時,會放在第三列中。

這就需要我們有一個數組來標識每列在新增圖片後的高度,以便在每次插入圖片時,找到當前最短的列。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    …………

    clearTop();
    for (int i = 0; i < childCount; i++) {
        View child = this.getChildAt(i);
        int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth();
        int minColum = getMinHeightColum();
        top[minColum] += vSpace + childHeight;
    }
    int wrapHeight;
    wrapHeight = getMaxHeight();
    setMeasuredDimension(widthMode == MeasureSpec.AT_MOST ? wrapWidth : sizeWidth, wrapHeight);
}    

首先,每次在計算高度之前,我們應該先把top[]陣列清空,以防上次的資料影響這次的計算,clearTop()的實現為:

private void clearTop() {
    for (int i = 0; i < columns; i++) {
        top[i] = 0;
    }
}

然後就要開始計算每列的最大高度了,我們需要輪詢每個控制元件,然後將每個控制元件按他所在的位置計算一遍,最後得到每列的最大高度。

for (int i = 0; i < childCount; i++) {
    View child = this.getChildAt(i);
    int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth();
    int minColum = getMinHeightColum();
    top[minColum] += vSpace + childHeight;
}

首先得到當前要擺放控制元件的高度:int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth(),因為我們每張圖片要擺放的寬度都是相同的,所以我們需要將圖片伸縮到指定的寬度,然後得到對應的高度,才是它所擺放的高度。

然後通過getMinHeightColum()得到top[]陣列的最短的列,getMinHeightColum()的實現如下:

private int getMinHeightColum() {
    int minColum = 0;
    for (int i = 0; i < columns; i++) {
        if (top[i] < top[minColum]) {
            minColum = i;
        }
    }
    return minColum;
}

實現很簡單,直接能top陣列輪詢,得到它的最短列的索引;

在得到最短列以後,將當前控制元件放在最短列中:top[minColum] += vSpace + childHeight;然後再計算下一個控制元件所在位置,並且放到當前的最短列中,當所有控制元件輪詢結束以後,top[]陣列中所保留的資料就是所有圖片擺放完以後各列的高度。

最後,通過getMaxHeight()得到最長列的高度就是整個控制元件應有的高度值,然後通過setMeasuredDimension函式將計算得到的wrapWdith和wrapHeight提交給父控制元件即可:

int wrapHeight;
wrapHeight = getMaxHeight();
setMeasuredDimension(widthMode == MeasureSpec.AT_MOST ? wrapWidth : sizeWidth, wrapHeight);

其中getMaxHieght()實現如下:

private int getMaxHeight() {
    int maxHeight = 0;
    for (int i = 0; i < columns; i++) {
        if (top[i] > maxHeight) {
            maxHeight = top[i];
        }
    }
    return maxHeight;
}

1.3 onLayout擺放子控制元件

在瞭解onMeasure中如何計算當前圖片所在的列之後,擺放就容易多了,只需要計算每個Item所在位置的left,top,right,bottom值,然後利用layout(left,top,right,bottom)函式將控制元件擺放在指定位置即可:

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    clearTop();
    for (int i = 0; i < childCount; i++) {
        View child = this.getChildAt(i);
        int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth();
        int minColum = getMinHeightColum();
        int tleft = minColum * (childWidth + hSpace);
        int ttop = top[minColum];
        int tright = tleft + childWidth;
        int tbottom = ttop + childHeight;
        top[minColum] += vSpace + childHeight;
        child.layout(tleft, ttop, tright, tbottom);
    }
}

同樣是每個控制元件輪詢,然後通過int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth()得到當前要擺放圖片的高度,然後根據int minColum = getMinHeightColum()得到最短的列,準備將這個控制元件擺放在這個列中。

下面就是根據要擺放的列的位置,得到要擺放圖片的left,top,right,bottom值;其中top很容易得到,top[minColum]就是當前要擺放圖片的top值,bottom也容易,加上圖片的自身高度就是bottom值;稍微有點難度的地方是left值,因為通過getMinHeightColum()得到的是當前最短列的索引,因為索引是從0開始的,所以,假設我們當前最短的是第三列,所以通過getMinHeightColum()得到的值是2;因為每個圖片都是由圖片本身和中間的間距組成,所以當前控制元件的left值就是2*(childWidth + hSpace);在計算出left、top、right、bottom以後,通過child.layout函式將它們擺放在當前位置即可。最後更新top[minColum]的高度:top[minColum] += vSpace + childHeight;

到這裡,有關測量和擺放就全部結束了,但是我們自定義的佈局,應該對每個Item新增上點選響應,這是佈局控制元件最基本的特性。

1.4 新增Item點選響應

對自定義的ViewGroup中的子控制元件新增點選響應是非常簡單的,首先,我人需要自定義一個介面來回調控制元件被點選的事件:

public interface OnItemClickListener {
    void onItemClick(View v, int index);
}

然後輪詢所有的子控制元件,並且在每個子控制元件在點選的時候,回調出去即可:

public void setOnItemClickListener(final OnItemClickListener listener) {
    for (int i = 0; i < getChildCount(); i++) {
        final int index = i;
        View view = getChildAt(i);
        view.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                listener.onItemClick(v, index);
            }
        });
    }
}

二、使用WaterFallLayout

在使用時,首先在XML中引入:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res/com.harvic.BlogWaterfallLayout"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent">

    <Button
            android:id="@+id/add_btn"
            android:layout_alignParentTop="true"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="隨機新增圖片"/>

    <ScrollView
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <com.harvic.BlogWaterfallLayout.WaterfallLayout
                android:id="@+id/waterfallLayout"
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                android:layout_below="@+id/add_btn"/>
    </ScrollView>
</LinearLayout>

因為WaterfallLayout是派生自ViewGroup的,所以當範圍超出螢幕時,不會自帶滾動,所以我們需要在外層包一個ScrollView來實現滾動。

然後在程式碼中,當點選按鈕時,隨便新增圖片:

public class MyActivity extends Activity {
    private static int IMG_COUNT = 5;
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        final WaterfallLayout waterfallLayout = ((WaterfallLayout)findViewById(R.id.waterfallLayout));
        findViewById(R.id.add_btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                addView(waterfallLayout);
            }
        });

    }
    …………
}    

其中addView的實現為:

public void addView(WaterfallLayout waterfallLayout) {
    Random random = new Random();
    Integer num = Math.abs(random.nextInt());
    WaterfallLayout.LayoutParams layoutParams = new WaterfallLayout.LayoutParams(WaterfallLayout.LayoutParams.WRAP_CONTENT,
        WaterfallLayout.LayoutParams.WRAP_CONTENT);
    ImageView imageView = new ImageView(this);
    if (num % IMG_COUNT == 0) {
        imageView.setImageResource(R.drawable.pic_1);
    } else if (num % IMG_COUNT == 1) {
        imageView.setImageResource(R.drawable.pic_2);
    } else if (num % IMG_COUNT == 2) {
        imageView.setImageResource(R.drawable.pic_3);
    } else if (num % IMG_COUNT == 3) {
        imageView.setImageResource(R.drawable.pic_4);
    } else if (num % IMG_COUNT == 4) {
        imageView.setImageResource(R.drawable.pic_5);
    }
    imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);

    waterfallLayout.addView(imageView, layoutParams);

    waterfallLayout.setOnItemClickListener(new com.harvic.BlogWaterfallLayout.WaterfallLayout.OnItemClickListener() {
        @Override
        public void onItemClick(View v, int index) {
            Toast.makeText(MyActivity.this, "item=" + index, Toast.LENGTH_SHORT).show();
        }
    });
}

程式碼很容易理解,首先隨機生成一個數字,因為我們有五張圖片,所以對生成的數字對圖片數取餘,然後指定一個圖片資源,這樣就實現了隨機新增圖片的效果,然後將ImageView新增到自定義控制元件waterfallLayout中,最後新增點選響應,在點選某個Item時,彈出這個Item的索引。

到這裡,整個自定義控制元件部分和使用都講完了,效果圖就如開篇所示。

三、改進Waterfalllayout實現

從上面的實現中可以看出一個問題,就是需要在onMeasure和onLayout中都需要重新計算每列的高度,如果佈局比較複雜的話,這種輪詢的計算是非常耗效能的,而且onMeasure中已經計算過一次,我們如果在OnMeasure計算時,直接將每個item所在的位置儲存起來,那麼在onLayout中就可以直接使用了。

那麼問題來了,怎麼儲存這些引數呢,難不成要生成一個具有陣列來儲存每個item的變數嗎?利用陣列來儲存當然是一種解決方案,但並不是最優的,因為我們的item可能會有幾千個,那當這個陣列可能就已經非常佔用記憶體,而且當陣列很大的時候,存取也是比較耗費效能的。回想一下,我們在《自定義控制元件三部曲檢視篇(一)——測量與佈局》中在講解MarginLayoutParams時,系統會把各個margin間距儲存在MarginLayoutParams中:

public static class MarginLayoutParams extends ViewGroup.LayoutParams {
    public int leftMargin;
    public int topMargin;
    public int rightMargin;
    public int bottomMargin;
    …………
}

那方法來了,我們可不可以仿照MarginLayoutParams自定義一個LayoutParams,然後每次將計算後的left、top、right、bottom的值儲存在這個自定義的LayoutParmas中,在佈局的時候,取出來就可以了。

首先,我們自定義一個LayoutParams:

public static class WaterfallLayoutParams extends ViewGroup.LayoutParams {
      public int left = 0;
      public int top = 0;
      public int right = 0;
      public int bottom = 0;

      public WaterfallLayoutParams(Context arg0, AttributeSet arg1) {
          super(arg0, arg1);
      }

      public WaterfallLayoutParams(int arg0, int arg1) {
          super(arg0, arg1);
      }

      public WaterfallLayoutParams(android.view.ViewGroup.LayoutParams arg0) {
          super(arg0);
      }
}

這裡相對原來的ViewGroup.LayoutParams,只新增幾個變數來儲存圖片的各點位置。

然後仿照MarginLayoutParams的使用方法,重寫generateLayoutParams()函式:

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

@Override
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
  return new WaterfallLayoutParams(WaterfallLayoutParams.WRAP_CONTENT, WaterfallLayoutParams.WRAP_CONTENT);
}

@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
  return new WaterfallLayoutParams(p);
}

然後在onMeasure時,將程式碼進行修改,在計算每列高度的時候,同時計算出每個Item的位置儲存在WaterfallLayoutParams中:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    …………

    clearTop();
    for (int i = 0; i < childCount; i++) {
        View child = this.getChildAt(i);
        int childHeight = child.getMeasuredHeight() * childWidth / child.getMeasuredWidth();
        int minColum = getMinHeightColum();

        WaterfallLayoutParams lParams = (WaterfallLayoutParams)child.getLayoutParams();
        lParams.left = minColum * (childWidth + hSpace);
        lParams.top = top[minColum];
        lParams.right = lParams.left + childWidth;
        lParams.bottom = lParams.top + childHeight;

        top[minColum] += vSpace + childHeight;
    }

    …………
}

然後在佈局時,直接從佈局引數中,取出來佈局即可:

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        WaterfallLayoutParams lParams = (WaterfallLayoutParams)child.getLayoutParams();
        child.layout(lParams.left, lParams.top, lParams.right, lParams.bottom);
    }
}

萬事具備之後,直接執行,發現在點選新增圖片Item時,報了Crash:

AndroidRuntime: FATAL EXCEPTION: main 
java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to com.harvic.BlogWaterfallLayout.WaterfallLayoutImprove$WaterfallLayoutParams
at com.harvic.BlogWaterfallLayout.WaterfallLayoutImprove.onMeasure(WaterfallLayoutImprove.java:92)
at android.view.View.measure(View.java:15518)
at android.widget.ScrollView.measureChildWithMargins(ScrollView.java:1217)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.widget.ScrollView.onMeasure(ScrollView.java:321)
at android.view.View.measure(View.java:15518)

奇怪了,明明仿照MarginLayoutParams來自定義的佈局引數,為什麼並沒有生效呢?

這是因為在,自定義ViewGroup的佈局引數時,需要重寫另一個函式:

@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return p instanceof WaterfallLayoutParams;
}

之所以需要重寫checkLayoutParams,是因為在ViewGroup原始碼中在新增子控制元件時,有如下程式碼:

private void addViewInner(View child, int index, LayoutParams params,
        boolean preventRequestLayout) {

    …………
    if (!checkLayoutParams(params)) {
        params = generateLayoutParams(params);
    }
    …………
}    

很明顯,當checkLayoutParams返回false時才會呼叫generateLayoutParams,checkLayoutParams的預設實現是:

protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return  p != null;
}

即ViewGroup.LayoutParams不為空,就不會再走generateLayoutParams(params)函式,也就沒辦法使用我們自定義LayoutParams。所以我們必須重寫,當LayoutParams不是WaterfallLayoutParams時,就需要進入generateLayoutParams函式,以使用自定義佈局引數。

到此,整個自定義控制元件就結束了,在講了原理之後,還需要對自定義的控制元件進行封裝,比如這裡的列數和每兩個圖片間的間距都是需要使用者指定的,所以我們需要在自定義控制元件屬性來實現這些功能。這些都是自定義控制元件的一部分,以前寫的一篇文章已經寫過了,這裡就不再重寫了,想了解的同學,參考下: 《PullScrollView詳解(一)——自定義控制元件屬性》

如果你喜歡我的文章,那麼你將會更喜歡我的微信公眾號,將定期推送博主最新文章與收集乾貨分享給大家(一週一次)
這裡寫圖片描述