Cloumn 和 Row

如果是普通的不是特别长的列表,可以直接使用 Column 和 Row 组件,默认 Column 和 Row 组件是不支持滚动的,如果需要支持滚动可以在 Column 和 Row 组件上使用 Modifier.verticalScroll() 和Modifier.horizontalScroll() 修饰符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Composable
fun ColumnList(list: List<String>) {
Box {
Column(Modifier.verticalScroll(rememberScrollState())) {
list.forEach {
Text(it)
Divider()
}
}
}
}

@Composable
fun RowList(list: List<String>) {
Box {
Row(Modifier.horizontalScroll(rememberScrollState())) {
list.forEach {
Text(it)
Divider(Modifier.width(1.dp).fillMaxHeight(), thickness = 1.dp)
}
}
}
}

Column 的 verticalArrangement 参数可以指定item在主轴不同的排列方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@Composable
fun ColumnExample() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
// verticalArrangement = Arrangement.Top // 默认是Top
) {
Text("默认效果", Modifier.background(Color.Green))
Text("默认效果", Modifier.background(Color.Green))
Text("默认效果", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample2() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.Center
) {
Text("Arrangement.Center", Modifier.background(Color.Green))
Text("Arrangement.Center", Modifier.background(Color.Green))
Text("Arrangement.Center", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample201() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.SpaceAround
) {
Text("Arrangement.SpaceAround", Modifier.background(Color.Green))
Text("Arrangement.SpaceAround", Modifier.background(Color.Green))
Text("Arrangement.SpaceAround", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample202() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.SpaceBetween
) {
Text("Arrangement.SpaceBetween", Modifier.background(Color.Green))
Text("Arrangement.SpaceBetween", Modifier.background(Color.Green))
Text("Arrangement.SpaceBetween", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample203() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.SpaceEvenly
) {
Text("Arrangement.SpaceEvenly", Modifier.background(Color.Green))
Text("Arrangement.SpaceEvenly", Modifier.background(Color.Green))
Text("Arrangement.SpaceEvenly", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample204() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Arrangement.spacedBy(10.dp)", Modifier.background(Color.Green))
Text("Arrangement.spacedBy(10.dp)", Modifier.background(Color.Green))
Text("Arrangement.spacedBy(10.dp)", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample205() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
verticalArrangement = Arrangement.Bottom
) {
Text("Arrangement.Bottom", Modifier.background(Color.Green))
Text("Arrangement.Bottom", Modifier.background(Color.Green))
Text("Arrangement.Bottom", Modifier.background(Color.Green))
}
}

在这里插入图片描述

下面的动图总结了不同垂直排列方式的效果:

在这里插入图片描述

同样,通过 horizontalAlignment 参数可以指定item在交叉轴(横轴)上的排列方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
fun ColumnExample3() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("ffff", Modifier.background(Color.Green))
Text("Alignment.CenterHorizontally", Modifier.background(Color.Green))
}
}

@Composable
fun ColumnExample4() {
Column(
modifier = Modifier.border(1.dp, Color.Blue),
horizontalAlignment = Alignment.End
) {
Text("kkk", Modifier.background(Color.Green))
Text("Alignment.End", Modifier.background(Color.Green))
}
}

在这里插入图片描述

Row 组件也可以通过 horizontalArrangement 参数指定item在水平方向的排列方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
@Composable
fun RowExample() {
val modifier = Modifier
.padding(5.dp)
.clip(RoundedCornerShape(35))
.background(Color.Green)
val modifier2 = modifier
.width(50.dp)
.padding(5.dp)
Column {
Divider()
Text("Equal Weight")
Row {
Box(modifier
.weight(1f)
.padding(5.dp)) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier
.weight(1f)
.padding(5.dp)) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier
.weight(1f)
.padding(5.dp)) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.Start")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.Center")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.End")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.SpaceBetween")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.SpaceAround")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.SpaceEvenly")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
Text("Arrangement.spacedBy(20.dp)")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
Box(modifier2) {
Text(text = "A", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "B", Modifier.align(Alignment.Center))
}
Box(modifier2) {
Text(text = "C", Modifier.align(Alignment.Center))
}
}
Divider()
}
}

在这里插入图片描述

下面的动图总结了不同水平排列方式的效果:

在这里插入图片描述

LazyColumn 和 LazyRow

这两个是支持惰性加载的列表组件,只有屏幕显示的部分才会真正被加载,对标传统View中的RecyclerView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun LazyColumnList() {
LazyColumn(
Modifier
.fillMaxSize()
.background(Color.Gray),
contentPadding = PaddingValues(35.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(50) { index ->
CardContent("我是序号第 $index 的卡片")
}
}
}

在这里插入图片描述

在LazyList组件中是通过使用itemsitem这两个DSL来添加item组件的,通过item DSL可以很方便的指定 header 和 footer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
fun LazyColumnList2() {
val list = listOf("A", "B", "C")
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(35.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
Text("Header 第一项")
}
items(5) { index ->
Text("第 ${index + 2} 项")
}
itemsIndexed(list) { index, s ->
Text("第 ${index + 7}$s")
}
item {
Text("Footer 最后一项")
}
}
}

在这里插入图片描述

粘性 header

粘性header通过 stickyHeader 这个DSL来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyColumnList5() {
val sections = listOf("A", "B", "C", "D", "E", "F", "G")
LazyColumn(
/*reverseLayout = true, */
contentPadding = PaddingValues(15.dp)
) {
sections.forEach { section ->
stickyHeader {
Text(
"Section $section 粘性Header",
Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp)
)
}
items(10) {
CardContent("Item $it from the section $section")
}
}
}
}

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyRowExample() {
LazyRow(
Modifier
.fillMaxWidth()
.background(Color.Gray),
contentPadding = PaddingValues(15.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
stickyHeader {
Box(Modifier
.height(150.dp)
.background(Color.Red)
.padding(8.dp)) {
Text(text = "粘性Header", color = Color.White, fontSize = 20.sp)
}
}
items(50) { index ->
CardContent2(index)
}
}
}

在这里插入图片描述

如需实现具有多个标题的列表(例如“联系人列表”),可以执行以下操作,先通过groupBy进行分组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// TODO: This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
LazyColumn {
grouped.forEach { (initial, contactsForInitial) ->
stickyHeader {
CharacterHeader(initial)
}

items(contactsForInitial) { contact ->
ContactListItem(contact)
}
}
}
}

