Android ListView功能扩展,实现高性能的瀑布流布局

经过前面两篇文章的学习,我们已经对 ListView 进行了非常深层次的剖析,不仅了解了 ListView 的源码和它的工作原理,同时也将 ListView 中常见的一些问题进行了归纳和总结。

经过前面两篇文章的学习,我们已经对 ListView 进行了非常深层次的剖析,不仅了解了 ListView 的源码和它的工作原理,同时也将 ListView 中常见的一些问题进行了归纳和总结。

那么本篇文章是我们 ListView 系列三部曲的最后一篇,在这篇文章当中我们将对 ListView 进行功能扩展,让它能够以瀑布流的样式来显示数据。另外,本篇文章的内容比较复杂,且知识点严重依赖于前两篇文章,如果你还没有阅读过的话,强烈建议先去阅读 Android ListView 工作原理完全解析,带你从源码的角度彻底理解 和 Android ListView 异步加载图片乱序问题,原因分析及解决方案 这两篇文章。

一直关注我博客的朋友们应该知道,其实在很早之前我就发布过一篇关于实现瀑布流布局的文章,Android 瀑布流照片墙实现,体验不规则排列的美感。但是这篇文章中使用的实现算法比较简单,其实就是在外层嵌套一个 ScrollView,然后按照瀑布流的规则不断向里面添加子 View,原理如下图所示:

虽说功能是可以正常实现,但是这种实现原理背后的问题太多了,因为它只会不停向 ScrollView 中添加子 View,而没有一种合理的回收机制,当子 View 无限多的时候,整个瀑布流布局的效率就会严重受影响,甚至有可能会出现 OOM 的情况。

而我们在前两篇文章中对 ListView 进行了深层次的分析,ListView 的工作原理就非常巧妙,它使用 RecycleBin 实现了非常出色的生产者和消费者的机制,移出屏幕的子 View 将会被回收,并进入到 RecycleBin 中进行缓存,而新进入屏幕的子 View 则会优先从 RecycleBin 当中获取缓存,这样的话不管我们有多少条数据需要显示,实际上屏幕上的子 View 其实也就来来回回那么几个。

那么,如果我们使用 ListView 工作原理来实现瀑布流布局,效率问题、OOM 问题就都不复存在了,可以说是真正意义上实现了一个高性能的瀑布流布局。原理示意图如下所示:

OK,工作原理确认了之后,接下来的工作就是动手实现了。由于瀑布流这个扩展对 ListView 整体的改动非常大,我们没办法简单地使用继承来实现,所以只能先将 ListView 的源码抽取出来,然后对其内部的逻辑进行修改来实现功能,那么我们第一步的工作就是要将 ListView 的源码抽取出来。但是这个工作并不是那么简单的,因为仅仅 ListView 这一个单独的类是不能够独立工作的,我们如果要抽取代码的话还需要将 AbsListView、AdapterView 等也一起抽取出来,然后还会报各种错误都需要一一解决,我当时也是折腾了很久才搞定的。所以这里我就不带着大家一步步对 ListView 源码进行抽取了,而是直接将我抽取好的工程 UIListViewTest 上传到了 CSDN,大家只需要点击 这里 进行下载就可以了,今天我们所有的代码改动都是在这个工程的基础上进行的。

另外需要注意的是,为了简单起见,我没有抽取最新版本的 ListView 代码,而是选择了 Android 2.3 版本 ListView 的源码,因为老版本的源码更为简洁,方便于我们理解核心的工作流程。

好的,那么现在将 UIListViewTest 项目导入到开发工具当中,然后运行程序,效果如下图所示:

可以看到,这是一个非常普通的 ListView,每个 ListView 的子 View 里面有一张图片,一段文字,还有一个按钮。文字的长度是随机生成的,因此每个子 View 的高度也各不相同。那么我们现在就来对 ListView 进行扩展,让它拥有瀑布流展示的能力。

首先,我们打开 AbsListView 这个类,在里面添加如下所示的几个全局变量:

protected int mColumnCount = 2;

protected ArrayList<View>[] mColumnViews = new ArrayList[mColumnCount];

protected Map<Integer, Integer> mPosIndexMap = new HashMap<Integer, Integer>();
复制代码

