feat: 预测性返回动画 — Box 分层布局替换 AnimatedContent
- 用 Box 三层堆叠替代 AnimatedContent,底层页面始终可见 - 返回手势跟手驱动顶层页面滑移+缩放+圆角+阴影,底层同步放大显现 - Animatable 驱动手势提交/取消的平滑过渡动画 - 前向导航从右侧滑入,底层页面同步缩小
This commit is contained in:
parent
7fc333eef4
commit
bde922080a
@ -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: AboutScreen(About 或 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: LicensesScreen(Licenses 页面时组合)
|
||||||
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user