自定义排列方式

通常,延迟列表包含许多项,并且这些项所占空间大于滚动容器的大小。不过,如果列表中填充的项很少,那么在设计中,您可以对这些项在视口中的位置做出更具体的要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Composable
fun TopWithFooterExample() {
val list = listOf("A", "B", "C", "I am the Footer")
LazyColumn(
Modifier.fillMaxHeight(),
contentPadding = PaddingValues(15.dp),
verticalArrangement = TopWithFooter // 使用自定义垂直 Arrangement
) {
itemsIndexed(list) { index, item ->
Box(modifier = Modifier
.background(Color.Blue)
.padding(10.dp)) {
Text("第 $index$item", fontSize = 20.sp, color = Color.White)
}
}
}
}
object TopWithFooter : Arrangement.Vertical {
// 重写 arrange 方法
override fun Density.arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) {
var y = 0
// 首先,它会将列表项逐个放在相应位置。
sizes.forEachIndexed { index, size ->
outPositions[index] = y
y += size
}
// 其次,如果所用总高度低于视口高度,则会将页脚放置在底部:
if (y < totalSize) {
val lastIndex = outPositions.lastIndex
outPositions[lastIndex] = totalSize - sizes.last()
}
}
}

在这里插入图片描述

contentPadding 内容边距

使用LazyList时,如果使用普通的 Modifier.padding() 为其添加内边距,效果不会像我们期望的那样,而是会在滑动到边界位置时内容出现padding大小的截断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun AlignYourBodyRow(
modifier: Modifier = Modifier
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
// 使用修饰符修改左右的padding会导致滑动的时候出现截断
modifier = modifier.padding(horizontal = 16.dp),
) {
items(alignYourBodyData) { item ->
AlignYourBodyElement(item.drawable, item.text)
}
}
}

在这里插入图片描述

此时正确的做法应当使用 LazyList 的 contentPadding 属性来设置内边距:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun AlignYourBodyRow(
modifier: Modifier = Modifier
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp), // 为列表设置正确的内边距
modifier = modifier
) {
items(alignYourBodyData) { item ->
AlignYourBodyElement(item.drawable, item.text)
}
}
}

在这里插入图片描述
可以仔细观察对比一下二者的不同。

显示返回顶部按钮

通过 scrollState.firstVisibleItemIndex > 0 判断是否向下滚动了,然后通过 scrollState.scrollToItem(0) 返回顶部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Composable
fun LazyColumnList3() {
val scrollState = rememberLazyListState(15) // 从index=15的开始显示
Box {
LazyColumn(
Modifier
.fillMaxHeight()
.background(Color.Gray),
verticalArrangement = Arrangement.spacedBy(10.dp),
state = scrollState
) {
items(50) { index ->
CardContent("我是序号第 $index 的卡片")
}
}
val showScrollToTopButton by remember {
derivedStateOf { scrollState.firstVisibleItemIndex > 10 } // 这里根据实际业务一页大概展示多少条,可以判断超过一页后再显示
}

if (showScrollToTopButton) {
val scope = rememberCoroutineScope()
ExtendedFloatingActionButton(
text = {
Column {
Icon(Icons.Default.KeyboardArrowUp, null)
Text("Top")
}
},
onClick = {
scope.launch { scrollState.scrollToItem(0) }
},
shape = CircleShape, // RoundedCornerShape(15),
modifier = Modifier.size(70.dp).align(Alignment.BottomEnd),
backgroundColor = Color.Green
)
}
}
}

在这里插入图片描述
可以使用 AnimatedVisibility 为按钮添加动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun LazyColumnList3() {
val scrollState = rememberLazyListState(15) // 从index=15的开始显示
Box {
LazyColumn(
Modifier
.fillMaxHeight()
.background(Color.Gray),
verticalArrangement = Arrangement.spacedBy(10.dp),
state = scrollState
) {
items(50) { index ->
CardContent("我是序号第 $index 的卡片")
}
}
val showScrollToTopButton by remember {
derivedStateOf { scrollState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(
visible = showScrollToTopButton,
modifier = Modifier.align(Alignment.BottomEnd),
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
) {
val scope = rememberCoroutineScope()
ExtendedFloatingActionButton(
text = {
Column {
Icon(Icons.Default.KeyboardArrowUp, null)
Text("Top")
}
},
onClick = {
scope.launch { scrollState.scrollToItem(0) }
},
shape = CircleShape, // RoundedCornerShape(15),
modifier = Modifier.size(70.dp),
backgroundColor = Color.Green
)
}
}
}

使用 scrollToItem 或 animateScrollToItem 时发现,有时不一定能够滑动到顶部,可以通过指定滑动的距离解决,如 animateScrollToItem(0, -10000)或者 scrollBy(-10000f)

LazyListState可以帮助我们分析滑动事件, 一个常见的例子是,系统会在用户滚动经过某个点后发送分析事件。 为了高效地解决此问题,我们可以使用 snapshotFlow()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val listState = rememberLazyListState()

LazyColumn(state = listState) {
// ...
}

LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}

LazyListState 还可以通过 layoutInfo 属性提供有关当前显示的所有列表项以及这些项在屏幕上的边界的信息。

contentType 多类型列表

从 Compose 1.2 开始,为了最大限度地提高延迟布局的性能,建议将 contentType 添加到列表或网格中。 当列表或网格由多种不同类型的项组成时,这样可为布局的每一项指定内容类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Composable
fun MultiTypeList() {
val list by remember { mutableStateOf(prepareNewsList()) }
LazyColumn(
contentPadding = PaddingValues(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(list, contentType = { it.type }) { item ->
if (item.type == 1) {
Card(elevation = 8.dp, modifier = Modifier.fillMaxWidth()) {
Row(
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.ic_sky),
contentDescription = null,
modifier = Modifier.size(100.dp),
contentScale = ContentScale.Crop
)
Text(item.name.repeat(2), fontSize = 20.sp)
}
}
} else {
CardContent(text = item.name.repeat(3))
}
}
}
}
fun prepareNewsList() : List<NewsItem> {
return List(50) {
NewsItem("NewsItem $it ", if(it % 2 == 0) 1 else 2)
}
}
data class NewsItem(val name : String, val type : Int)

