Add fling velocity threshold to collapse/expand drag gesture

Quick swipe now snaps to the target state regardless of progress position,
matching the behavior of HorizontalPager's fling logic. Uses VelocityTracker
to measure release velocity and a 800 dp/s threshold (FLING_VELOCITY_THRESHOLD_DP).
Slow drags still use the positional COLLAPSE_THRESHOLD (50%) as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-15 16:49:32 +08:00
parent d0492d02f0
commit 69e49b5d81
3 changed files with 39 additions and 10 deletions

View File

@ -17,6 +17,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import kotlin.time.Clock import kotlin.time.Clock
import plus.rua.project.ui.COLLAPSE_THRESHOLD import plus.rua.project.ui.COLLAPSE_THRESHOLD
import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP
data class CalendarDay( data class CalendarDay(
val date: LocalDate, val date: LocalDate,
@ -61,10 +62,16 @@ class CalendarViewModel(
} }
// 拖拽超过阈值时自动折叠到周视图,否则回弹到月视图 // 拖拽超过阈值时自动折叠到周视图,否则回弹到月视图
fun onDragEnd() { // velocityDpPerSec: 松手时的 fling 速度 (dp/s),正值=上滑(折叠方向),负值=下滑(展开方向)
fun onDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch { coroutineScope.launch {
val current = _collapseAnimatable.value val progress = _collapseAnimatable.value
if (current > COLLAPSE_THRESHOLD) { val shouldCollapse = when {
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true // 快速上滑→折叠
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false // 快速下滑→展开
else -> progress > COLLAPSE_THRESHOLD // 慢速按 progress 判断
}
if (shouldCollapse) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)
@ -88,10 +95,16 @@ class CalendarViewModel(
} }
// 下拉超过阈值时自动展开到月视图,否则回弹到周视图 // 下拉超过阈值时自动展开到月视图,否则回弹到周视图
fun onExpandDragEnd() { // velocityDpPerSec: 同上,正值=上滑,负值=下滑
fun onExpandDragEnd(velocityDpPerSec: Float = 0f) {
coroutineScope.launch { coroutineScope.launch {
val current = _collapseAnimatable.value val progress = _collapseAnimatable.value
if (current < COLLAPSE_THRESHOLD) { val shouldExpand = when {
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true // 快速下滑→展开
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false // 快速上滑→保持折叠
else -> progress < COLLAPSE_THRESHOLD // 慢速按 progress 判断
}
if (shouldExpand) {
_collapseAnimatable.animateTo( _collapseAnimatable.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f) animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f)

View File

@ -11,11 +11,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import plus.rua.project.CalendarViewModel import plus.rua.project.CalendarViewModel
@ -33,20 +35,27 @@ fun BottomCard(
dragRangePx: Float, dragRangePx: Float,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val density = LocalDensity.current
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.pointerInput(viewModel.isCollapsed) { .pointerInput(viewModel.isCollapsed) {
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
if (viewModel.isCollapsed) { if (viewModel.isCollapsed) {
// 折叠状态:下拉恢复到月视图 // 折叠状态:下拉恢复到月视图
detectVerticalDragGestures( detectVerticalDragGestures(
onDragEnd = { onDragEnd = {
viewModel.onExpandDragEnd() val velocity = velocityTracker.calculateVelocity()
// 上滑为正(折叠方向),下拉为负(展开方向)
val velocityDpPerSec = with(density) { -velocity.y.toDp().value }
viewModel.onExpandDragEnd(velocityDpPerSec)
}, },
onDragCancel = { onDragCancel = {
viewModel.onExpandDragEnd() viewModel.onExpandDragEnd()
} }
) { _, dragAmount -> ) { change, dragAmount ->
velocityTracker.addPosition(change.uptimeMillis, change.position)
val delta = -dragAmount / dragRangePx val delta = -dragAmount / dragRangePx
viewModel.onExpandDrag(delta) viewModel.onExpandDrag(delta)
} }
@ -54,12 +63,16 @@ fun BottomCard(
// 展开状态:上拉折叠到周视图 // 展开状态:上拉折叠到周视图
detectVerticalDragGestures( detectVerticalDragGestures(
onDragEnd = { onDragEnd = {
viewModel.onDragEnd() val velocity = velocityTracker.calculateVelocity()
// 上滑为正(折叠方向),下拉为负(展开方向)
val velocityDpPerSec = with(density) { -velocity.y.toDp().value }
viewModel.onDragEnd(velocityDpPerSec)
}, },
onDragCancel = { onDragCancel = {
viewModel.onDragEnd() viewModel.onDragEnd()
} }
) { _, dragAmount -> ) { change, dragAmount ->
velocityTracker.addPosition(change.uptimeMillis, change.position)
val delta = -dragAmount / dragRangePx val delta = -dragAmount / dragRangePx
viewModel.onDrag(delta) viewModel.onDrag(delta)
} }

View File

@ -24,6 +24,9 @@ const val HORIZONTAL_PADDING_DP = 16
/** BottomCard 拖拽手势范围最小值 (dp),防止行数少时 dragRange 过小 */ /** BottomCard 拖拽手势范围最小值 (dp),防止行数少时 dragRange 过小 */
const val DRAG_RANGE_MIN_DP = 100 const val DRAG_RANGE_MIN_DP = 100
/** fling 速度阈值 (dp/s),超过此速度按方向直接折叠/展开,不受 progress 阈值限制 */
const val FLING_VELOCITY_THRESHOLD_DP = 800
/** 日历与 BottomCard 之间的间距 (dp):展开时 */ /** 日历与 BottomCard 之间的间距 (dp):展开时 */
const val CARD_GAP_EXPANDED_DP = 24 const val CARD_GAP_EXPANDED_DP = 24