本文已授权[郭霖]公众号独家发布
目录
- 1. 前言
- 2. 正文
- 2.1 创建直接继承于 ViewGroup 的子类 FlowLayout
- 2.2 测量过程
- 2.3 布局过程
- 2.4 测试一波儿
- 2.5 解决子元素显示不出来的问题
- 2.6 解决子元素行数显示不对的问题
- 2.7 解决流式布局不支持 padding 的问题
- 2.8 解决流式布局不支持子元素的 layout_margin 的问题
- 2.9 解决动态添加子元素时发生类型转换异常的问题
- 2.10 自定义流式布局的自定义属性之行内垂直靠顶部,居中,靠底部
- 2.11 自定义流式布局的自定义属性之限制行数
- 2.12 自定义流式布局的自定义属性之限制个数
- 2.13 解决限制行数与限制个数设置冲突的问题
- 2.14 解决流式布局宽度方向上没有限制时仍会换行的问题
- 2.15 解决流式布局高度会大于父布局高度的问题
- 2.16 流式布局的状态保存与恢复
- 2.17 优化 onMeasure 方法中分配对象的问题
- 3. 最后
- 4. 参考
1. 前言
什么是流式布局呢?
京东 app 的搜索历史就是使用了流式布局,请看下图:
淘宝 app 的搜索历史也是使用了流式布局,请看下图:
玩Android网站里也用到了流式布局,请看下图:
流式布局有什么好处呢?
流式布局可以有效地利用屏幕空间,使页面内容显示更加紧凑。
那么流式布局的效果是怎么做出来的呢?
- 使用自定义
ViewGroup
的方式; - 使用自定义
LayoutManager
的方式。
本文采用自定义 ViewGroup
的方式来实现,最终效果如下:
会从以下几个方面展开来说明:
- 自定义流式布局的测量过程;
- 自定义流式布局的布局过程;
- 自定义流式布局的padding支持;
- 自定义流式布局对子元素的 layout_margin 的支持;
- 自定义流式布局动态添加子元素的类型转换异常处理;
- 自定义流式布局的自定义属性;
- 自定义流式布局的状态保存与恢复;
- 自定义流式布局的一些优化。
2. 正文
2.1 创建直接继承于 ViewGroup 的子类 FlowLayout
class FlowLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
}
注意到此时 FlowLayout
的类名下有红色波浪警告线:
原因是 ViewGroup
是一个抽象类,包含一个抽象方法 onLayout
:
protected abstract void onLayout(boolean changed,
int l, int t, int r, int b);
而这里 FlowLayout
是一个具体的类,所以它必须实现 onLayout
方法。
这样就没有红色波浪线了。
另外,需要注意的是,使用带命名参数的构造方法时,必须在 constructor
关键字前加 @JvmOverloads
注解,如果不加的话,会报错:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.blog.flowlayout, PID: 27067
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.blog.flowlayout/com.blog.flowlayout.MainActivity}: android.view.InflateException: Binary XML file line #12 in com.blog.flowlayout:layout/activity_main: Binary XML file line #12 in com.blog.flowlayout:layout/activity_main: Error inflating class com.example.lib.FlowLayout
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3521)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3693)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2135)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:236)
at android.app.ActivityThread.main(ActivityThread.java:8060)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)
Caused by: android.view.InflateException: Binary XML file line #12 in com.blog.flowlayout:layout/activity_main: Binary XML file line #12 in com.blog.flowlayout:layout/activity_main: Error inflating class com.example.lib.FlowLayout
Caused by: android.view.InflateException: Binary XML file line #12 in com.blog.flowlayout:layout/activity_main: Error inflating class com.example.lib.FlowLayout
Caused by: java.lang.NoSuchMethodException: com.example.lib.FlowLayout.<init> [class android.content.Context, interface android.util.AttributeSet]
at java.lang.Class.getConstructor0(Class.java:2332)
at java.lang.Class.getConstructor(Class.java:1728)
at android.view.LayoutInflater.createView(LayoutInflater.java:826)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:1008)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:963)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:1125)
at android.view.LayoutInflater.rInflateChildren(LayoutInflater.java:1086)
at android.view.LayoutInflater.inflate(LayoutInflater.java:684)
at android.view.LayoutInflater.inflate(LayoutInflater.java:536)
at android.view.LayoutInflater.inflate(LayoutInflater.java:479)
at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696)
at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170)
at com.blog.flowlayout.MainActivity.onCreate(MainActivity.kt:9)
at android.app.Activity.performCreate(Activity.java:8143)
at android.app.Activity.performCreate(Activity.java:8115)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1310)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3494)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3693)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2135)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:236)
at android.app.ActivityThread.main(ActivityThread.java:8060)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)
查看日志可以得出原因是没有 FlowLayout(Context context, AttributeSet attrs)
这个构造方法导致的。只有加上 @JvmOverloads
注解才会生成这样的构造方法。
2.2 测量过程
我们知道,对于自定义 ViewGroup
来说,除了完成自己的 measure
过程以外,还要遍历去调用所有子元素的 measure
方法,各个子元素再递归去执行 measure
过程。
自定义ViewGroup
完成自己的 measure
过程是指在 onMeasure
方法里调用了 setMeasuredDimension(int measuredWidth, int measuredHeight)
方法,参数分别是 ViewGroup
的测量宽度和测量高度。
对于流式布局来说,是应该先完成自己的 measure
过程,再去完成子元素的 measure
过程,还是先完成子元素的 measure
过程,再去完成自己的 measure
过程呢?
应该先完成子元素的 measure
过程,再去完成自己的 measure
过程,因为流式布局的宽高依赖于子元素。
测量思路如下:
重写 onMeasure
方法,在 onMeasure
方法中:
- 获取流式布局允许的最大宽度;
- 把所有子元素一行一行地放置,如果当前行已经放不下一个子元素,就把这个子元素移到下一行显示;
- 行高是一行中所有子元素高度的最大值;
- 所有行宽度的最大值作为流式布局宽度的参考值;
- 计算出流式布局所占的区域大小。
// 子元素水平间距
private var itemHorizontalSpacing = 20
// 子元素竖直间距
private var itemVerticalSpacing = 20
/**
* 记录每一行所有的子 View 的集合
*/
private val allLineViews = ArrayList<ArrayList<View>>()
/**
* 所有行的行高的集合
*/
private val lineHeights = ArrayList<Int>()
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
// 获取流式布局允许的最大宽度
val maxWidth = widthSize
var lineWidth = 0 // 行宽
var maxLineWidth = 0 // 最大行宽
var lineHeight = 0 // 行高
var totalHeight = 0 // 总高度
val childCount = getChildCount()
var lineViews = ArrayList<View>()
var lineCount = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != View.GONE) {
// 测量子 View
val lp = child.layoutParams
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeft() + getPaddingRight(), lp.width)
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
getPaddingTop() + getPaddingBottom(), lp.height)
child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
// 获取子 View 的测量宽/高
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
val actualItemHorizontalSpacing = if (lineWidth == 0) 0 else itemHorizontalSpacing
if (lineWidth + actualItemHorizontalSpacing + childMeasuredWidth <= maxWidth) {
// 在本行还可以放置一个子 View
lineWidth += actualItemHorizontalSpacing + childMeasuredWidth
// 行高为一行中所有子 View 最高的那一个
lineHeight = max(lineHeight, childMeasuredHeight)
lineViews.add(child)
} else {
// 在本行不可以放置一个子 View,需要换行
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
lineWidth = childMeasuredWidth
lineHeight = childMeasuredHeight
lineViews = ArrayList<View>()
lineViews.add(child)
}
}
}
val measuredWidth = if (widthMode == MeasureSpec.EXACTLY) widthSize else maxLineWidth
val measuredHeight = if (heightMode == MeasureSpec.EXACTLY) heightSize else totalHeight
setMeasuredDimension(measuredWidth, measuredHeight)
}
2.3 布局过程
流式布局在 onLayout
方法中确定子元素的位置。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 获取行数
val lineCount = allLineViews.size
// 子元素的左上角横坐标
var childLeft = 0
// 子元素的左上角纵坐标
var childTop = 0
// 遍历行
for (i in 0 until lineCount) {
val lineViews = allLineViews[i]
val lineHeight = lineHeights[i]
// 遍历一行中的所有子元素
for (j in 0 until lineViews.size) {
val child = lineViews[j]
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
// 确定子元素的位置
child.layout(childLeft, childTop, childLeft + childMeasuredWidth, childTop + childMeasuredHeight)
// 更新 childLeft,作为该行下一个子元素的左上角横坐标
childLeft += childMeasuredWidth + itemHorizontalSpacing
}
// 更新 childTop,作为下一行子元素的左上角纵坐标
childTop += lineHeight + itemVerticalSpacing
// 更新 childLeft,作为下一行子元素的左上角横坐标
childLeft = 0
}
}
2.4 测试一波儿
设置布局如下:
<?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-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<com.example.lib.FlowLayout
android:background="#44ff0000"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
android:text="Android"
style="@style/ItemStyle" />
<TextView
android:text="Kotlin"
style="@style/ItemStyle" />
<TextView
android:text="Java"
style="@style/ItemStyle" />
<TextView
android:text="Custom View"
style="@style/ItemStyle" />
</com.example.lib.FlowLayout>
</LinearLayout>
我们给 FlowLayout
添加了 4 个子元素。
ItemStyle
资源如下:
<style name="ItemStyle">
<item name="android:background">@drawable/shape_button_circular</item>
<item name="android:paddingLeft">12dp</item>
<item name="android:paddingRight">12dp</item>
<item name="android:paddingTop">2dp</item>
<item name="android:paddingBottom">2dp</item>
<item name="android:textSize">24sp</item>
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
</style>
shape_button_circular.xml
资源如下:
<?xml version="1.0" encoding="utf-8" ?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE7BA" />
<padding android:bottom="8dp"
android:left="8dp"
android:right="8dp"
android:top="8dp"/>
<!--设置圆角-->
<corners android:radius="25dp" />
</shape>
在 Activity
里面没有添加任何代码,运行程序,界面如下:
咦…,怎么看不到子元素呢?流式布局的宽高都是有的,因为我们可以看到它的浅红色的背景。
查看一下代码逻辑,也没有发现什么问题啊。
这该怎么办呢?
定位问题的思路无非两种:打印日志 和 debug。
这里我们先打印一些日志:
在 onMeasure
方法的 val measuredWidth = if (widthMode == MeasureSpec.EXACTLY) widthSize else maxLineWidth
后面添加:
Log.d(TAG, "onMeasure: lineCount=$lineCount")
在 onLayout
方法的 val lineCount = allLineViews.size
后面添加:
Log.d(TAG, "onLayout: lineCount=$lineCount")
运行程序查看日志:
D/FlowLayout: onMeasure: lineCount=1
D/FlowLayout: onMeasure: lineCount=1
D/FlowLayout: onLayout: lineCount=2
可以看到 onMeasure
方法执行了两次,打印出的行数每次都是1,onLayout
方法执行了一次,打印出的行数是 2 。
也就是说,onMeasure
方法里测量时得到的行数是1,但是在 onLayout
方法里得到的行数却是 2 了。而 onLayout
方法得到行数是从 val lineCount = allLineViews.size
来的。allLineViews
集合的大小是 2,它应该是 1 才对啊,为什么不是 1 呢?
这是因为 allLineViews
是成员变量,onMeasure
方法每次执行都会向该对象添加数据。而这里的 onMeasure
方法执行了 2 次,所以向 allLineViews
添加了 2 次数据:每次添加 1 行,所以总共是 2 行了。
好吧,这就是导致屏幕上看不到子元素的原因吗?
是的。
当 allLineViews
里有相同的两行时,每一行里包含的子元素是一模一样的;在布局时,就是先把这行元素布局在第 1 行,然后再把同样的行元素布局在第 2 行。
但是,这里我们的流式布局是只显示 1 行的(这是在 onMeasure
决定的),所以就看不到子元素了。
2.5 解决子元素显示不出来的问题
我们应该在 onMeasure
方法里开头的地方对成员变量的集合做一次清除操作:
在 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
后添加代码:
allLineViews.clear()
lineHeights.clear()
运行程序,查看效果:
可以看到子元素了!!!
但是,细心的我们记得之前我们给 FlowLayout
添加了 4 个子元素,这里怎么才显示 3 个?第 4 个子元素:文字显示为 Custom View 的子元素怎么没有显示出来?
2.6 解决子元素行数显示不对的问题
先来仔细看下换行的代码:
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != View.GONE) {
// 测量子 View
val lp = child.layoutParams
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeft() + getPaddingRight(), lp.width)
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
getPaddingTop() + getPaddingBottom(), lp.height)
child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
// 获取子 View 的测量宽/高
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
val actualItemHorizontalSpacing = if (lineWidth == 0) 0 else itemHorizontalSpacing
if (lineWidth + actualItemHorizontalSpacing + childMeasuredWidth <= maxWidth) {
// 在本行还可以放置一个子 View
lineWidth += actualItemHorizontalSpacing + childMeasuredWidth
// 行高为一行中所有子 View 最高的那一个
lineHeight = max(lineHeight, childMeasuredHeight)
lineViews.add(child)
} else {
// 在本行不可以放置一个子 View,需要换行
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
lineWidth = childMeasuredWidth
lineHeight = childMeasuredHeight
lineViews = ArrayList<View>()
lineViews.add(child)
}
}
}
-
如果所有的子元素排列起来不足一行,会被统计为一行吗?
答:不会,目前我们的代码是只有换行的时候,才会统计一行。
-
最后一行子元素会被统计为一行吗?比如现在有 4 个子元素,其中 3 个放在第一行,最后 1 个放在第二行,那么第二行会被统计上吗?
答:不会的,目前我们的代码是只有换行的时候,才会统计一行。第二行没有达到换行,所以不会被统计进去。
怎么办呢?
从上面的分析可以知道,就目前的代码来看,最后一行子元素是统计不上的。解决办法:如果发现当前遍历到最后一个子元素,就把当前行进行一次统计。代码如下:
if (child.visibility != View.GONE) {
// 省略代码
}
// 在 if (child.visibility != View.GONE) 分支后,添加如下代码
if (i == childCount - 1) {
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
}
运行程序,效果如下:
ok 了。
2.7 解决流式布局不支持 padding 的问题
现在我们在 xml 中给 FlowLayout
添加 padding
,代码如下:
<com.example.lib.FlowLayout
android:background="#44ff0000"
android:padding="8dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
运行程序,查看效果:
怎么子元素显示都不全啊?
这是因为 ViewGroup
设置了 padding 时,默认不允许子元素在 padding 区域绘制。具体来说,是因为 clipToPadding
属性的默认值是 true
(参考:Android clipToPadding 原理你了解过吗?)。而我们布局子元素时并没有考虑 padding 的因素。
为了解决这一问题,我们在测量以及布局时都要考虑到 padding:测量时可用的宽高不包括 padding 区域,布局时不能把子元素摆放在 padding 区域。
在 onMeasure
方法里做出修改
可用的宽度为最大宽度-左右 padding ,所以修改
if (lineWidth + actualItemHorizontalSpacing + childMeasuredWidth <= maxWidth)
为
if (lineWidth + actualItemHorizontalSpacing + childMeasuredWidth <= maxWidth - getPaddingLeft() - getPaddingRight())
虽然测量时可用的宽高不包括 padding 区域,但是 padding 区域确实是流式布局宽高的一部分,所以在 onMeasure
方法里的 for
循环之后,添加如下代码:
maxLineWidth += getPaddingLeft() + getPaddingRight()
totalHeight += getPaddingTop() + getPaddingBottom()
在 onLayout
方法里做出修改
更新 childLeft
和 childTop
的初始值:
// 子元素的左上角横坐标
var childLeft = getPaddingLeft()
// 子元素的左上角纵坐标
var childTop = getPaddingTop()
更新外层 for
循环里 childLeft
的值:
// 更新 childLeft,作为下一行子元素的左上角横坐标
childLeft = getPaddingLeft()
修改完毕,运行程序,查看效果:
O 了。
2.8 解决流式布局不支持子元素的 layout_margin 的问题
我们修改 xml 布局的第 3 个子元素,给它添加 android:layout_margin="16dp"
:
<TextView
android:layout_margin="16dp"
android:text="Java"
style="@style/ItemStyle" />
先不运行程序,想一下预期的效果是什么样子的?
应该是文本为 Java 的子元素和周围子元素以及流式布局的间距都会变大。
运行程序,查看效果:
居然和对第 3 个子元素不设置android:layout_margin="16dp"
的效果是一毛一样的。
这肯定是有问题的。
原因是在测量和布局过程中,我们压根儿就没有考虑到子元素的 margin 值,自然就不会有子元素的 margin 效果了。
怎么办呢?
首先要获取到子元素的 margin 值,通过把子元素的 LayoutParams
对象强转为 MarginLayoutParams
:
val lp = child.layoutParams as MarginLayoutParams
为什么要强转为 MarginLayoutParams
呢?这是因为 MarginLayoutParams
可以解析 margin 相关的布局参数,而 LayoutParams
只可以解析宽高的布局参数。
但是,这样直接强转会不会报类型转换错误呢?先思考一下。
其次在测量时和布局时都使用到获取的子元素 margin 值参与测量和布局:
在 onMeausure
方法中处理换行时,使用 actualChildWidth
和 actualChildHeight
来替换原来使用的 childMeasuredWidth
和 childMeasuredHeight
。
val actualChildWidth = childMeasuredWidth + lp.leftMargin + lp.rightMargin
val actualChildHeight = childMeasuredHeight + lp.topMargin + lp.bottomMargin
val actualItemHorizontalSpacing = if (lineWidth == 0) 0 else itemHorizontalSpacing
if (lineWidth + actualItemHorizontalSpacing + actualChildWidth /*这里替换了childMeasuredWidth*/
<= maxWidth - getPaddingLeft() - getPaddingRight()) {
// 在本行还可以放置一个子 View
lineWidth += actualItemHorizontalSpacing + actualChildWidth /*这里替换了childMeasuredWidth*/
// 行高为一行中所有子 View 最高的那一个
lineHeight = max(lineHeight, actualChildHeight /*这里替换了childMeasuredHeight*/ )
lineViews.add(child)
} else {
// 在本行不可以放置一个子 View,需要换行
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
lineWidth = actualChildWidth /*这里替换了childMeasuredWidth*/
lineHeight = actualChildHeight /*这里替换了childMeasuredHeight*/
lineViews = ArrayList<View>()
lineViews.add(child)
}
在 onLayout
方法中,对子元素进行布局时考虑到子元素的 margin 值:
for (j in 0 until lineViews.size) {
val child = lineViews[j]
val lp = child.layoutParams as MarginLayoutParams
childLeft += lp.leftMargin
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
// 确定子元素的位置
child.layout(childLeft, childTop + lp.topMargin, childLeft + childMeasuredWidth, childTop + lp.topMargin + childMeasuredHeight)
// 更新 childLeft,作为该行下一个子元素的左上角横坐标
childLeft += childMeasuredWidth + lp.rightMargin + itemHorizontalSpacing
}
运行一下程序,发现程序崩溃了,报出如下错误了:
Process: com.blog.flowlayout, PID: 24130
java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to android.view.ViewGroup$MarginLayoutParams
at com.example.lib.FlowLayout.onMeasure(FlowLayout.kt:49)
...
果然是在 val lp = child.layoutParams as MarginLayoutParams
时报出了类型转换异常。
解决这个问题需要在 FlowLayout
类中重写 ViewGroup
的 generateLayoutParams(AttributeSet attrs)
方法(是在 LayoutInflater
的 rInflate
方法中被调用的:final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
):
/**
* Returns a new set of layout parameters based on the supplied attributes set.
*
* @param attrs the attributes to build the layout parameters from
*
* @return an instance of {@link android.view.ViewGroup.LayoutParams} or one
* of its descendants
*/
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
重写如下:
// 当通过 xml 添加时,会走这个方法获取子 View 的布局参数
// 但是,默认的实现只会从 AttributeSet 里解析 layout_width 和 layout_height 这两个属性
// 这里重写的目的是解析 margin 属性。
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(getContext(), attrs)
}
再次运行程序,效果如下:
Good。
2.9 解决动态添加子元素时发生类型转换异常的问题
在实际开发中,除了直接在 xml 中给流式布局添加子元素,还可以在代码中通过 addView
方法给流式布局添加子元素,对应代码如下:
binding.flowlayout.addView(TextView(this).apply {
text = tabArray[tabIndex]
setBackgroundResource(R.drawable.shape_button_circular)
setPadding(12.dp2px(), 2.dp2px(), 12.dp2px(), 2.dp2px())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
})
运行程序,又崩溃了,查看报错日志:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.blog.flowlayout, PID: 25517
java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to android.view.ViewGroup$MarginLayoutParams
at com.example.lib.FlowLayout.onMeasure(FlowLayout.kt:49)
啊…,又是类型转换异常,刚才不是解决了一个了吗?怎么又来一个啊!!!
没事儿,我有办法,在 FlowLayout
里重载 generateDefaultLayoutParams
方法:
// 当通过 addView(View) 方法添加子元素,并且子元素没有设置布局参数时,会调用此方法来生成默认的布局参数
// 这里重写返回 MarginLayoutParams 对象,是为了在获取子元素的 LayoutParams 对象时,可以正常强转为 MarginLayoutParams
override fun generateDefaultLayoutParams(): LayoutParams {
return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
再次运行程序,效果如下:
可以了。
我们把动态添加子元素的代码修改如下:
binding.flowlayout.addView(TextView(this).apply {
text = tabArray[tabIndex]
setBackgroundResource(R.drawable.shape_button_circular)
setPadding(12.dp2px(), 2.dp2px(), 12.dp2px(), 2.dp2px())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
// 这行代码是新添加的
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
})
再次运行程序,崩溃了,报错如下:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.blog.flowlayout, PID: 13750
java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to android.view.ViewGroup$MarginLayoutParams
at com.example.lib.FlowLayout.onMeasure(FlowLayout.kt:49)
怎么又又又是类型转换异常?
没事,我们有办法。
// 检查传入的布局参数是否符合某个条件
override fun checkLayoutParams(p: LayoutParams?): Boolean {
return p is MarginLayoutParams
}
// addViewInner 中调用,但是布局参数类型无法通过 checkLayoutParams() 判断时,会走这个方法。
override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
return MarginLayoutParams(p)
}
运行程序,可以正常添加子元素。
2.10 自定义流式布局的自定义属性之行内垂直靠顶部,居中,靠底部
目前我们在行内垂直方向上的子元素是靠顶部布局的,希望可以实现靠顶部,居中,靠底部三种效果。
定义自定义属性,在 res/values 文件下新建 attrs.xml
:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="FlowLayout">
<attr name="flowlayout_line_vertical_gravity" format="enum">
<enum name="top" value="0"/>
<enum name="center_vertical" value="1"/>
<enum name="bottom" value="2" />
</attr>
</declare-styleable>
</resources>
在 FlowLayout
中解析自定义属性,并定义属性的 getter/setter 方法:
var lineVerticalGravity: Int = LINE_VERTICAL_GRAVITY_CENTER_VERTICAL
set(value) {
field = value
requestLayout()
}
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout)
lineVerticalGravity = ta.getInt(R.styleable.FlowLayout_flowlayout_line_vertical_gravity, LINE_VERTICAL_GRAVITY_CENTER_VERTICAL)
Log.d(TAG, "init: lineVerticalGravity=$lineVerticalGravity")
ta.recycle()
}
companion object {
private const val TAG = "FlowLayout"
const val LINE_VERTICAL_GRAVITY_TOP = 0
const val LINE_VERTICAL_GRAVITY_CENTER_VERTICAL = 1
const val LINE_VERTICAL_GRAVITY_BOTTOM = 2
}
在 xml 布局中使用自定义属性:
<com.example.lib.FlowLayout
android:id="@+id/flowlayout"
android:background="#44ff0000"
android:padding="8dp"
app:flowlayout_line_vertical_gravity="top"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
运行程序,查看是否可以正常获取到属性值,打印如下:
D/FlowLayout: init: lineVerticalGravity=0
可以正常获取属性值。
在 Activity 中,我们还添加一些代码里动态设置 lineVerticalGravity 的 menu 项:
R.id.action_line_vertical_gravity_top -> {
binding.flowlayout.lineVerticalGravity = FlowLayout.LINE_VERTICAL_GRAVITY_TOP
true
}
R.id.action_line_vertical_gravity_center_vertical -> {
binding.flowlayout.lineVerticalGravity = FlowLayout.LINE_VERTICAL_GRAVITY_CENTER_VERTICAL
true
}
R.id.action_line_vertical_gravity_bottom -> {
binding.flowlayout.lineVerticalGravity = FlowLayout.LINE_VERTICAL_GRAVITY_BOTTOM
true
}
自定义属性的定义,解析,设置工作都完成了,接着看如何使用自定义属性吧。
在 FlowLayout
中定义 getOffsetTop
方法,用于获取子元素顶部的偏移量:
private fun getOffsetTop(lineHeight: Int, child: View): Int {
val lp = child.layoutParams as MarginLayoutParams
val childMeasuredHeight = child.getMeasuredHeight()
val childMeasuredHeightWithMargin = childMeasuredHeight + lp.topMargin + lp.bottomMargin
return when (lineVerticalGravity) {
LINE_VERTICAL_GRAVITY_TOP -> 0
LINE_VERTICAL_GRAVITY_CENTER_VERTICAL -> (lineHeight - childMeasuredHeightWithMargin) / 2
LINE_VERTICAL_GRAVITY_BOTTOM -> lineHeight - childMeasuredHeightWithMargin
else -> {
throw IllegalArgumentException("unknown lineVerticalGravity value: $lineVerticalGravity")
}
}
}
在 onLayout
方法的内层 for
循环里使用 getOffsetTop
:
val offsetTop = getOffsetTop(lineHeight, child)
// 确定子元素的位置
child.layout(childLeft, childTop + lp.topMargin + offsetTop, childLeft + childMeasuredWidth,
childTop + lp.topMargin + offsetTop+ childMeasuredHeight)
运行程序,效果如下:
2.11 自定义流式布局的自定义属性之限制行数
在 attrs.xml 文件中添加自定义属性:
<declare-styleable name="FlowLayout">
...
<attr name="android:maxLines"/>
</declare-styleable>
可以看到,这里添加的是 Android 系统已经有的自定义属性,这时候要写上 android 的命名空间。
在 FlowLayout
里面,解析定义的自定义属性,并设置代码动态设置行数的方法:
var maxLines: Int = Int.MAX_VALUE
set(value) {
field = value
requestLayout()
}
init {
// 默认值为 Int.MAX_VALUE,表示不限制行数
maxLines = ta.getInt(R.styleable.FlowLayout_android_maxLines, Int.MAX_VALUE)
Log.d(TAG, "init: maxLines=$maxLines")
}
属性的定义和获取工作已经完成了,下面开始去使用它吧。
我们的思路是:
在 onMeasure
方法中,在新增一行之前去判断当前行数是否等于最大行数,如果等于就结束遍历子元素。
在 onLayout
方法中,需要做修改吗?不需要,因为在 onMeasure
方法里我们已经控制了行数,在 onLayout
方法里自然不会再多布局元素了。
在 onMeasure
方法中,有两处新增行:
第一处:
if (lineWidth + actualItemHorizontalSpacing + actualChildWidth <= maxWidth - getPaddingLeft() - getPaddingRight()) {
...
} else {
// 如果当前行数等于最大行数,就结束遍历子元素
if (lineCount == maxLines) {
break
}
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
...
}
第二处:
if (i == childCount - 1) {
// 如果当前行数等于最大行数,就结束遍历子元素
if (lineCount == maxLines) {
break
}
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
}
在页面里添加测试代码:
R.id.action_maxlines_1 -> {
binding.flowlayout.maxLines = 1
true
}
R.id.action_maxlines_3 -> {
binding.flowlayout.maxLines = 3
true
}
R.id.action_maxlines_maxCount_no_limit -> {
binding.flowlayout.maxLines = Int.MAX_VALUE
true
}
运行程序,效果如下:
查看日志,可以看到在 onMeasure
方法和 onLayout
方法打印的行数是一致的:
D/FlowLayout: onMeasure: lineCount=1
D/FlowLayout: onLayout: lineCount=1
D/FlowLayout: onMeasure: lineCount=3
D/FlowLayout: onLayout: lineCount=3
D/FlowLayout: onMeasure: lineCount=6
D/FlowLayout: onLayout: lineCount=6
2.12 自定义流式布局的自定义属性之限制个数
在 attrs.xml
中添加自定义属性:
<declare-styleable name="FlowLayout">
...
<attr name="maxCount" format="integer"/>
</declare-styleable>
在 FlowLayout
中解析自定义属性并设置代码中动态设置的方法:
var maxCount: Int = Int.MAX_VALUE
set(value) {
field = value
requestLayout()
}
init {
maxCount = ta.getInt(R.styleable.FlowLayout_maxCount, Int.MAX_VALUE)
}
使用个数限制的思路是:当个数达到个数限制时,就结束遍历子元素。
具体来说,
在 onMeasure
方法中,定义一个局部变量:
var measuredChildCount = 0
用于记录参与测量的元素个数。
在统计到元素参与测量时,就自增 measuredChildCount
:
if (child.visibility != View.GONE) {
...
measuredChildCount++
}
在元素个数达到个数限制时,就结束子元素遍历:
if (i == childCount - 1 || (measuredChildCount == maxCount)) {
if (lineCount == maxLines) {
break
}
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
if (measuredChildCount == maxCount) {
break
}
}
在 onLayout
方法没有做修改。
运行程序,查看效果:
可以看到,限制最多 3 个时,屏幕上显示的是 3 个;但是,当限制最多 6 个时,屏幕上显示的是 7 个。
这肯定是不对的。
在 onLayout
方法添加打印日志的代码:
val itemCount = allLineViews.sumOf { it.size }
Log.d(TAG, "onLayout: itemCount=$itemCount,childCount=$childCount")
查看日志:
D/FlowLayout: onMeasure: lineCount=1
D/FlowLayout: onLayout: lineCount=1
D/FlowLayout: onLayout: itemCount=3,childCount=14
D/FlowLayout: onMeasure: lineCount=2
D/FlowLayout: onLayout: lineCount=2
D/FlowLayout: onLayout: itemCount=6,childCount=14
当限制两行时,打印出的 itemCount
确实是 6 个,childCount
是 14 个。
为什么 childCount
是 14 个呢?
这是因为 FlowLayout
这个 ViewGroup
里有 14 个子元素,在限制个数时,并没有更改子元素的个数,所以流式布局的子元素个数是没有变化的。
那么,为什么限制最多显示 6 个,而实际上却显示了 7 个?
当屏幕上的元素个数没有限制时,显示为 14 个。当限制最多显示 6 个时,计算出需要的行数为 2 行,元素个数为 6 个,这时在 onLayout
方法里,只会对前 6 个元素进行重新布局,而第 7 个元素正好位于第 2 行,没有经过重新布局,所以它的显示不受任何影响。
解决办法是:对在限制个数以外的子元素进行重新布局,布局在 (0, 0, 0, 0)
的位置。
在 onLayout
中定义
var nextChildIndex = 0
在内层 for
循环里添加:
nextChildIndex = indexOfChild(child)
在外层 for
循环结束后添加:
val childCount = getChildCount()
for (i in nextChildIndex + 1 until childCount) {
val child = getChildAt(i)
if (child.visibility == View.GONE) {
continue
}
child.layout(0,0,0,0)
}
重新运行程序,效果如下:
看效果是正常了。
2.13 解决限制行数与限制个数设置冲突的问题
因为 maxLines
和 maxCount
都是在 onMeasure
方法中使用的,我们希望设置限制行数时,限制个数的设置不要使用;设置限制个数时,限制行数的设置不要使用。
定义模式常量来区分:
const val MODE_LIMIT_MAX_LINE = 0
const val MODE_LIMIT_MAX_COUNT = 1
在 onMeasure
方法中,需要使用限制行数或限制个数的地方添加 mode 判断:
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != View.GONE) {
// 测量子 View
val lp = child.layoutParams as MarginLayoutParams
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeft() + getPaddingRight(), lp.width)
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
getPaddingTop() + getPaddingBottom(), lp.height)
child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
// 获取子 View 的测量宽/高
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
val actualChildWidth = childMeasuredWidth + lp.leftMargin + lp.rightMargin
val actualChildHeight = childMeasuredHeight + lp.topMargin + lp.bottomMargin
val actualItemHorizontalSpacing = if (lineWidth == 0) 0 else itemHorizontalSpacing
if (lineWidth + actualItemHorizontalSpacing + actualChildWidth <= maxWidth - getPaddingLeft() - getPaddingRight()) {
// 在本行还可以放置一个子 View
lineWidth += actualItemHorizontalSpacing + actualChildWidth
// 行高为一行中所有子 View 最高的那一个
lineHeight = max(lineHeight, actualChildHeight)
lineViews.add(child)
} else {
// 在本行不可以放置一个子 View,需要换行
if (lineCount == maxLines && mode == MODE_LIMIT_MAX_LINE) {
break
}
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
lineWidth = actualChildWidth
lineHeight = actualChildHeight
lineViews = ArrayList<View>()
lineViews.add(child)
}
measuredChildCount++
}
if (i == childCount - 1 || (measuredChildCount == maxCount && mode == MODE_LIMIT_MAX_COUNT)) {
if (lineCount == maxLines && mode == MODE_LIMIT_MAX_LINE) {
break
}
maxLineWidth = max(lineWidth, maxLineWidth)
lineCount++
totalHeight += lineHeight + if (lineCount == 1) 0 else itemVerticalSpacing
lineHeights.add(lineHeight)
allLineViews.add(lineViews)
if (measuredChildCount == maxCount && mode == MODE_LIMIT_MAX_COUNT) {
break
}
}
}
2.14 解决流式布局宽度方向上没有限制时仍会换行的问题
修改使用 FlowLayout
的布局的父节点为 HorizontalScrollView
:
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<com.example.lib.FlowLayout
android:id="@+id/flowlayout"
android:background="#44ff0000"
android:padding="8dp"
app:itemHorizontalSpacing="20px"
app:itemVerticalSpacing="20px"
app:flowlayout_line_vertical_gravity="top"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
android:text="Android"
style="@style/ItemStyle" />
<TextView
android:text="Kotlin"
style="@style/ItemStyle" />
<TextView
android:layout_margin="16dp"
android:text="Java"
style="@style/ItemStyle" />
<TextView
android:text="Custom View"
style="@style/ItemStyle" />
</com.example.lib.FlowLayout>
</HorizontalScrollView>
运行程序,查看效果:
FlowLayout
的父节点是 HorizonalScrollView
,那么在宽度方向上,父节点给 FlowLayout
的测量模式就是 UNSPECIED
,意思就是对流式布局在宽度方向上没有任何限制,想多大就多大,想多小就多小。
这样来看,流式布局不应该换行才对啊。
查看代码,判断换行的依据是 maxWidth
:
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
Log.d(TAG, "onMeasure: widthMeasureSpec=${MeasureSpec.toString(widthMeasureSpec)}, heightMeasureSpec=${MeasureSpec.toString(heightMeasureSpec)}")
// 获取流式布局允许的最大宽度
val maxWidth = widthSize
查看日志 widSize
目前为 1080:
D/FlowLayout: onMeasure: widthMeasureSpec=MeasureSpec: UNSPECIFIED 1080, heightMeasureSpec=MeasureSpec: AT_MOST 2025
同时看到日志里面,此时流式布局在宽度方向上的测量模式确实是 UNSPECIFIED
,需要说明的是 1080 只是一个参考值,这里我们希望的是宽度非常大,越大越好。
所以调整获取流式布局允许的最大宽度的代码为:
val maxWidth = if (widthMode != MeasureSpec.UNSPECIFIED) widthSize else Int.MAX_VALUE
重新运行程序,效果如下:
2.15 解决流式布局高度会大于父布局高度的问题
修改布局如下:
<?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-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="100px"
tools:context=".MainActivity">
<com.example.lib.FlowLayout
android:id="@+id/flowlayout"
android:background="#44ff0000"
android:padding="8dp"
app:itemHorizontalSpacing="20px"
app:itemVerticalSpacing="20px"
app:flowlayout_line_vertical_gravity="top"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
android:text="Android"
style="@style/ItemStyle" />
<TextView
android:text="Kotlin"
style="@style/ItemStyle" />
<TextView
android:layout_margin="16dp"
android:text="Java"
style="@style/ItemStyle" />
<TextView
android:text="Custom View"
style="@style/ItemStyle" />
</com.example.lib.FlowLayout>
</LinearLayout>
给 LinearLayout
指定了固定的高度为 100px
,运行程序查看流式布局的测量宽高:
D/FlowLayout: onMeasure: measuredWidth=934, measuredHeight=264
可以看到,流式布局的高度竟然比父布局的高度还大,这是有问题的。
看一下,计算流式布局测量高度的代码:
val measuredHeight = if (heightMode == MeasureSpec.EXACTLY) heightSize else totalHeight
当 heightMode
不为 EXACTLY
时,就直接把计算出的高度作为测量高度了。但是,计算出的高度会超出父布局给的最大允许高度,这点是欠考虑的。
修改代码如下:
val measuredHeight = when(heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> Math.min(heightSize, totalHeight)
else -> totalHeight
}
重新允许程序,查看日志:
D/FlowLayout: onMeasure: measuredWidth=934, measuredHeight=100
Okay.
2.16 流式布局的状态保存与恢复
这里以 maxCount
值的状态保存与恢复为例子来说明。
先看下问题:
在翻转屏幕之前,设置了个数限制为 3 个,在翻转屏幕之后,个数限制就失效了。
我们希望在屏幕翻转后,个数限制的值依然是 3。
这就需要用到状态恢复与保存了。
在 FlowLayout
中添加代码如下:
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
val ss = SavedState(superState)
ss.maxCount = maxCount
Log.d(TAG, "onSaveInstanceState: maxCount=$maxCount")
return ss
}
override fun onRestoreInstanceState(state: Parcelable?) {
val ss = state as SavedState
super.onRestoreInstanceState(ss.superState)
maxCount = ss.maxCount
Log.d(TAG, "onRestoreInstanceState: maxCount=$maxCount")
}
class SavedState : BaseSavedState {
var maxCount = Int.MAX_VALUE
constructor(superState: Parcelable?): super(superState)
constructor(parcel: Parcel) : super(parcel) {
maxCount = parcel.readInt()
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
super.writeToParcel(parcel, flags)
parcel.writeInt(maxCount)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<SavedState> {
override fun createFromParcel(parcel: Parcel): SavedState {
return SavedState(parcel)
}
override fun newArray(size: Int): Array<SavedState?> {
return arrayOfNulls(size)
}
}
}
再次测试程序,效果如下:
日志如下:
D/FlowLayout: onSaveInstanceState: maxCount=3
D/FlowLayout: onRestoreInstanceState: maxCount=3
可以看到,问题已经解决了。
2.17 优化 onMeasure 方法中分配对象的问题
每次换行都会分配一个集合:
var lineViews = ArrayList<View>()
As 已经警告了:
Avoid object allocations during draw/layout operations (preallocate and reuse instead)
目前来看,这样做是必须的,因为 lineViews
是用来存放每一行的子元素的集合。
如果要避免分配太多对象,复用对象和提前创建对象都是不可行的。
其实,我们即便不存储每一行的元素,在 onLayout
方法中也可以把它们布局好:因为我们已经知道了流式布局的测量宽高,每个子元素的测量宽高,它们的间距,margin 等等。
删除 allLineViews
这个成员变量,以及在 onMeasure
方法中的使用。
在 onLayout
方法修改代码如下:
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 子元素的左上角横坐标
var childLeft = getPaddingLeft()
// 子元素的左上角纵坐标
var childTop = getPaddingTop()
val childCount = getChildCount()
// 行数索引
var lineIndex = 0
var layoutChildCount = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != View.GONE) {
val lp = child.layoutParams as MarginLayoutParams
childLeft += lp.leftMargin
val childMeasuredWidth = child.getMeasuredWidth()
val childMeasuredHeight = child.getMeasuredHeight()
if (childLeft + childMeasuredWidth > getMeasuredWidth()) {
// 需要换行了
// 更新 childTop,作为下一行子元素的左上角纵坐标
childTop += lineHeights[lineIndex] + itemVerticalSpacing
// 更新 childLeft,作为下一行子元素的左上角横坐标
childLeft = getPaddingLeft()
lineIndex++
}
if (lineIndex + 1 > maxLines && mode == MODE_LIMIT_MAX_LINE) {
child.layout(0,0,0,0)
} else if (layoutChildCount >= maxCount && mode == MODE_LIMIT_MAX_COUNT) {
child.layout(0,0,0,0)
} else {
val offsetTop = getOffsetTop(lineHeights[lineIndex], child)
// 确定子元素的位置
child.layout(
childLeft, childTop + lp.topMargin + offsetTop, childLeft + childMeasuredWidth,
childTop + lp.topMargin + offsetTop + childMeasuredHeight
)
layoutChildCount++
// 更新 childLeft,作为该行下一个子元素的左上角横坐标
childLeft += childMeasuredWidth + lp.rightMargin + itemHorizontalSpacing
}
}
}
}
3. 最后
本文一步一步地使用自定义 ViewGroup
实现了流式布局,比较全面地考虑了自定义 ViewGroup
的注意事项。希望能够帮助到大家。
进一步地学习,可以查看参考里的文章或者视频。
代码在这里。
4. 参考
- Android多种方式实现流式布局-鸿洋
鸿洋在慕课网上的视频,可以多看几遍,讲解地非常详细,并且使用适配器来给流式布局填充数据。 - QMUIFloatLayout
QMUI 对流式布局的实现,比鸿洋视频里的实现属性更加丰富。 - flexbox-layout
谷歌对流式布局的实现,包括自定义ViewGroup
的实现方式和使用RecyclerView
自定义LayoutManager 的实现方式。 - MDC的FlowLayout
在谷歌的 material-components-android 仓库里也实现了一个流式布局,不过没有开放给开发者使用,而是作为ChipGroup
的基类。 - 每日一问 自定义 ViewGroup 的时候,关于 LayoutParams 有哪些注意事项-玩Android