在这里插入图片描述

延迟列表的使用注意事项

为列表项指定 key

注意:实际业务中使用LazyColumn必须为每个item提供一个稳定的 唯一key (默认是将position作为索引)

键的类型必须受 Bundle 支持,这是 Android 的机制,旨在当重新创建 activity 时保持相应状态。

Bundle 支持基本类型、枚举或 Parcelable 等类型。

Bundle 必须支持该键,以便在重新创建 activity 时,甚至在滚动离开此项然后滚动回来时,此项可组合项中的 rememberSaveable 仍可以恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
key = { message ->
// Return a stable + unique key for the item
message.id
}
) { message ->
MessageRow(message)
}
}
}
避免使用大小为 0 像素的列表项

如果使用分页,尤其是在加载图片列表时,必须为每个列表项预先提供一个固定高度大小的占位符,因为LazyColumn会在首次测量时认为高度没有限制,会一直进行组合,直到计算的测量高度填满可用的视图窗口,然后停止组合。

如果没有提供占位符(高度是0px)或者提供了高度很小的占位符,则意味着在首次测量时,LazyColumn会组合它的所有项,因为高度为0的项可以很容易容纳到当前窗口中。而在几毫秒之后,图片又加载出来了,LazyColumn开始重组显示图片,但此时只有一部分能被容纳到窗口中,因此LazyColumn会舍弃最开始组合的不必要的其他所有项。为了避免这种性能损耗,应该为每个item提供一个默认的高度大小的占位符,最好的做法是保证加载前后的item高度大小不变

避免嵌套可以同方向滚动的组件

这仅适用于将没有预定义尺寸的可滚动子级嵌套在可向同一方向滚动的另一个父级中的情况。

例如,尝试在可垂直滚动的 Column 父级中嵌套没有固定高度的子级 LazyColumn

1
2
3
4
5
6
7
8
// Throws IllegalStateException
Column(
modifier = Modifier.verticalScroll(state)
) {
LazyColumn {
// ...
}
}

Compose不支持嵌套同方向的滚动列表,比如LazyColumn里嵌套LazyColumn,这种小心思就别想了,Google不让你这么玩~

但是可以在Row中嵌套LazyColumn,如果一定要在Column中嵌套LazyColumn,必须为LazyColumn指定固定的大小:

1
2
3
4
5
6
7
8
9
Column(
modifier = Modifier.verticalScroll(scrollState)
) {
LazyColumn(
modifier = Modifier.height(200.dp)
) {
// ...
}
}

总之,官方是不推荐嵌套同方向滚动的组件的,但是如果你硬要这么做,就必须为内层的滚动组件设置一个固定的大小。即不支持两个无界大小的滚动组件嵌套。

尽量避免将多个元素放入一个 item{...} 项中
1
2
3
4
5
6
7
8
9
10
11
LazyVerticalGrid(
// ...
) {
item { Item(0) }
item {
Item(1)
Item(2)
}
item { Item(3) }
// ...
}

在此示例中,第二个项 lambda 在一个代码块中发出 2 个项,当多个元素作为一个项的一部分发出时,系统会将其作为一个实体进行处理,这意味着这些元素无法再单独组合

也就是说当其中一个元素需要重组时,系统会将lambda项中的所有元素都进行重组。如果过度使用,则可能会降低性能

将所有元素放入一个项属于极端情况,完全违背了使用延迟布局的目的。除了潜在的性能问题外,这种情况还会干扰 scrollToItem() 和 animateScrollToItem()。如上面代码如果调用scrollToItem(index = 2) 则实际会滑动到Item(3)的位置,因为Item(1)Item(2)被视为一个项。

不过,也有一些将多个元素放入一个项的有效用例,例如在一个列表内添加多条分隔线。你可能不希望分隔线更改滚动索引,因为分割线不应被视为独立元素。此外,由于分隔线占空间很小,因此性能不会受到影响。
分隔线可能需要在其之前的那个项可见时显示,这样分割线就可纳入前一个项中:

1
2
3
4
5
6
7
8
9
10
11
LazyVerticalGrid(
// ...
) {
item { Item(0) }
item {
Item(1)
Divider()
}
item { Item(2) }
// ...
}

此外,如果要测试延时列表的性能应当在release版本的模式下测试,不应该测试debug版本(并且是在真机而不是模拟器上测试,多次测试结果)

LazyVerticalGrid 和 LazyHorizontalGrid

可以滑动的表格组件,同时支持Column和Row的verticalArrangement 和 horizontalArrangement 属性效果,可以通过 columns 和 rows 属性指定列数或行数,例如 GridCells.Fixed(3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun LazyVerticalGridExample() {
val itemsIndexedList = ('A'..'Z').toList()
LazyVerticalGrid(
columns = GridCells.Fixed(3),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(5.dp)
) {
itemsIndexed(itemsIndexedList) { index, item ->
GridItem("Item at $index is $item")
}
}
}

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun LazyHorizontalGridExample() {
val itemsIndexedList = ('A'..'Z').toList()
LazyHorizontalGrid(
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(5.dp)
) {
itemsIndexed(itemsIndexedList) { index, item ->
GridItem("Item at $index is $item")
}
}
}

在这里插入图片描述

设置单元格的大小占比权重

可以通过 span 参数设置每个单元格的大小占比权重,span 默认值是 1, 将 span 设置为不同的值可以实现简单的瀑布流效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Composable
fun LazyVerticalGridExample2_1() {
val list = ('A'..'Z').toList()
val spanList = listOf(1, 2, 1, 3, 1, 1, 3, 4, 2, 2, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 3, 1, 1, 1)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(5.dp)
) {
itemsIndexed(list, span={index, item -> GridItemSpan( spanList[index])}) { index, item ->
Text(
"Item $item",
modifier = Modifier
.clip(RoundedCornerShape(5.dp))
.background(colors[index % colors.size])
.height(80.dp)
.wrapContentSize()
.padding(5.dp),
color = Color.White,
fontSize = 16.sp
)
}
}
}

