feat: 添加年月滚轮选择器,支持触觉反馈

- 新增 WheelPicker composable:惯性吸附 + 触觉反馈滚轮
- 新增 MonthYearPickerDialog:年月双滚轮选择弹窗
- 新增 YearPickerDialog:年份滚轮选择弹窗
- MonthHeader:点击年月文字弹出年月选择器
- YearHeader:点击年份文字弹出年份选择器
- 滚动时触发 HapticFeedbackConstants.CLOCK_TICK 触觉反馈
This commit is contained in:
meyou 2026-05-25 23:34:25 +08:00
parent 6fac313fdf
commit bbe51051ae
No known key found for this signature in database
5 changed files with 329 additions and 3 deletions

View File

@ -207,12 +207,21 @@ fun CalendarMonthView(
val onToday = remember(viewModel, today) {
{ viewModel.selectDate(today) }
}
val onMonthYearSelect = remember(viewModel, today) {
{ year: Int, month: Int ->
@Suppress("DEPRECATION")
val date = if (year == today.year && today.month.number == month) today
else LocalDate(year, month, 1)
viewModel.selectDate(date)
}
}
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = weekNumber,
showToday = selectedDate != today,
onToday = onToday
onToday = onToday,
onMonthYearSelect = onMonthYearSelect
)
WeekdayHeader(
modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp)

View File

@ -19,6 +19,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -29,11 +32,14 @@ import androidx.compose.ui.unit.sp
/**
* 月份标题栏显示"年月"文字和 ISO 周号
*
* 点击年月文字弹出滚轮选择器可快速跳转到任意年月
*
* @param year 年份
* @param month 月份1-12
* @param weekNumber 当前 ISO 周号
* @param showToday 是否显示今天按钮 selectedDate today
* @param onToday 点击今天按钮跳转今天
* @param onMonthYearSelect 年月选择回调
* @param modifier 外部布局修饰符
*/
@Composable
@ -43,8 +49,23 @@ fun MonthHeader(
weekNumber: Int,
showToday: Boolean,
onToday: (() -> Unit)? = null,
onMonthYearSelect: ((year: Int, month: Int) -> Unit)? = null,
modifier: Modifier = Modifier
) {
var showPicker by remember { mutableStateOf(false) }
if (showPicker && onMonthYearSelect != null) {
MonthYearPickerDialog(
currentYear = year,
currentMonth = month,
onConfirm = { y, m ->
onMonthYearSelect(y, m)
showPicker = false
},
onDismiss = { showPicker = false }
)
}
Row(
modifier = modifier
.fillMaxWidth()
@ -66,7 +87,13 @@ fun MonthHeader(
Text(
text = "${y}${m}",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.titleLarge,
modifier = if (onMonthYearSelect != null) {
Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showPicker = true }
.padding(horizontal = 4.dp, vertical = 2.dp)
} else Modifier
)
}
Spacer(modifier = Modifier.width(6.dp))

View File

@ -0,0 +1,122 @@
package plus.rua.project.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private val Years = (1970..2100).map { "${it}" }
private val Months = (1..12).map { "${it}" }
/**
* 年月滚轮选择器弹窗
*
* 左侧年份滚轮 + 右侧月份滚轮每次滚动触发触觉反馈
*
* @param currentYear 当前年份
* @param currentMonth 当前月份1-12
* @param onConfirm 确认回调参数为 (year, month)
* @param onDismiss 关闭回调
*/
@Composable
fun MonthYearPickerDialog(
currentYear: Int,
currentMonth: Int,
onConfirm: (year: Int, month: Int) -> Unit,
onDismiss: () -> Unit
) {
var selectedYear by remember { mutableIntStateOf(currentYear) }
var selectedMonth by remember { mutableIntStateOf(currentMonth) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("选择年月", style = MaterialTheme.typography.titleMedium)
},
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
WheelPicker(
items = Years,
selectedIndex = selectedYear - 1970,
onSelectedChange = { selectedYear = it + 1970 },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
WheelPicker(
items = Months,
selectedIndex = selectedMonth - 1,
onSelectedChange = { selectedMonth = it + 1 },
modifier = Modifier.weight(1f)
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selectedYear, selectedMonth) }) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
/**
* 年份滚轮选择器弹窗用于年视图
*
* @param currentYear 当前年份
* @param onConfirm 确认回调参数为 year
* @param onDismiss 关闭回调
*/
@Composable
fun YearPickerDialog(
currentYear: Int,
onConfirm: (year: Int) -> Unit,
onDismiss: () -> Unit
) {
var selectedYear by remember { mutableIntStateOf(currentYear) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("选择年份", style = MaterialTheme.typography.titleMedium)
},
text = {
WheelPicker(
items = Years,
selectedIndex = selectedYear - 1970,
onSelectedChange = { selectedYear = it + 1970 },
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = { onConfirm(selectedYear) }) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}

View File

@ -0,0 +1,151 @@
package plus.rua.project.ui
import android.os.Build
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 kotlin.math.abs
import kotlin.math.roundToInt
private val ItemHeight = 48.dp
private const val VisibleItemCount = 5
private val WheelHeight = ItemHeight * VisibleItemCount
/**
* 通用滚轮选择器支持惯性吸附和触觉反馈
*
* @param items 显示的项目列表
* @param selectedIndex 当前选中项索引
* @param onSelectedChange 选中项变化回调
* @param modifier 外部布局修饰符
* @param itemContent 单个项目渲染[isSelected] true 表示中心选中项
*/
@Composable
fun WheelPicker(
items: List<String>,
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)
)
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 {
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)
}
}
// 初始滚动到选中项
LaunchedEffect(selectedIndex) {
val target = layoutIndexForCenter(selectedIndex)
if (centerForLayoutIndex(listState.firstVisibleItemIndex) != selectedIndex) {
listState.scrollToItem((target - paddingItems).coerceAtLeast(0))
}
}
// 滚动停止后吸附到最近项
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.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 snapLayoutInfoProvider = remember(listState) {
SnapLayoutInfoProvider(listState)
}
LazyColumn(
state = listState,
modifier = modifier.height(WheelHeight),
flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider),
horizontalAlignment = Alignment.CenterHorizontally,
userScrollEnabled = true
) {
items(totalItems) { layoutIndex ->
val centerIndex = centerForLayoutIndex(layoutIndex)
val isValid = centerIndex in items.indices
Box(
modifier = Modifier
.height(ItemHeight)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
if (isValid) {
itemContent(centerIndex, items[centerIndex], centerIndex == currentCenter)
}
}
}
}
}
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)
}
}

View File

@ -351,6 +351,19 @@ fun YearHeader(
onYearChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var showPicker by remember { mutableStateOf(false) }
if (showPicker) {
YearPickerDialog(
currentYear = year,
onConfirm = { y ->
onYearChange(y)
showPicker = false
},
onDismiss = { showPicker = false }
)
}
Column(
modifier = modifier
.fillMaxWidth()
@ -373,7 +386,11 @@ fun YearHeader(
text = "${y}",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { showPicker = true }
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
Spacer(modifier = Modifier.weight(1f))