Jetpack Compose实现滚动选择器

前言

在 Jetpack Compose 中实现滚动选择器(Scrollable Picker),通常可以使用 LazyColumnHorizontalPager 等组件。

但如果你想要类似传统 Android 的 NumberPicker 那种居中高亮、可滚动选择的效果,推荐使用 Modifier.graphicsLayer + LazyListState 来实现自定义的垂直/水平滚动选择器。

组件封装

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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.abs

@Composable
fun OnScrollStopped(
lazyListState: LazyListState,
onScrollStopped: () -> Unit
) {
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.isScrollInProgress }
.distinctUntilChanged()
.collect { isScrolling ->
if (!isScrolling) {
// 滚动已停止
onScrollStopped()
}
}
}
}

@Composable
fun ZScrollablePicker(
items: List<String>,
unit: String = "",
selectedIndex: Int,
selectBgColor: Color = Color(0xFFFFFAF2),
showNum: Int = 3,
onSelectedIndexChange: (Int) -> Unit,
) {
val TAG = "ZScrollablePicker"

var showItemNum = showNum
if (showItemNum < 3) {
showItemNum = 3
}
if (showItemNum % 2 == 0) {
showItemNum += 1
}

val listState =
rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)

LaunchedEffect(selectedIndex) {
listState.scrollToItem(selectedIndex)
}

var itemHeightPx = 0f

OnScrollStopped(listState) {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) {
return@OnScrollStopped
}

var selectIndexNew = 0
val centerY = itemHeightPx / 2f

// 找到包含 centerY 的那个 item
var closestIndex = visibleItems.first().index
var minDistance = Float.MAX_VALUE

for (item in visibleItems) {
// item.offset 是 item 顶部相对于 viewport 顶部的偏移(可为负)
val itemCenterY = item.offset + itemHeightPx / 2f
val distance = abs(itemCenterY - centerY)
if (distance < minDistance) {
minDistance = distance
closestIndex = item.index
}
}

selectIndexNew = closestIndex

onSelectedIndexChange(selectIndexNew)
}
var itemHeight by remember { mutableStateOf(48.dp) }
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
// 获取布局完成后的像素高度
itemHeight =
with(density) { (coordinates.size.height / showItemNum).toDp() }
itemHeightPx = with(density) { itemHeight.toPx() }
}
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
// 中间背景
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.background(selectBgColor)
) {}

}
LazyColumn(
state = listState,
contentPadding = PaddingValues(vertical = itemHeight * ((showItemNum - 1) / 2)),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize()
) {
items(items.size) { index ->
val isSelected = index == selectedIndex
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.clip(RoundedCornerShape(8.dp))
.graphicsLayer {
alpha = if (isSelected) 1f else 0.6f
scaleX = if (isSelected) 1.1f else 1f
}
) {
Text(
text = items[index] + unit,
fontSize = if (isSelected) 20.sp else 16.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) Color(0xFF333333) else Color(
0xFF999999
)
)
}
}
}
}
}

使用

1
2
3
4
5
6
7
8
9
10
val years = (1900..2100).map { it.toString() }
var selectedYear by remember { mutableIntStateOf(0) }

ZScrollablePicker(
items = years,
unit = "天",
selectedIndex = selectedYear,
) {
selectedYear = it
}

注意点

滚动结束监听

最好的方式是滚动结束的时候在触发选中事件,不要一直滚动一直触发,效果不好。

这就需要监听什么时候滚动结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun OnScrollStopped(
lazyListState: LazyListState,
onScrollStopped: () -> Unit
) {
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.isScrollInProgress }
.distinctUntilChanged()
.collect { isScrolling ->
if (!isScrolling) {
// 滚动已停止
onScrollStopped()
}
}
}
}

选中项居中

要想选中项居中,这个有一个简单的实现方法

1
contentPadding = PaddingValues(vertical = itemHeight * ((showItemNum - 1) / 2)),

设置容器的内部padding就行,假如显示5项,我们内部padding设置为2项的高度,那么相当于显示一项,这一项就是居中的。

注意

显示的项目至少是3,并且要是单数。

找到中心项

找到中心项,也就是找到某一项的中心离 viewport 中心最近的项。

item.offset 是 item 顶部相对于 viewport 顶部的偏移(可为负)。

注意

viewport 是要排除contentPadding的,也就是说contentPadding内的内容在页面上能看到,但是不算viewport内。

所以viewport的中心就是显示的那一项的中心。

1
val centerY = itemHeightPx / 2f

高度测量

1
2
3
4
5
6
7
8
9
10
11
12
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
coordinates ->
// 获取布局完成后的像素高度
itemHeight =
with(density) { (coordinates.size.height / showItemNum).toDp() }
itemHeightPx = with(density) { itemHeight.toPx() }
}
)