在这里插入图片描述

如果设置 span = GridItemSpan(maxLineSpan) 则会独占一整行,可以用来实现常见的分组标题的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Composable
fun LazyVerticalGridExample2() {
val sections = (0 until 25).toList().chunked(5)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(5.dp)
) {
sections.forEachIndexed { index, items ->
// maxLineSpan 是 LazyGridItemSpanScope 的值之一
// 设置 span = GridItemSpan(maxLineSpan) 即独占一整行
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
"This is section $index",
Modifier
.clip(RoundedCornerShape(5.dp))
.background(MaterialTheme.colorScheme.primary)
.height(50.dp)
.wrapContentSize(),
color = Color.White,
fontSize = 16.sp
)
}
items(
items,
span = { GridItemSpan( if(it % 2 == 0) 1 else 2) }
) {
GridItem("Item $it")
}
}
}
}

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
fun LazyHorizontalGridExample2() {
val sections = (0 until 25).toList().chunked(5)
LazyHorizontalGrid(
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(5.dp)
) {
sections.forEachIndexed { index, items ->
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
"section $index",
Modifier
.clip(RoundedCornerShape(5.dp))
.background(MaterialTheme.colorScheme.primary)
.wrapContentSize()
.padding(8.dp),
color = Color.White,
fontSize = 16.sp
)
}
items(
items,
// not required as it is the default
span = { GridItemSpan(1) }
) {
GridItem("Item $it")
}
}
}
}

在这里插入图片描述

GridCells.Adaptive() 自适应屏幕

例如,以下代码设置每个单元格至少100dp, 这在旋转屏幕场景下会自适应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun LazyVerticalGridExample3() {
val itemsIndexedList = ('A'..'Z').toList()
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(5.dp)
) {
itemsIndexed(itemsIndexedList) { index, item ->
GridItem("Item at $index is $item")
}
}
}

在这里插入图片描述

自定义columns的每一列的宽度占比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
fun LazyVerticalGridExample4() {
val itemsIndexedList = ('A'..'Z').toList()
LazyVerticalGrid(
columns = object: GridCells {
override fun Density.calculateCrossAxisCellSizes(
availableSize: Int,
spacing: Int
): List<Int> {
// 将第一列的宽度调整为可用空间的2/3,第二列的宽度调整为可用空间的1/3
val firstColumn = (availableSize - spacing) * 2 / 3
val secondColumn = availableSize - spacing - firstColumn
return listOf(firstColumn, secondColumn)
}
},
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(5.dp)
) {
itemsIndexed(itemsIndexedList) { index, item ->
GridItem("Item at $index is $item")
}
}
}

在这里插入图片描述

LazyHorizontalStaggeredGrid 和 LazyVerticalStaggeredGrid 瀑布流

LazyHorizontalStaggeredGrid 和 LazyVerticalStaggeredGrid 主要为了支持瀑布流效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyVerticalStaggeredGridExample() {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(3),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
){
itemsIndexed(list){ index, item ->
Card(
shape = RoundedCornerShape(4.dp),
backgroundColor = colors[index % colors.size],
) {
Text(
text = "$index $item",
color = Color.White,
modifier = Modifier.padding(10.dp)
)
}
}
}
}

在这里插入图片描述

LazyVerticalStaggeredGrid 的单元格同样支持Adaptive自适应属性, 例如将上面的代码中 columns 属性改成 StaggeredGridCells.Adaptive(80.dp),则效果如下:

在这里插入图片描述

LazyHorizontalStaggeredGrid使用是类似的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyHorizontalStaggeredGridExample() {
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Adaptive(80.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp)
){
itemsIndexed(list){ index, item ->
Card(
shape = RoundedCornerShape(4.dp),
backgroundColor = colors[index % colors.size],
) {
Box {
Text(
text = "$index $item",
color = Color.White,
modifier = Modifier.padding(10.dp).align(Alignment.Center)
)
}
}
}
}
}

在这里插入图片描述
LazyHorizontalStaggeredGrid 和 LazyVerticalStaggeredGrid 中的 items DSL目前不支持设置span参数,这意味着不能在固定列数/行数的情况下指定某个item占据多个行或多个列。也就是目前还无法实现如下效果:

在这里插入图片描述
如果想要实现此效果,只能使用前面的 LazyVerticalGrid 然后结合 RowColumn甚至是ConstraintLayout来设置每个出现横纵交错的内容块里面的布局,比较麻烦,自适应性肯定不好,还是希望官方能给出支持。

下拉刷新 Modifier.pullRefresh

Compose 1.3.0开始,SDK自带了对下拉刷新的支持(在此之前的版本是通过Accompanist库中提供的SwipeRefresh组件),通过在Box组件上应用修饰符Modifier.pullRefresh即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullToRefreshModifier() {
val refreshScope = rememberCoroutineScope()
var refreshing by remember { mutableStateOf(false) }
var itemCount by remember { mutableStateOf(15) }

fun refresh() = refreshScope.launch {
refreshing = true
delay(1000)
itemCount += 5
refreshing = false
}

val state = rememberPullRefreshState(refreshing, ::refresh)

Box(Modifier.pullRefresh(state)) {
LazyColumn(Modifier.fillMaxSize()) {
items(itemCount) {
ListItem { Text(text = "Item ${itemCount - it}") }
}
}

PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter))
}
}

