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), ) { 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组件中是通过使用items或item这两个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通过 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( 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 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 ) { 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 { 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), 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 ) 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, 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 ) 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, 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 -> message.id } ) { message -> MessageRow(message) } } }
避免使用大小为 0 像素的列表项 如果使用分页,尤其是在加载图片列表时,必须为每个列表项预先提供一个固定高度大小的占位符 ,因为LazyColumn会在首次测量时认为高度没有限制,会一直进行组合,直到计算的测量高度填满可用的视图窗口,然后停止组合。
如果没有提供占位符(高度是0px)或者提供了高度很小的占位符,则意味着在首次测量时,LazyColumn会组合它的所有项,因为高度为0的项可以很容易容纳到当前窗口中。而在几毫秒之后,图片又加载出来了,LazyColumn开始重组显示图片,但此时只有一部分能被容纳到窗口中,因此LazyColumn会舍弃最开始组合的不必要的其他所有项。为了避免这种性能损耗,应该为每个item提供一个默认的高度大小的占位符,最好的做法是保证加载前后的item高度大小不变 。
避免嵌套可以同方向滚动的组件 这仅适用于将没有预定义尺寸的可滚动子级嵌套在可向同一方向滚动的另一个父级中的情况。
例如,尝试在可垂直滚动的 Column 父级中嵌套没有固定高度的子级 LazyColumn:
1 2 3 4 5 6 7 8 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 -> 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, 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 > { 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 然后结合 Row或Column甚至是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.isRefreshingval 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 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} " ) } } } 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 ) } } } } }
本文开头提到可以在 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() 是一个高级的专门用于处理嵌套滚动的修饰符,它可以让开发者参与嵌套滑动的各个部分当中详细定制滑动的偏移量的消费。
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 个阶段:
onPreScroll :当子项即将执行滚动操作时,将触发此回调,并给父级一个机会在此之前消耗部分子级的增量。此过程应发生在每次可滚动组件接收到增量并通过 NestedScrollDispatcher 分派的时候。子级的分派应考虑到其所有祖先消耗了多少,并相应地调整消耗。
onPostScroll :当子项已经消耗了增量(考虑了父级在 1 中预消耗的部分)并希望将未消耗的增量通知祖先时,将触发此回调。此过程应发生在每当可滚动组件接收到增量并通过 NestedScrollDispatcher 分派的时候。任何接收 NestedScrollConnection.onPostScroll 的父级都应消耗不超过 left 的数量,并返回已消耗的数量。
onPreFling : 在滚动子项停止拖动并带着某种速度飞动之前发生的阶段。此回调允许祖先消耗速度的一部分。此过程应在 fling 本身发生之前发生。类似于 pre-scroll,父级可以消耗速度的一部分,而下面的节点(包括分派子级)应相应地调整其逻辑以仅适应剩余速度。
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("" ) } 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 @Composable fun NestedScrollWithCollapseToolBar () { val toolbarHeight = 50. dp val toolbarHeightPx = toolbarHeight.toPx() var toolbarOffsetHeightPx by remember { mutableStateOf(0f ) } val nestedScrollConnection = remember { object : NestedScrollConnection { 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 ) return Offset.Zero } } } Box( Modifier.fillMaxSize().nestedScroll(nestedScrollConnection) ) { LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) { 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的LargeTopAppBa r的效果:
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 toolbarOffsetHeightPx = newOffset.coerceIn(toolbarMinHeightPx-toolbarHeightPx, 0f ) return Offset.Zero } } } var progress by remember { mutableStateOf(0f ) } LaunchedEffect(key1 = toolbarOffsetHeightPx){ 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)) } } 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 @Composable fun NestedScrollByDispatcher (content: @Composable (Float ) -> Unit ) { var basicState by remember { mutableStateOf(0f ) } val minBound = -100f val maxBound = 100f val onNewDelta: (Float ) -> Float = { delta -> val oldState = basicState val newState = (basicState + delta).coerceIn(minBound, maxBound) basicState = newState newState - oldState } val nestedScrollDispatcher = remember { NestedScrollDispatcher() } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll (consumed: Offset , available: Offset , source: NestedScrollSource ) : Offset { 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) 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 ) } ) ) { 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 回调中,不断的在月亮和中间背景图上应用经过修改后的偏移量,以达到视差滚动效果。