其中 mColumnCount 表示瀑布流布局一共有几列,这里我们先让它分为两列显示,后面随时可以对它进行修改。当然,如果想扩展性做的好的话,也可以使用自定义属性的方式在 XML 里面指定显示的列数,不过这个功能就不在我们本篇文章的讨论范围之内了。mColumnViews 创建了一个长度为 mColumnCount 的数组,数组中的每个元素都是一个泛型为 View 的 ArrayList,用于缓存对应列的子 View。mPosIndexMap 则是用于记录每一个位置的子 View 应当放置在哪一列当中。

接下来让我们回忆一下,ListView 最基本的填充方式分为向下填充和向上填充两种,分别对应的方法是 fillDown() 和 fillUp() 方法,而这两个方法的触发点都是在 fillGap() 方法当中的,fillGap() 方法又是由 trackMotionScroll() 方法根据子元素的位置来进行调用的,这个方法只要手指在屏幕上滑动时就会不停进行计算,当有屏幕外的元素需要进入屏幕时,就会调用 fillGap() 方法来进行填充。那么,trackMotionScroll() 方法也许就应该是我们开始着手修改的地方了。

这里我们最主要的就是修改对于子 View 进入屏幕判断的时机,因为原生的 ListView 只有一列内容,而瀑布流布局将会有多列内容,所以这个时机的判断算法也就需要进行改动。那么我们先来看一下原先的判断逻辑,如下所示:

final int firstTop = getChildAt(0).getTop();

final int lastBottom = getChildAt(childCount - 1).getBottom();

final Rect listPadding = mListPadding;

final int spaceAbove = listPadding.top - firstTop;

final int end = getHeight() - listPadding.bottom;

final int spaceBelow = lastBottom - end;
复制代码

这里 firstTop 表示屏幕中第一个元素顶边的位置,lastBottom 表示屏幕中最后一个元素底边的位置,然后 spaceAbove 记录屏幕第一个元素顶边到 ListView 上边缘的距离,spaceBelow 记录屏幕最后一个元素底边到 ListView 下边缘的距离。最后使用手指在屏幕上移动的距离和 spaceAbove、spaceBelow 进行比较,来判断是否需要调用 fillGap() 方法,如下所示:

final int absIncrementalDeltaY = Math.abs(incrementalDeltaY);

if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
复制代码

了解了原先的工作原理之后,我们就可以来思考一下怎么将这个逻辑改成适配瀑布流布局的方式。比如说目前 ListView 中有两列内容,那么获取屏幕中的第一个元素和最后一个元素其实意义是不大的,因为在有多列内容的情况下,我们需要找到的是最靠近屏幕上边缘和最靠近屏幕下边缘的元素,因此这里就需要写一个算法来去计算 firstTop 和 lastBottom 的值,这里我先把修改后的 trackMotionScroll() 方法贴出来,然后再慢慢解释:

boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {

final int childCount = getChildCount();

int firstTop = Integer.MIN_VALUE;

int lastBottom = Integer.MAX_VALUE;

int endBottom = Integer.MIN_VALUE;

for (int i = 0; i < mColumnViews.length; i++) {

		ArrayList<View> viewList = mColumnViews[i];

int size = viewList.size();

int top = viewList.get(0).getTop();

int bottom = viewList.get(size - 1).getBottom();

if (lastBottom > bottom) {

if (endBottom < bottom) {

final Rect listPadding = mListPadding;

final int spaceAbove = listPadding.top - firstTop;

final int end = getHeight() - listPadding.bottom;

final int spaceBelow = lastBottom - end;

final int height = getHeight() - getPaddingBottom() - getPaddingTop();

		deltaY = Math.max(-(height - 1), deltaY);

		deltaY = Math.min(height - 1, deltaY);

if (incrementalDeltaY < 0) {

		incrementalDeltaY = Math.max(-(height - 1), incrementalDeltaY);

		incrementalDeltaY = Math.min(height - 1, incrementalDeltaY);

final int firstPosition = mFirstPosition;

if (firstPosition == 0 && firstTop >= listPadding.top && deltaY >= 0) {

if (firstPosition + childCount == mItemCount && endBottom <= end && deltaY <= 0) {

final boolean down = incrementalDeltaY < 0;

final boolean inTouchMode = isInTouchMode();

final int headerViewsCount = getHeaderViewsCount();

final int footerViewsStart = mItemCount - getFooterViewsCount();

final int top = listPadding.top - incrementalDeltaY;

for (int i = 0; i < childCount; i++) {

final View child = getChildAt(i);

if (child.getBottom() >= top) {

int position = firstPosition + i;

if (position >= headerViewsCount && position < footerViewsStart) {

					mRecycler.addScrapView(child);

int columnIndex = (Integer) child.getTag();

if (columnIndex >= 0 && columnIndex < mColumnCount) {

						mColumnViews[columnIndex].remove(child);

final int bottom = getHeight() - listPadding.bottom - incrementalDeltaY;

for (int i = childCount - 1; i >= 0; i--) {

final View child = getChildAt(i);

if (child.getTop() <= bottom) {

int position = firstPosition + i;

if (position >= headerViewsCount && position < footerViewsStart) {

					mRecycler.addScrapView(child);

int columnIndex = (Integer) child.getTag();

if (columnIndex >= 0 && columnIndex < mColumnCount) {

						mColumnViews[columnIndex].remove(child);

	mMotionViewNewTop = mMotionViewOriginalTop + deltaY;

	mBlockLayoutRequests = true;

		detachViewsFromParent(start, count);

	tryOffsetChildrenTopAndBottom(incrementalDeltaY);

final int absIncrementalDeltaY = Math.abs(incrementalDeltaY);

if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {

		fillGap(down, down ? lastBottom : firstTop);

if (!inTouchMode && mSelectedPosition != INVALID_POSITION) {

final int childIndex = mSelectedPosition - mFirstPosition;

if (childIndex >= 0 && childIndex < getChildCount()) {

			positionSelector(getChildAt(childIndex));

	mBlockLayoutRequests = false;

	invokeOnItemScrollListener();
复制代码

从第 9 行开始看,这里我们使用了一个循环,遍历瀑布流 ListView 中的所有列,每次循环都去获取该列的第一个元素和最后一个元素,然后和 firstTop 及 lastBottom 做比较,以此找出所有列中最靠近屏幕上边缘的元素位置和最靠近屏幕下边缘的元素位置。注意这里除了 firstTop 和 lastBottom 之外,我们还计算了一个 endBottom 的值,这个值记录最底部的元素位置,用于在滑动时做边界检查的。

最重要的修改就是这些了,不过在其它一些地方还做了一些小的改动。观察第 75 行,这里是把被移出屏幕的子 View 添加到 RecycleBin 当中,其实也就是说明这个 View 已经被回收了。那么还记得我们刚刚添加的全局变量 mColumnViews 吗?它用于缓存每一列的子 View,那么当有子 View 被回收的时候,mColumnViews 中也需要进行删除才可以。在第 76 行,先调用 getTag() 方法来获取该子 View 的所处于哪一列,然后调用 remove() 方法将它移出。第 96 行处的逻辑是完全相同的,只不过一个是向上移动,一个是向下移动,这里就不再赘述。

另外还有一点改动,就是我们在第 115 行调用 fillGap() 方法的时候添加了一个参数,原来的 fillGap() 方法只接收一个布尔型参数,用于判断向上还是向下滑动,然后在方法的内部自己获取第一个或最后一个元素的位置来获取偏移值。不过在瀑布流 ListView 中,这个偏移值是需要通过循环进行计算的,而我们刚才在 trackMotionScroll() 方法中其实已经计算过了,因此直接将这个值通过参数进行传递会更加高效。

现在 AbsListView 中需要改动的内容已经结束了,那么我们回到 ListView 当中,首先修改 fillGap() 方法的参数:

void fillGap(boolean down, int startOffset) {

final int count = getChildCount();

		startOffset = count > 0 ? startOffset + mDividerHeight : getListPaddingTop();

		fillDown(mFirstPosition + count, startOffset);

		correctTooHigh(getChildCount());

		startOffset = count > 0 ? startOffset - mDividerHeight : getHeight() - getListPaddingBottom();

		fillUp(mFirstPosition - 1, startOffset);

		correctTooLow(getChildCount());
复制代码

只是将原来的获取数值改成了直接使用参数传递过来的值,并没有什么太大的改动。接下来看一下 fillDown 方法,原先的逻辑是在 while 循环中不断地填充子 View,当新添加的子 View 的下边缘超出 ListView 底部的时候就跳出循环,现在我们进行如下修改:

private View fillDown(int pos, int nextTop) {

	View selectedView = null;

int end = (getBottom() - getTop()) - mListPadding.bottom;

while (nextTop < end && pos < mItemCount) {

boolean selected = pos == mSelectedPosition;

		View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);

int lowerBottom = Integer.MAX_VALUE;

for (int i = 0; i < mColumnViews.length; i++) {

			ArrayList<View> viewList = mColumnViews[i];

int size = viewList.size();

int bottom = viewList.get(size - 1).getBottom();

if (bottom < lowerBottom) {

		nextTop = lowerBottom + mDividerHeight;
复制代码

可以看到,这里在 makeAndAddView 之后并没有直接使用新增的 View 来获取它的 bottom 值,而是再次使用了一个循环来遍历瀑布流 ListView 中的所有列,找出所有列中最靠下的那个子 View 的 bottom 值,如果这个值超出了 ListView 的底部,那就跳出循环。这样的写法就可以保证只要在有子 View 的情况下,瀑布流 ListView 中每一列的内容都是填满的,界面上不会有空白的地方出现。

接下来 makeAndAddView() 方法并没有任何需要改动的地方,但是 makeAndAddView() 方法中调用的 setupChild() 方法,我们就需要大刀阔斧地修改了。

大家应该还记得,setupChild() 方法是用来具体设置子 View 在 ListView 中显示的位置的,在这个过程中可能需要用到几个辅助方法,这里我们先提供好,如下所示:

private int[] getColumnToAppend(int pos) {

int bottom = Integer.MAX_VALUE;

for (int i = 0; i < mColumnViews.length; i++) {

int size = mColumnViews[i].size();

return new int[] { i, 0 };

			View view = mColumnViews[i].get(size - 1);

if (view.getBottom() < bottom) {

				bottom = view.getBottom();

return new int[] { indexToAppend, bottom };

private int[] getColumnToPrepend(int pos) {

int indexToPrepend = mPosIndexMap.get(pos);

int top = mColumnViews[indexToPrepend].get(0).getTop();

return new int[] { indexToPrepend, top };

private void clearColumnViews() {

for (int i = 0; i < mColumnViews.length; i++) {
复制代码

这三个方法全部都非常重要,我们来逐个看一下。getColumnToAppend() 方法是用于判断当 ListView 向下滑动时,新进入屏幕的子 View 应该添加到哪一列的。而判断的逻辑也很简单,其实就是遍历瀑布流 ListView 的每一列,取每一列的最下面一个元素,然后再从中找出最靠上的那个元素所在的列,这就是新增子 View 应该添加到的位置。返回值是待添加位置列的下标和该列最底部子 View 的 bottom 值。原理示意图如下所示:

然后来看一下 getColumnToPrepend() 方法。getColumnToPrepend() 方法是用于判断当 ListView 向上滑动时,新进入屏幕的子 View 应该添加到哪一列的。不过如果你认为这和 getColumnToAppend() 方法其实就是类似或者相反的过程,那你就大错特错了。因为向上滑动时,新进入屏幕的子 View 其实都是之前被移出屏幕后回收的,它们不需要关心每一列最高子 View 或最低子 View 的位置,而是只需要遵循一个原则,就是当它们第一次被添加到屏幕时所属于哪一列,那么向上滑动时它们仍然还属于哪一列,绝不能出现向上滑动导致元素换列的情况。而使用的算法也非常简单,就是根据当前子 View 的 position 值来从 mPosIndexMap 中获取该 position 值对应列的下标,mPosIndexMap 的值在 setupChild() 方法当中填充,这个我们待会就会看到。返回值是待添加位置列的下标和该列最顶部子 View 的 top 值。

最后一个 clearColumnViews() 方法就非常简单了,它就是负责把 mColumnViews 缓存的所有子 View 全部清除掉。

所有辅助方法都提供好了,不过在进行 setupChild 之前我们还缺少一个非常重要的值,那就是列的宽度。普通的 ListView 是不用考虑这一点的,因为列的宽度其实就是 ListView 的宽度。但瀑布流 ListView 则不一样了,列数不同,每列的宽度也会不一样,因此这个值我们需要提前进行计算。修改 onMeasure() 方法中的代码,如下所示:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    setMeasuredDimension(widthSize, heightSize);

    mWidthMeasureSpec = widthMeasureSpec;  

    mColumnWidth = widthSize / mColumnCount;
复制代码

其实很简单,我们只不过在 onMeasure() 方法的最后一行添加了一句代码,就是使用当前 ListView 的宽度除以列数,得到的就是每列的宽度了,这里将列的宽度赋值到 mColumnWidth 这个全局变量上面。

现在准备工作都已经完成了,那么我们开始来修改 setupChild() 方法中的代码,如下所示:

private void setupChild(View child, int position, int y, boolean flowDown, int childrenLeft,

boolean selected, boolean recycled) {

final boolean isSelected = selected && shouldShowSelector();

final boolean updateChildSelected = isSelected != child.isSelected();

final int mode = mTouchMode;

final boolean isPressed = mode > TOUCH_MODE_DOWN && mode < TOUCH_MODE_SCROLL &&

            mMotionPosition == position;

final boolean updateChildPressed = isPressed != child.isPressed();

final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();

    AbsListView.LayoutParams p = (AbsListView.LayoutParams) child.getLayoutParams();

        p = new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,

                ViewGroup.LayoutParams.WRAP_CONTENT, 0);

    p.viewType = mAdapter.getItemViewType(position);

if ((recycled && !p.forceAdd) || (p.recycledHeaderFooter &&

            p.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER)) {

        attachViewToParent(child, flowDown ? -1 : 0, p);

if (p.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {

            p.recycledHeaderFooter = true;

        addViewInLayout(child, flowDown ? -1 : 0, p, true);

if (updateChildSelected) {

        child.setSelected(isSelected);

if (updateChildPressed) {

        child.setPressed(isPressed);

int childWidthSpec = ViewGroup.getChildMeasureSpec(

        		MeasureSpec.makeMeasureSpec(mColumnWidth, MeasureSpec.EXACTLY), 0, p.width);

            childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);

            childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);

        child.measure(childWidthSpec, childHeightSpec);

        cleanupLayoutState(child);

int w = child.getMeasuredWidth();

int h = child.getMeasuredHeight();

int[] columnInfo = getColumnToAppend(position);

int indexToAppend = columnInfo[0];

int childTop = columnInfo[1];

int childBottom = childTop + h;

int childLeft = indexToAppend * w;

int childRight = indexToAppend * w + w;

    		child.layout(childLeft, childTop, childRight, childBottom);

    		child.setTag(indexToAppend);

    		mColumnViews[indexToAppend].add(child);

    		mPosIndexMap.put(position, indexToAppend);

int[] columnInfo = getColumnToPrepend(position);

int indexToAppend = columnInfo[0];

int childBottom = columnInfo[1];

int childTop = childBottom - h;

int childLeft = indexToAppend * w;

int childRight = indexToAppend * w + w;

    		child.layout(childLeft, childTop, childRight, childBottom);

    		child.setTag(indexToAppend);

    		mColumnViews[indexToAppend].add(0, child);

int columnIndex = mPosIndexMap.get(position);

    		mColumnViews[columnIndex].add(child);

    		mColumnViews[columnIndex].add(0, child);

if (mCachingStarted && !child.isDrawingCacheEnabled()) {

        child.setDrawingCacheEnabled(true);
复制代码

第一个改动的地方是在第 33 行,计算 childWidthSpec 的时候。普通 ListView 由于子 View 的宽度和 ListView 的宽度是一致的,因此可以在 ViewGroup.getChildMeasureSpec() 方法中直接传入 mWidthMeasureSpec,但是在瀑布流 ListView 当中则需要再经过一个 MeasureSpec.makeMeasureSpec 过程来计算每一列的 widthMeasureSpec,传入的参数就是我们刚才保存的全局变量 mColumnWidth。经过这一步修改之后,调用 child.getMeasuredWidth() 方法获取到的子 View 宽度就是列的宽度,而不是 ListView 的宽度了。

接下来在第 48 行判断 needToMeasure,如果是普通情况下的填充或者 ListView 滚动,needToMeasure 都是为 true 的,但如果是点击 ListView 触发 onItemClick 事件这种场景,needToMeasure 就会是 false。针对这两种不同的场景处理的逻辑也是不一样的,我们先来看一下 needToMeasure 为 true 的情况。

在第 49 行判断,如果是向下滑动,则调用 getColumnToAppend() 方法来获取新增子 View 要添加到哪一列,并计算出子 View 左上右下的位置,最后调用 child.layout() 方法完成布局。如果是向上滑动,则调用 getColumnToPrepend() 方法来获取新增子 View 要添加到哪一列,同样计算出子 View 左上右下的位置,并调用 child.layout() 方法完成布局。另外,在设置完子 View 布局之后,我们还进行了几个额外的操作。child.setTag() 是给当前的子 View 打一个标签,记录这个子 View 是属于哪一列的,这样我们在 trackMotionScroll() 的时候就可以调用 getTag() 来获取到该值,mColumnViews 和 mPosIndexMap 中的值也都是在这里填充的。

接着看一下 needToMeasure 为 false 的情况,首先在第 72 行调用 mPosIndexMap 的 get() 方法获取该 View 所属于哪一列,接着判断是向下滑动还是向上滑动,如果是向下滑动,则将该 View 添加到 mColumnViews 中所属列的末尾,如果是向上滑动,则向该 View 添加到 mColumnViews 中所属列的顶部。这么做的原因是因为当 needToMeasure 为 false 的时候,所有 ListView 中子元素的位置都不会变化,因而不需要调用 child.layout() 方法,但是 ListView 仍然还会走一遍 layoutChildren 的过程,而 layoutChildren 算是一个完整布局的过程,所有的缓存值在这里都应该被清空,所以我们需要对 mColumnViews 重新进行赋值。

那么说到 layoutChildren 过程中所有的缓存值应该清空,很明显我们还没有进行这一步,那么现在修改 layoutChildren() 方法中的代码,如下所示:

protected void layoutChildren() {

if (!blockLayoutRequests) {

            mBlockLayoutRequests = false;
复制代码

很简单,由于刚才我们已经提供好辅助方法了,这里只需要在开始 layoutChildren 过程之前调用一下 clearColumnViews() 方法就可以了。

最后还有一个细节需要注意,之前在定义 mColumnViews 的时候,其实只是定义了一个长度为 mColumnCount 的 ArrayList 数组而已,但数组中的每个元素目前还都是空的,因此我们还需要在 ListView 开始工作之前对数组中的每个元素进行初始化才行。那么修改 ListView 构造函数中的代码,如下所示:

public ListView(Context context, AttributeSet attrs, int defStyle) {

super(context, attrs, defStyle);

for (int i = 0; i < mColumnViews.length; i++) {

    	mColumnViews[i] = new ArrayList<View>();
复制代码

这样基本上就算是把所有的工作都完成了。现在重新运行一下 UIListViewTest 项目,效果如下图所示:

恩,效果还是相当不错的,说明我们对 ListView 的功能扩展已经成功实现了。值得一题的是,这个功能扩展对于调用方而言是完全不透明的,也就是说在使用瀑布流 ListView 的时候其实仍然在使用标准的 ListView 用法,但是自动就变成了这种瀑布流的显示模式,而不用做任何特殊的代码适配,这种设计体验对于调用方来说是非常友好的。

另外我们这个瀑布流 ListView 并不仅仅支持两列内容显示而已,而是可以轻松指定任意列数显示,比如将 mColumnCount 的值改成 3,就可以变成三列显示了。不过三列显示有点挤,这里我把屏幕设置成横屏再来看一下效果:

测试结果还是比较让人满意的。

最后还需要提醒大家一点,本篇文章中的例子仅供参考学习,是用于帮助大家理解源码和提升水平的,切误将本篇文章中的代码直接使用在正式项目当中,不管在功能性还是稳定性方面,例子中的代码都还达不到商用产品的标准。如果确实需要在项目实现瀑布流布局的效果,可以使用开源项目 [PinterestLikeAdapterView]的代码,或者使用 Android 新推出的 RecyclerView 控件,RecyclerView 中的 StaggeredGridLayoutManager 也是可以轻松实现瀑布流布局效果的。

好的,那么今天就到这里了,ListView 系列的内容也到此结束,相信大家通过这三篇文章的学习,对 ListView 一定都有了更深一层的理解,使用 ListView 时碰到了什么问题也可以更多从源码和工作原理的层次去考虑如何解决。感谢大家可以看到最后。

关注我的技术公众号“郭霖”,优质技术文章推送。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享