在这里插入图片描述
其中rememberPullRefreshState需要传递一个refreshing标记状态以及一个refresh方法,refresh是请求刷新数据的业务逻辑,实际项目中,应该放在ViewModel中:

1
2
3
4
5
6
7
8
9
10
11
val viewModel: MyViewModel = viewModel()
val refreshing by viewModel.isRefreshing

val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.refresh() })

Box(Modifier.pullRefresh(pullRefreshState)) {
LazyColumn(Modifier.fillMaxSize()) {
...
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}

此外,Modifier.pullRefresh还有一个重载函数,可以提供 onPull 和 onRelease 两个动作的回调,方便我们在不同的触发时机进行一些业务操作,如显示自定义的Indicator等。

1
2
3
4
5
6
@ExperimentalMaterialApi
fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Unit,
enabled: Boolean = true
): Modifier

以下代码在下拉时根据下拉距离改变顶部的水平进度条的进度,在释放时进行刷新请求数据并执行动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullToRefreshModifier2() {
val refreshScope = rememberCoroutineScope()
val threshold = 160.dp.toPx()

var refreshing by remember { mutableStateOf(false) }
var itemCount by remember { mutableStateOf(15) }
var currentDistance by remember { mutableStateOf(0f) }

val progress = currentDistance / threshold

fun refresh() = refreshScope.launch {
refreshing = true
delay(1000)
itemCount += 5
refreshing = false
}

fun onPull(pullDelta: Float): Float = when {
refreshing -> 0f
else -> {
val newOffset = (currentDistance + pullDelta).coerceAtLeast(0f)
val dragConsumed = newOffset - currentDistance
currentDistance = newOffset
dragConsumed
}
}

suspend fun onRelease() {
if (refreshing) return // Already refreshing - don't call refresh again.
if (currentDistance > threshold) refresh()

animate(
initialValue = currentDistance,
targetValue = 0f,
animationSpec = tween(1000, easing = LinearOutSlowInEasing)
) { value, _ ->
currentDistance = value
}
}

Box(Modifier.pullRefresh(::onPull, { onRelease() })) {
LazyColumn {
items(itemCount) {
ListItem { Text(text = "Item ${itemCount - it}") }
}
}

// Custom progress indicator
AnimatedVisibility(visible = (refreshing || progress > 0)) {
if (refreshing) {
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(progress, Modifier.fillMaxWidth())
}
}
}
}

在这里插入图片描述
将上面代码中的LinearProgressIndicator换成CircularProgressIndicator,还可以根据下拉的距离改变圆形Indicator到顶部的距离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullToRefreshModifier2() {
.......
Box(Modifier.pullRefresh(::onPull, { onRelease() })) {
LazyColumn {
items(itemCount) {
ListItem { Text(text = "Item ${itemCount - it}") }
}
}
if (currentDistance > 0) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.TopCenter)
.size(50.dp)
.offset(y = if (refreshing) 0.dp else currentDistance.toDp()),
color = Color.Red,
strokeWidth = 3.dp
)
}
}
}

在这里插入图片描述
官方还提供了一个很方便的修饰符 Modifier.pullRefreshIndicatorTransform 来根据PullRefreshState自动改变Indicator的平移位置和缩放大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PullToRefreshModifier4() {
val refreshScope = rememberCoroutineScope()
var refreshing by remember { mutableStateOf(false) }
var itemCount by remember { mutableStateOf(15) }

fun refresh() = refreshScope.launch {
refreshing = true
delay(1500)
itemCount += 5
refreshing = false
}

val state = rememberPullRefreshState(refreshing, ::refresh)
val rotation = animateFloatAsState(state.progress * 120)

Box(Modifier.fillMaxSize().pullRefresh(state)) {
LazyColumn {
items(itemCount) {
ListItem { Text(text = "Item ${itemCount - it}") }
}
}

Surface(
modifier = Modifier
.size(50.dp)
.align(Alignment.TopCenter)
.pullRefreshIndicatorTransform(state, scale = true)
.rotate(rotation.value),
shape = RoundedCornerShape(10.dp),
color = Color.DarkGray,
elevation = if (state.progress > 0 || refreshing) 20.dp else 0.dp,
) {
Box {
if (refreshing) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(30.dp),
color = Color.White,
strokeWidth = 3.dp
)
}
}
}
}
}

在这里插入图片描述

Modifier.verticalScroll() 和 Modifier.horizontalScroll() 开启滚动特性

本文开头提到可以在 Column 和 Row 组件上使用 Modifier.verticalScroll() 和Modifier.horizontalScroll() 修饰符使之成为可滚动列表,后来我发现其实这两个修饰符不止可以在这两个组件上应用,它们几乎可以在任意组件上应用,甚至在 Box 组件上也可以应用这样的修饰符来使内容成为可滚动列表。例如下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Composable
fun BoxScrollExample() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
BoxNestedScrollExample()
}
}

@Composable
private fun BoxNestedScrollExample() {
Box(
modifier = Modifier
.width(200.dp)
.background(Color.Magenta)
.verticalScroll(rememberScrollState())
.padding(32.dp),
contentAlignment = Alignment.TopCenter
) {
Column {
repeat(12) {
Box(
modifier = Modifier
.height(128.dp)
.verticalScroll(rememberScrollState())
) {
Text("$it Scroll here", fontSize = 16.sp,
modifier = Modifier
.border(12.dp, Color.Cyan)
.background(brush = Brush.verticalGradient(gradientColorsReversed))
.padding(24.dp)
.height(180.dp)
)
}
}
}
}
}

在这里插入图片描述

这里在在 Box 组件上应用了Modifier.verticalScroll() 修饰符使之成为一个可纵向滚动的列表,当然要使组件纵向排列还得借助 Column 组件。但是这里值得注意的一点是,Modifier.verticalScroll() 这个修饰符居然连嵌套滑动也为我们处理好了,简直太贴心了,不得不感叹这个修饰符真是太强大了!有了它之后,夫复何求?

