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