feat: 预测性返回动画 — Box 分层布局替换 AnimatedContent

- 用 Box 三层堆叠替代 AnimatedContent,底层页面始终可见
- 返回手势跟手驱动顶层页面滑移+缩放+圆角+阴影,底层同步放大显现
- Animatable 驱动手势提交/取消的平滑过渡动画
- 前向导航从右侧滑入,底层页面同步缩小
This commit is contained in:
meyou 2026-05-19 21:12:02 +08:00
parent 7fc333eef4
commit bde922080a
No known key found for this signature in database

View File

@ -1,117 +1,196 @@
package plus.rua.project package plus.rua.project
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import plus.rua.project.ui.AboutScreen import plus.rua.project.ui.AboutScreen
import plus.rua.project.ui.CalendarMonthView import plus.rua.project.ui.CalendarMonthView
import plus.rua.project.ui.LicensesScreen import plus.rua.project.ui.LicensesScreen
import plus.rua.project.ui.lerp
private enum class Screen { Main, About, Licenses } private enum class Screen { Main, About, Licenses }
/** /**
* 应用入口 Composable根据系统主题切换明暗 ColorScheme 并管理页面导航 * 应用入口 Composable根据系统主题切换明暗 ColorScheme 并管理页面导航
*
* 使用 Box 分层布局替代 AnimatedContent支持预测性返回手势
* - 底层页面始终组合状态保持缩放显现
* - 顶层页面在手势期间平滑位移缩放圆角阴影
* - 前向导航从右侧滑入返回导航跟手驱动
*/ */
@Composable @Composable
@Preview(name = "Calendar App") @Preview(name = "Calendar App")
fun App() { fun App() {
var currentScreen by remember { mutableStateOf(Screen.Main) } var currentScreen by remember { mutableStateOf(Screen.Main) }
var backProgress by remember { mutableFloatStateOf(0f) } var backProgress by remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
val backAnimProgress = remember { Animatable(0f) }
val effectiveBackProgress by remember {
derivedStateOf { maxOf(backProgress, backAnimProgress.value) }
}
var forwardTarget by remember { mutableStateOf<Screen?>(null) }
val forwardProgress = remember { Animatable(1f) }
val handleBack: () -> Unit = { val handleBack: () -> Unit = {
backProgress = 0f scope.launch {
when (currentScreen) { backAnimProgress.snapTo(backProgress)
Screen.About -> currentScreen = Screen.Main backProgress = 0f
Screen.Licenses -> currentScreen = Screen.About backAnimProgress.animateTo(1f, tween(200, easing = FastOutSlowInEasing))
else -> {} currentScreen = when (currentScreen) {
Screen.About -> Screen.Main
Screen.Licenses -> Screen.About
else -> currentScreen
}
backAnimProgress.snapTo(0f)
} }
} }
val handleCancel: () -> Unit = { val handleCancel: () -> Unit = {
backProgress = 0f scope.launch {
backAnimProgress.snapTo(backProgress)
backProgress = 0f
backAnimProgress.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
}
}
val navigateTo: (Screen) -> Unit = { target ->
if (forwardTarget == null) {
scope.launch {
forwardTarget = target
currentScreen = target
forwardProgress.snapTo(0f)
forwardProgress.animateTo(1f, tween(350, easing = FastOutSlowInEasing))
forwardTarget = null
}
}
} }
val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
AnimatedContent( Box(modifier = Modifier.fillMaxSize()) {
targetState = currentScreen, // Layer 0: CalendarMonthView始终组合以保持状态
transitionSpec = { CalendarMonthView(
if (targetState.ordinal > initialState.ordinal) { modifier = Modifier.graphicsLayer {
// 正向导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出 if (currentScreen != Screen.Main) {
(slideInHorizontally { it } + fadeIn()) togetherWith val baseScale = 0.92f + 0.08f * effectiveBackProgress
(slideOutHorizontally { -it / 4 } + fadeOut()) val scale = if (forwardTarget != null) {
} else { lerp(1f, baseScale, forwardProgress.value)
// 返回导航:新页面从左侧滑入,旧页面向右侧滑出 } else {
(slideInHorizontally(animationSpec = tween(250)) { -it } + fadeIn( baseScale
animationSpec = tween( }
250 scaleX = scale
) scaleY = scale
)) togetherWith }
(slideOutHorizontally(animationSpec = tween(250)) { it } + fadeOut( },
animationSpec = tween(250) onNavigateToAbout = { navigateTo(Screen.About) }
)) )
}
}, // Layer 1: AboutScreenAbout 或 Licenses 页面时组合)
modifier = Modifier.fillMaxSize() if (currentScreen == Screen.About || currentScreen == Screen.Licenses) {
) { screen -> AboutScreen(
when (screen) { onBack = {
Screen.Main -> CalendarMonthView( if (currentScreen == Screen.About) handleBack()
modifier = Modifier, },
onNavigateToAbout = { currentScreen = Screen.About } onNavigateToLicenses = {
if (currentScreen == Screen.About) navigateTo(Screen.Licenses)
},
modifier = Modifier.graphicsLayer {
when (currentScreen) {
Screen.Licenses -> {
val baseScale = 0.92f + 0.08f * effectiveBackProgress
val scale = if (forwardTarget == Screen.Licenses) {
lerp(1f, baseScale, forwardProgress.value)
} else {
baseScale
}
scaleX = scale
scaleY = scale
}
Screen.About -> {
val bp = effectiveBackProgress
val fp = forwardProgress.value
when {
bp > 0.001f -> {
translationX = bp * size.width * 0.3f
scaleX = 1f - bp * 0.05f
scaleY = 1f - bp * 0.05f
shadowElevation = 32.dp.toPx() * bp
shape = RoundedCornerShape(28.dp * bp)
clip = bp > 0.01f
}
fp < 0.999f && forwardTarget == Screen.About -> {
translationX = (1f - fp) * size.width
alpha = fp
}
}
}
else -> {}
}
}
) )
}
Screen.About -> { // Layer 2: LicensesScreenLicenses 页面时组合)
PredictiveBackHandler( if (currentScreen == Screen.Licenses) {
enabled = backProgress == 0f, LicensesScreen(
onProgress = { backProgress = it }, onBack = handleBack,
onBack = handleBack, modifier = Modifier.graphicsLayer {
onCancel = handleCancel val bp = effectiveBackProgress
) val fp = forwardProgress.value
AboutScreen( when {
onBack = { currentScreen = Screen.Main }, bp > 0.001f -> {
onNavigateToLicenses = { currentScreen = Screen.Licenses }, translationX = bp * size.width * 0.3f
modifier = Modifier.graphicsLayer { scaleX = 1f - bp * 0.05f
translationX = backProgress * size.width * 0.3f scaleY = 1f - bp * 0.05f
scaleX = 1f - backProgress * 0.05f shadowElevation = 32.dp.toPx() * bp
scaleY = 1f - backProgress * 0.05f shape = RoundedCornerShape(28.dp * bp)
} clip = bp > 0.01f
) }
}
Screen.Licenses -> { fp < 0.999f && forwardTarget == Screen.Licenses -> {
PredictiveBackHandler( translationX = (1f - fp) * size.width
enabled = backProgress == 0f, alpha = fp
onProgress = { backProgress = it }, }
onBack = handleBack,
onCancel = handleCancel
)
LicensesScreen(
onBack = { currentScreen = Screen.About },
modifier = Modifier.graphicsLayer {
translationX = backProgress * size.width * 0.3f
scaleX = 1f - backProgress * 0.05f
scaleY = 1f - backProgress * 0.05f
} }
) }
} )
}
// 预测性返回手势
if (currentScreen != Screen.Main) {
PredictiveBackHandler(
enabled = backProgress == 0f && !backAnimProgress.isRunning && forwardTarget == null,
onProgress = { backProgress = it },
onBack = handleBack,
onCancel = handleCancel
)
} }
} }
} }