Modifier.nestedScroll() 开启嵌套滚动

Modifier.nestedScroll() 是一个高级的专门用于处理嵌套滚动的修饰符,它可以让开发者参与嵌套滑动的各个部分当中详细定制滑动的偏移量的消费。

1
2
3
4
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier

使用 nestedScroll 参数列表中有一个必选参数 connection 和一个可选参数 dispatcher

  • connection:嵌套滑动手势处理的核心逻辑,内部回调可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
  • dispatcher:调度器,内部包含用于父布局的 NestedScrollConnection , 可以调用 dispatch* 系列方法来通知父布局发生滑动。

有两种方式来参与嵌套的滚动:作为一个滚动子项,通过 NestedScrollDispatcher 将滚动事件分派到嵌套的滚动链;并通过提供 NestedScrollConnection 作为嵌套滚动链的成员,当下面的另一个嵌套滚动子项分派滚动事件时,将调用该连接。

作为链中的 NestedScrollConnection 参与是强制的,但是另一方面,通过NestedScrollDispatcher进行调度是可选的,因为在某些情况下,元素只是希望参与到嵌套的滚动过程中,但元素本身是不可滚动的。

NestedScrollConnection 提供了 4 个回调方法分别对应嵌套滚动系统的 4 个阶段:

  1. onPreScroll:当子项即将执行滚动操作时,将触发此回调,并给父级一个机会在此之前消耗部分子级的增量。此过程应发生在每次可滚动组件接收到增量并通过 NestedScrollDispatcher 分派的时候。子级的分派应考虑到其所有祖先消耗了多少,并相应地调整消耗。

  2. onPostScroll:当子项已经消耗了增量(考虑了父级在 1 中预消耗的部分)并希望将未消耗的增量通知祖先时,将触发此回调。此过程应发生在每当可滚动组件接收到增量并通过 NestedScrollDispatcher 分派的时候。任何接收 NestedScrollConnection.onPostScroll 的父级都应消耗不超过 left 的数量,并返回已消耗的数量。

  3. onPreFling: 在滚动子项停止拖动并带着某种速度飞动之前发生的阶段。此回调允许祖先消耗速度的一部分。此过程应在 fling 本身发生之前发生。类似于 pre-scroll,父级可以消耗速度的一部分,而下面的节点(包括分派子级)应相应地调整其逻辑以仅适应剩余速度。

  4. onPostFling: 这个阶段是指当滚动子项停止 fling 并希望通知祖先时,并提供未消耗的速度。 此过程应在滚动子项上的 fling 本身发生之后发生。分派节点的祖先将有机会使用提供的 velocityLeft 自行飞行。父级必须调用 notifySelfFinish 回调,以便继续传播剩余速度给上方的祖先。

通过下面的例子我们可以观察在滑动过程中各个参数的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Composable
private fun TutorialContent() {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
NestedScrollExample()
}
}

@Composable
private fun ColumnScope.NestedScrollExample() {
var text by remember { mutableStateOf("") }
// 连接到嵌套滚动系统的接口
//
// 将此 connection 传递给 nestedScroll 修饰符,以参与嵌套滚动过程,
// 并在滚动子元素通过 NestedScrollDispatcher 分派嵌套滚动事件时接收它们。
// 其中,滚动子元素是实际接收滚动事件并通过 NestedScrollDispatcher 分派它们的元素。
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
text = "onPreScroll()\navailable: $available\nsource: $source\n\n"
return super.onPreScroll(available, source)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
text += "onPostScroll()\nconsumed: $consumed\navailable: $available\nsource: $source\n\n"
return super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
text += "onPreFling()\navailable: $available\n\n"
return super.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
text += "onPostFling()\nconsumed: $consumed\navailable: $available\n\n"
return super.onPostFling(consumed, available)
}
}
}

Box(Modifier.weight(1f).nestedScroll(nestedScrollConnection)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(100) {
Text(
text = "I'm item $it",
modifier = Modifier
.shadow(1.dp, RoundedCornerShape(5.dp))
.fillMaxWidth()
.background(Color.Magenta)
.padding(12.dp),
fontSize = 16.sp,
color = Color.White
)
}
}
}

Spacer(Modifier.height(10.dp))
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.height(250.dp)
.padding(10.dp)
.background(BlueGrey400),
fontSize = 16.sp,
color = Color.White
)
}

在这里插入图片描述

这里可以观察到一件事就是,当手指向下滑动时,available 的 y 值是正值,当手指向上滑动时,available 的 y 值是负值,因此可以通过 available.y > 0 来判断手指是否是下滑操作。

下面代码是官方API参考文档中提供的使用 NestedScrollConnection 实现的一个滑动列表顶部折叠工具栏的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 这里我们希望充当这个LazyColumn的父元素,并参与其嵌套滚动。为LazyColumn制作一个折叠工具栏
@Composable
fun NestedScrollWithCollapseToolBar() {
val toolbarHeight = 50.dp
val toolbarHeightPx = toolbarHeight.toPx()
var toolbarOffsetHeightPx by remember { mutableStateOf(0f) } // 折叠工具栏的偏移量
// 创建到嵌套滚动系统的连接,并倾听子LazyColumn内部发生的滚动
val nestedScrollConnection = remember {
object : NestedScrollConnection {
// 如果需要的话,尝试在LazyColumn之前消费以折叠工具栏,因此需要预滚动
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
println("available.y = ${available.y}")
val delta = available.y
val newOffset = toolbarOffsetHeightPx + delta
toolbarOffsetHeightPx = newOffset.coerceIn(-toolbarHeightPx, 0f) // 限制 toolbarOffsetHeightPx 不会越界
// 这里有一个问题: 让我们假设我们在任何情况下消耗了0,
// 因为我们希望 LazyColumn 能够滚动以获得良好的用户体验
return Offset.Zero
}
}
}
Box(
Modifier.fillMaxSize().nestedScroll(nestedScrollConnection) // 作为父元素附加到嵌套的滚动系统
) {
// 我们的列表内置嵌套滚动支持,将通知我们它的滚动
LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) { // 顶部预留出显示 TopAppBar 的空间
items(100) { index ->
Text("I'm item $index", modifier = Modifier.fillMaxWidth().padding(16.dp))
}
}
TopAppBar(
modifier = Modifier
.height(toolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.roundToInt()) },
title = { Text("toolbar offset is $toolbarOffsetHeightPx") }
)
}
}

