fix: 滚轮选择器仅在滚动停止后触发选中变更和触觉反馈

快速滑动时不再每帧更新 selectedIndex,改为等滚动停止后
计算最终中心项再触发回调,消除来回抽搐问题。
This commit is contained in:
meyou 2026-05-25 23:38:59 +08:00
parent bbe51051ae
commit 0b6d9ea87a
No known key found for this signature in database

View File

@ -1,6 +1,7 @@
package plus.rua.project.ui
import android.os.Build
import android.view.HapticFeedbackConstants
import android.view.View
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
@ -16,32 +17,31 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlin.math.abs
import kotlin.math.roundToInt
private val ItemHeight = 48.dp
private const val VisibleItemCount = 5
private val WheelHeight = ItemHeight * VisibleItemCount
private const val PaddingItems = VisibleItemCount / 2
/**
* 通用滚轮选择器支持惯性吸附和触觉反馈
*
* 滚动停止后才触发选中变更和触觉反馈避免快速滑动时抖动
*
* @param items 显示的项目列表
* @param selectedIndex 当前选中项索引
* @param onSelectedChange 选中项变化回调
* @param onSelectedChange 选中项变化回调仅在滚动停止后触发
* @param modifier 外部布局修饰符
* @param itemContent 单个项目渲染[isSelected] true 表示中心选中项
*/
@Composable
fun WheelPicker(
@ -49,65 +49,41 @@ fun WheelPicker(
selectedIndex: Int,
onSelectedChange: (Int) -> Unit,
modifier: Modifier = Modifier,
itemContent: @Composable (index: Int, item: String, isSelected: Boolean) -> Unit = { _, item, isSelected ->
Text(
text = item,
color = if (isSelected) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
fontSize = if (isSelected) 20.sp else 16.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
style = LocalTextStyle.current
)
}
) {
val paddingItems = VisibleItemCount / 2
val totalItems = items.size + paddingItems * 2
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = (selectedIndex - paddingItems).coerceAtLeast(0)
initialFirstVisibleItemIndex = (selectedIndex - PaddingItems).coerceAtLeast(0)
)
val coroutineScope = rememberCoroutineScope()
val view = LocalView.current
fun centerForLayoutIndex(layoutIndex: Int): Int = layoutIndex - paddingItems
fun layoutIndexForCenter(center: Int): Int = center + paddingItems
// 检测中心选中项变化 → 触觉反馈
val currentCenter by remember {
// 视觉中心项(实时,仅用于渲染高亮)
val visualCenter by remember {
derivedStateOf {
val viewportCenter = listState.layoutInfo.viewportSize.height / 2f
listState.layoutInfo.visibleItemsInfo.minByOrNull {
abs(it.offset + it.size / 2f - viewportCenter)
}?.index?.let { centerForLayoutIndex(it) } ?: -1
}
}
LaunchedEffect(currentCenter) {
if (currentCenter in items.indices && currentCenter != selectedIndex) {
onSelectedChange(currentCenter)
performHapticFeedback(view)
}?.index?.let { it - PaddingItems } ?: selectedIndex
}
}
// 初始滚动到选中项
LaunchedEffect(selectedIndex) {
val target = layoutIndexForCenter(selectedIndex)
if (centerForLayoutIndex(listState.firstVisibleItemIndex) != selectedIndex) {
listState.scrollToItem((target - paddingItems).coerceAtLeast(0))
val target = (selectedIndex - PaddingItems).coerceAtLeast(0)
if (listState.firstVisibleItemIndex != target) {
listState.scrollToItem(target)
}
}
// 滚动停止后吸附到最近项
// 滚动停止后:计算最终中心项 → 触发选中变更 + 触觉反馈
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling ->
var lastSettled = selectedIndex
snapshotFlow { listState.isScrollInProgress to listState.layoutInfo}
.collect { (scrolling, _) ->
if (!scrolling) {
val target = layoutIndexForCenter(currentCenter.coerceIn(0, items.lastIndex))
val current = listState.firstVisibleItemIndex + paddingItems
if (target != current) {
coroutineScope.launch {
listState.animateScrollToItem((target - paddingItems).coerceAtLeast(0))
}
val center = visualCenter.coerceIn(0, items.lastIndex)
if (center != lastSettled) {
lastSettled = center
onSelectedChange(center)
view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
}
}
}
@ -124,8 +100,8 @@ fun WheelPicker(
horizontalAlignment = Alignment.CenterHorizontally,
userScrollEnabled = true
) {
items(totalItems) { layoutIndex ->
val centerIndex = centerForLayoutIndex(layoutIndex)
items(items.size + PaddingItems * 2) { layoutIndex ->
val centerIndex = layoutIndex - PaddingItems
val isValid = centerIndex in items.indices
Box(
modifier = Modifier
@ -134,18 +110,17 @@ fun WheelPicker(
contentAlignment = Alignment.Center
) {
if (isValid) {
itemContent(centerIndex, items[centerIndex], centerIndex == currentCenter)
val isSelected = centerIndex == visualCenter
Text(
text = items[centerIndex],
color = if (isSelected) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
fontSize = if (isSelected) 20.sp else 16.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
style = LocalTextStyle.current
)
}
}
}
}
}
private fun performHapticFeedback(view: android.view.View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
} else {
@Suppress("DEPRECATION")
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
}
}