Android 瀑布流容器WaterFallLayout实现

自定义控件WaterFallLayout

我们自定义一个派生自ViewGroup的控件WaterFallLayout,然后再定义几个变量:

package com.as.waterfalllayout;

import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;

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) {
        this(context, null);
    }

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

    public WaterfallLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        top = new int[columns];
    }

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

    }

}

int columns用于指定当前的列数,这里指定的是三列;hSpace与vSpace用于指定每个图片间的水平间距和垂直间距。因为控件宽度是固定的,有了列数,每个子控件的宽度就确定了,所以childWidth表示当前每个控件的宽度。由于图片之间的宽高比不同,它们宽度相同,高度不同,top[] 用于保存每列的高度,实时找到最短的高度的位置,将新增的图片放在那里。

设定onMeasure结果

前面的博客中我们知道ViewGroup的onMeasure和onLayout的作用,onMeasure是测量子控件大小,让父控件加上它的大小。onLayout函数ViewGroup给控件设置位置。

我们需要先计算出整个ViewGroup所要占据的大小,然后通过setMeasuredDimension()函数通知ViewGroup的父控件以预留位置,所以我们需要先求出控件所占的宽和高。

们需要先求出来控件所占的宽度

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()才会有值,然后,我们需要先求个每个子控件的宽度。计算原理就是根据总宽度减去总间距得到的就是所有子控件的总宽度和,然后除以列数,就得到了每个item的宽度。

求得控件总宽度

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

然后我们就可以根据子控件的数量是不是超过设定的列数来得到总的宽度,由于我们设定的每行的有三列,所以,如果所有子控件数并没有超过三列,那么总的控件宽度就是当前个数子控件的宽度总和组成。如果子控件数超过了三个,那说明肯定能撑满一行了,宽度也就是父控件建议的sizeWidth宽度了

求得控件总高度

我们在摆放控件时,总是先找到最短的列,然后把新的控件摆放在这列中,这就需要我们有一个数组来标识每列在添加图片后的高度,以便在每次插入图片时,找到当前最短的列。

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);

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

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点击响应

对自定义的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"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/add_btn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:text="随机添加图片" />

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

        <com.as.waterfalllayout.WaterfallLayout
            android:id="@+id/waterfallLayout"
            android:layout_width="wrap_content"
            android:layout_height="match_parent" />

    </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);
            }
        });

    }
    
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中,在布局的时候,取出来就可以了。

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:

06-16 16:50:35.278 15169-15169/com.as.waterfalllayout E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.as.waterfalllayout, PID: 15169
    java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to com.as.waterfalllayout.WaterfallLayout$WaterfallLayoutParams
        at com.as.waterfalllayout.WaterfallLayout.onLayout(WaterfallLayout.java:103)
        at android.view.View.layout(View.java:16630)
        at android.view.ViewGroup.layout(ViewGroup.java:5437)
        at android.widget.FrameLayout.layoutChildren(FrameLayout.java:336)
        at android.widget.FrameLayout.onLayout(FrameLayout.java:273)
        at android.widget.ScrollView.onLayout(ScrollView.java:1525)
        at android.view.View.layout(View.java:16630)
        at android.view.ViewGroup.layout(ViewGroup.java:5437)
        at android.widget.LinearLayout.setChildFrame(LinearLayout.java:1743)
        at android.widget.LinearLayout.layoutVertical(LinearLayout.java:1586)
        at android.widget.LinearLayout.onLayout(LinearLayout.java:1495)
        at android.view.View.layout(View.java:16630)
        at android.view.ViewGroup.layout(ViewGroup.java:5437)
        at android.widget.FrameLayout.layoutChildren(FrameLayout.java:336)
        at android.widget.FrameLayout.onLayout(FrameLayout.java:273)
        at android.view.View.layout(View.java:16630)
        at android.view.ViewGroup.layout(ViewGroup.java:5437)

奇怪了,明明仿照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函数,以使用自定义布局参数。

到此,整个自定义控件就结束了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342