在这里插入图片描述

在上面代码中,注意应用nestedScroll修饰符的Box组件是作为父元素,而它包裹的 LazyColumn 是作为子元素,在滚动过程中 LazyColumn 会通过 nestedScroll提供的 nestedScrollConnection 参数不断的回调其中的方法。

在向上滚动时,available.y 是一个负值,而顶部的 TopAppBar 在向上滚动时需要收起,因此我们需要不断的修改 TopAppBar 的 offsetY 的值让它不断的减小,所以在每次 onPreScroll 回调中 toolbarOffsetHeightPx 不断累加available.y ,它就变成一个不断减小的负值。相反,在向下滚动时,我们需要不断的增大 TopAppBar 的 offsetY 的值,而此时 available.y 是一个正值,因此 toolbarOffsetHeightPx 不断累加它就会不断的变大。

我们只需把这个例子稍加修改,就可以实现一个常见的视差滚动效果,下面是利用上面代码实现的类似Material3的LargeTopAppBar的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@Composable
fun MotionLayoutAnimationByNestedScroll() {
val maxHeight = 200f
val minHeight = 60f
val toolbarHeightPx = maxHeight.dp.toPx()
val toolbarMinHeightPx = minHeight.dp.toPx()
var toolbarOffsetHeightPx by remember { mutableStateOf(0f) }

val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx + delta
// 限制范围在 [-140.dp, 0] 之间
toolbarOffsetHeightPx = newOffset.coerceIn(toolbarMinHeightPx-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}

var progress by remember { mutableStateOf(0f) }
LaunchedEffect(key1 = toolbarOffsetHeightPx){
// progress的值在topbar的高度从60.dp变化到200.dp过程中,会从0f变化到1f,反之从1f变化到0f
progress = ((toolbarHeightPx + toolbarOffsetHeightPx)/toolbarHeightPx-minHeight/maxHeight)/(1f-minHeight/maxHeight)
}

Box(Modifier.fillMaxSize().nestedScroll(nestedScrollConnection)) {
LazyColumn(contentPadding = PaddingValues(top = maxHeight.dp)) {
items(100) { index ->
Text("I'm item $index", modifier = Modifier.fillMaxWidth().padding(16.dp))
}
}
// 在向下滚动时: TopBar的高度不断增加,progress 不断增大
// 在向上滚动时: TopBar的高度不断减小,progress 不断减小
MyTopBar(height = (toolbarHeightPx + toolbarOffsetHeightPx).toDp(), progress)
}
}

@Composable
fun MyTopBar(height: Dp, progress: Float) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.background(Color.Red)
){
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
){
Spacer(Modifier.width(24.dp))
Spacer(Modifier.fillMaxWidth().weight(progress + 0.001f))
Column(horizontalAlignment = Alignment.CenterHorizontally){
Box(
modifier = Modifier
.fillMaxHeight().weight(1f)
.padding(vertical = 10.dp)
.aspectRatio(1f)
.clip(CircleShape)
.background(Color.White)
){
Image(
painter = painterResource(id = R.drawable.ic_head),
contentDescription = "",
modifier = Modifier.fillMaxSize()
)
}
Text(
"Hello World",
color = Color.White,
modifier = Modifier.alpha(progress)
.padding((8*(progress*progress*progress)).dp),
fontSize = (24*(progress)).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
"Hello World",
color = Color.White,
modifier = Modifier.alpha(1f-progress)
.weight(1.001f-progress)
.padding(start = 20.dp),
fontSize = (24*(1f-progress)).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.width(24.dp))
}
}
}

在这里插入图片描述

这里同样的当向下滚动时,在 onPreScroll 回调中不断累加 available.y 的值到 toolbarOffsetHeightPx 中,因此它是一个不断增大的值, 所以在 MyTopBar 的原本高度值 toolbarHeightPx 的基础上加上这个 toolbarOffsetHeightPx 之后,MyTopBar的高度就会在向下滚动的过程中不断的增大。对应的,在向上滚动时,MyTopBar的高度就会不断的减小,因为其累加的 toolbarOffsetHeightPx值在不断减小。而 MyTopBar 内部的各个组件之间的状态是根据滑动过程中计算的偏移量百分比 progress 的值动态调整的。

上面两个例子中 TopBar 只需要参与到嵌套滑动的系统中,来获得一些增量值以改变自身的偏移量或高度值跟随滑动而变化,但是 TopBar 本身是不需要进行滑动的。

