自定义控件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函数,以使用自定义布局参数。
到此,整个自定义控件就结束了。