而对于可选的 NestedScrollDispatcher: 如果一个组件能够接收和响应拖动/抛动事件,并且你希望该组件能够在滚动发生时通知父组件,则需要它,从而实现更好的整体协调。下面同样是官方文档提供的一个例子,其中子组件是可拖拽的,并且它通过分派嵌套的滚动事件来参与嵌套滚动链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 这里以 Modifier.draggable 为例,为包含 draggable 的组件添加嵌套滚动的支持。
// (与Modifier.scrollable不同,Modifier.draggable默认是没有提供嵌套滚动支持的)
// 这将是一个通用组件,将在其他嵌套滚动组件中工作。
// 把它放在 LazyColumn 或 Modifier.verticalScroll 中,可以查看它们如何相互作用
@Composable
fun NestedScrollByDispatcher(content: @Composable (Float) -> Unit) {
// first, state and it's bounds
var basicState by remember { mutableStateOf(0f) }
val minBound = -100f
val maxBound = 100f
// Lambda 用来更新状态并返回消耗的量
val onNewDelta: (Float) -> Float = { delta ->
val oldState = basicState
val newState = (basicState + delta).coerceIn(minBound, maxBound)
basicState = newState
newState - oldState
}

// 创建一个 dispatcher 来分派嵌套的滚动事件 (作为嵌套的滚动子项参与)
val nestedScrollDispatcher = remember { NestedScrollDispatcher() }

// 创建嵌套滚动连接以响应嵌套滚动事件 (作为父元素参与)
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 我们没有 fling,所以我们对常规的 postScroll 周期感兴趣,让我们尝试消耗剩余的量,并返回消耗的量
val vertical = available.y
val weConsumed = onNewDelta(vertical)
return Offset(x = 0f, y = weConsumed)
}
}
}
Box(
Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Magenta)
// 将自己附加到嵌套的滚动系统
.nestedScroll(connection = nestedScrollConnection, dispatcher = nestedScrollDispatcher)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
// 这是普通的拖拽,先问问父元素是否想要预消费(这是一个嵌套的滚动契约)
val parentsConsumed = nestedScrollDispatcher.dispatchPreScroll(
available = Offset(x = 0f, y = delta),
source = NestedScrollSource.Drag
)
// 调整我们可用的量,因为可能父元素已经消耗了一些
val adjustedAvailable = delta - parentsConsumed.y
// 我们的子元素进行消费
val weConsumed = onNewDelta(adjustedAvailable)
// 将 pre-scroll 和我们消费之后剩下的量作为 postScroll 进行调度
val totalConsumed = Offset(x = 0f, y = weConsumed) + parentsConsumed
val left = adjustedAvailable - weConsumed
nestedScrollDispatcher.dispatchPostScroll(
consumed = totalConsumed,
available = Offset(x = 0f, y = left),
source = NestedScrollSource.Drag
)
// 这里我们不会分发 pre/post fling 事件,因为我们这里没有fling操作,但是思想是相似的:
// 1. 分发 pre fling, 请求父元素进行预消费
// 2. 执行 fling 动作
// 3. 分发 post fling, 让父元素响应和处理剩余的速度
}
)
) {
Box(modifier = Modifier.align(Alignment.Center)) {
content(basicState)
}
}
}

我们可以把上面的 NestedScrollByDispatcher 组件放在一个 LazyColumn 滚动列表中查看效果:

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun NestedScrollByDispatcherUse() {
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
items(100) { index ->
NestedScrollByDispatcher { delta ->
Text("第 $index 项 draggable State: ${delta.roundToInt()}", color = Color.White, fontSize = 16.sp)
}
Divider()
}
}
}

在这里插入图片描述

如果把 NestedScrollByDispatcher 的 content 传入另外一个 LazyColumn 可能效果会更加明显:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Composable
fun NestedScrollByDispatcherUse() {
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
items(100) { index ->
NestedScrollByDispatcher { delta ->
SimpleColumnList()
}
Divider()
}
}
}
@Composable
fun SimpleColumnList() {
LazyColumn(
modifier = Modifier.width(200.dp).height(100.dp).background(getRandomColor()),
contentPadding = PaddingValues(5.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(10) { index ->
Text("子列表的第 $index 项", fontSize = 16.sp)
}
}
}

在这里插入图片描述

注意:建议在重组之间重用 NestedScrollConnection 和 NestedScrollDispatcher 对象,因为不同的对象会导致嵌套滚动图被不必要地重新计算。

最后再分享一个通过 nestedScroll 实现的列表视差滚动效果的动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@Composable
fun ParallaxScrollByNestedScrollDemo() {
val moonScrollSpeed = 0.3f
val midBgScrollSpeed = 0.03f

val imageHeight = (LocalConfiguration.current.screenWidthDp * (2f / 3f)).dp
val lazyListState = rememberLazyListState()

var moonOffset by remember { mutableStateOf(0f) }
var midBgOffset by remember { mutableStateOf(0f) }

val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val layoutInfo = lazyListState.layoutInfo
// 当列表中第一项可见时手指向下滑动不执行动画偏移的修改
if(lazyListState.firstVisibleItemIndex == 0) {
return Offset.Zero
}
// 当列表中最后一项可见时手指向上滑动不执行动画偏移的修改
if(layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1) {
return Offset.Zero
}
moonOffset += delta * moonScrollSpeed
midBgOffset += delta * midBgScrollSpeed
return Offset.Zero
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.nestedScroll(nestedScrollConnection),
state = lazyListState
) {
items(10) {
Text(
text = "Sample item",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
item {
Box(
modifier = Modifier
.clipToBounds()
.fillMaxWidth()
.height(imageHeight + midBgOffset.toDp())
.background(
Brush.verticalGradient(
listOf(
Color(0xFFf36b21),
Color(0xFFf9a521)
)
)
)
) {
Image(
painter = painterResource(id = R.drawable.ic_moonbg),
contentDescription = "moon",
contentScale = ContentScale.FillWidth,
alignment = Alignment.BottomCenter,
modifier = Modifier
.padding(15.dp)
.matchParentSize()
.graphicsLayer {
translationY = moonOffset
}
)
Image(
painter = painterResource(id = R.drawable.ic_midbg),
contentDescription = "mid bg",
contentScale = ContentScale.FillWidth,
alignment = Alignment.BottomCenter,
modifier = Modifier
.matchParentSize()
.graphicsLayer {
translationY = midBgOffset
}
)
Image(
painter = painterResource(id = R.drawable.ic_outerbg),
contentDescription = "outer bg",
contentScale = ContentScale.FillWidth,
alignment = Alignment.BottomCenter,
modifier = Modifier.matchParentSize()
)
}
}
items(20) {
Text(
text = "Sample item",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}

在这里插入图片描述

其中,中间的动画背景图实际上是由三张矢量图的 drawable xml 资源组成的:

在这里插入图片描述

然后在 onPreScroll 回调中,不断的在月亮和中间背景图上应用经过修改后的偏移量,以达到视差滚动效果。