diff --git a/androidApp/src/main/assets/app_icon.png b/androidApp/src/main/assets/app_icon.png index 53fc536..d4491c3 100644 Binary files a/androidApp/src/main/assets/app_icon.png and b/androidApp/src/main/assets/app_icon.png differ diff --git a/androidApp/src/main/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index e93e11a..0000000 --- a/androidApp/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/androidApp/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cf..0000000 --- a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cf..0000000 --- a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png index a571e60..9c67d11 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png index 61da551..9c67d11 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png index c41dd28..8c16c78 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png index db5080a..8c16c78 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png index 6dba46d..333be02 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png index da31a87..333be02 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png index 15ac681..78b044c 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index b216f2d..78b044c 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png index f25a419..9a9fedb 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index e96783c..9a9fedb 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/shared/src/androidMain/kotlin/plus/rua/project/Platform.android.kt b/shared/src/androidMain/kotlin/plus/rua/project/Platform.android.kt index 6b4d872..aef4f5c 100644 --- a/shared/src/androidMain/kotlin/plus/rua/project/Platform.android.kt +++ b/shared/src/androidMain/kotlin/plus/rua/project/Platform.android.kt @@ -1,9 +1,12 @@ package plus.rua.project import android.os.Build +import androidx.activity.BackEventCompat +import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" @@ -22,19 +25,20 @@ actual fun PredictiveBackHandler( onBack: () -> Unit, onCancel: () -> Unit ) { - if (Build.VERSION.SDK_INT >= 34) { - rememberCoroutineScope() - androidx.activity.compose.PredictiveBackHandler(enabled) { progress -> - try { - progress.collect { backEvent -> - onProgress(backEvent.progress) - } - onBack() - } catch (e: CancellationException) { - onCancel() + // 官方 PredictiveBackHandler — Flow 模式:collect 完成=返回,CancellationException=取消 + PredictiveBackHandler(enabled = enabled) { progress: Flow -> + try { + progress.collect { event -> + onProgress(event.progress) } + onBack() + } catch (e: CancellationException) { + onCancel() } - } else { - androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack) + } + + // 降级:部分设备(如 OPPO/ColorOS)不通过 OnBackInvokedCallback 分发返回事件 + BackHandler(enabled = enabled) { + onBack() } } diff --git a/shared/src/commonMain/composeResources/files/app_icon.png b/shared/src/commonMain/composeResources/files/app_icon.png index 53fc536..d4491c3 100644 Binary files a/shared/src/commonMain/composeResources/files/app_icon.png and b/shared/src/commonMain/composeResources/files/app_icon.png differ diff --git a/shared/src/commonMain/kotlin/plus/rua/project/App.kt b/shared/src/commonMain/kotlin/plus/rua/project/App.kt index 317e723..0a0be15 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/App.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/App.kt @@ -1,117 +1,203 @@ 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.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.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope import androidx.compose.ui.graphics.graphicsLayer 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.CalendarMonthView import plus.rua.project.ui.LicensesScreen +import plus.rua.project.ui.lerp private enum class Screen { Main, About, Licenses } +/** 返回手势动画:顶层页面滑出 + 淡出 + 缩小 + 圆角阴影 */ +private fun GraphicsLayerScope.applyDismissTransform(progress: Float) { + // 二次缓动:小幅手势产生更柔和的视觉变化,大幅手势仍达到完整效果 + val p = progress * progress + translationX = p * size.width * 0.5f + scaleX = 1f - p * 0.08f + scaleY = 1f - p * 0.08f + alpha = 1f - p + shadowElevation = 32.dp.toPx() * p + shape = RoundedCornerShape(28.dp * p) + clip = p > 0.01f +} + +/** 底层页面缩放:随返回进度从 baseScale 放大到 1.0 */ +private fun GraphicsLayerScope.applyRevealTransform( + progress: Float, + forwardProgress: Float, + isForwardAnimating: Boolean +) { + val p = progress * progress + val baseScale = 0.92f + 0.08f * p + val scale = if (isForwardAnimating) lerp(1f, baseScale, forwardProgress) else baseScale + scaleX = scale + scaleY = scale +} + +/** 前向导航动画:新页面从右侧滑入 */ +private fun GraphicsLayerScope.applyEnterTransform(progress: Float) { + translationX = (1f - progress) * size.width + alpha = progress +} + /** * 应用入口 Composable,根据系统主题切换明暗 ColorScheme 并管理页面导航。 + * + * 使用 Box 分层布局替代 AnimatedContent,支持预测性返回手势: + * - 底层页面始终组合(状态保持),缩放显现 + * - 顶层页面在手势期间平滑位移、缩放、圆角、阴影 + * - 前向导航从右侧滑入,返回导航跟手驱动 */ @Composable @Preview(name = "Calendar App") fun App() { var currentScreen by remember { mutableStateOf(Screen.Main) } var backProgress by remember { mutableFloatStateOf(0f) } + val scope = rememberCoroutineScope() - val handleBack: () -> Unit = { - backProgress = 0f - when (currentScreen) { - Screen.About -> currentScreen = Screen.Main - Screen.Licenses -> currentScreen = Screen.About - else -> {} + val backAnimProgress = remember { Animatable(0f) } + val effectiveBackProgress by remember { + derivedStateOf { maxOf(backProgress, backAnimProgress.value) } + } + + var forwardTarget by remember { mutableStateOf(null) } + val forwardProgress = remember { Animatable(1f) } + + var isHandlingBack by remember { mutableStateOf(false) } + val handleBack: () -> Unit = lambda@{ + if (isHandlingBack) return@lambda + isHandlingBack = true + scope.launch { + backAnimProgress.snapTo(backProgress) + backProgress = 0f + backAnimProgress.animateTo(1f, spring(stiffness = Spring.StiffnessMedium, dampingRatio = Spring.DampingRatioNoBouncy)) + currentScreen = when (currentScreen) { + Screen.About -> Screen.Main + Screen.Licenses -> Screen.About + else -> currentScreen + } + backAnimProgress.animateTo(0f, spring(stiffness = Spring.StiffnessMedium, dampingRatio = Spring.DampingRatioNoBouncy)) + isHandlingBack = false } } 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() MaterialTheme(colorScheme = colorScheme) { - AnimatedContent( - targetState = currentScreen, - transitionSpec = { - if (targetState.ordinal > initialState.ordinal) { - // 正向导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出 - (slideInHorizontally { it } + fadeIn()) togetherWith - (slideOutHorizontally { -it / 4 } + fadeOut()) - } else { - // 返回导航:新页面从左侧滑入,旧页面向右侧滑出 - (slideInHorizontally(animationSpec = tween(250)) { -it } + fadeIn( - animationSpec = tween( - 250 + Box(modifier = Modifier.fillMaxSize()) { + // Layer 0: CalendarMonthView(始终组合以保持状态) + CalendarMonthView( + modifier = Modifier.graphicsLayer { + if (currentScreen != Screen.Main) { + applyRevealTransform( + effectiveBackProgress, + forwardProgress.value, + forwardTarget != null ) - )) togetherWith - (slideOutHorizontally(animationSpec = tween(250)) { it } + fadeOut( - animationSpec = tween(250) - )) - } - }, - modifier = Modifier.fillMaxSize() - ) { screen -> - when (screen) { - Screen.Main -> CalendarMonthView( - modifier = Modifier, - onNavigateToAbout = { currentScreen = Screen.About } + } + }, + onNavigateToAbout = { navigateTo(Screen.About) } + ) + + // Layer 1: AboutScreen(About 或 Licenses 页面时组合) + if (currentScreen == Screen.About || currentScreen == Screen.Licenses) { + AboutScreen( + onBack = { + if (currentScreen == Screen.About) handleBack() + }, + onNavigateToLicenses = { + if (currentScreen == Screen.About) navigateTo(Screen.Licenses) + }, + modifier = Modifier.graphicsLayer { + when (currentScreen) { + Screen.Licenses -> applyRevealTransform( + effectiveBackProgress, + forwardProgress.value, + forwardTarget == Screen.Licenses + ) + + Screen.About -> { + val bp = effectiveBackProgress + val fp = forwardProgress.value + when { + bp > 0.001f -> applyDismissTransform(bp) + fp < 0.999f && forwardTarget == Screen.About -> applyEnterTransform(fp) + } + } + + else -> {} + } + } ) + } - Screen.About -> { - PredictiveBackHandler( - enabled = backProgress == 0f, - onProgress = { backProgress = it }, - onBack = handleBack, - onCancel = handleCancel - ) - AboutScreen( - onBack = { currentScreen = Screen.Main }, - onNavigateToLicenses = { currentScreen = Screen.Licenses }, - modifier = Modifier.graphicsLayer { - translationX = backProgress * size.width * 0.3f - scaleX = 1f - backProgress * 0.05f - scaleY = 1f - backProgress * 0.05f + // Layer 2: LicensesScreen(Licenses 页面时组合) + if (currentScreen == Screen.Licenses) { + LicensesScreen( + onBack = handleBack, + modifier = Modifier.graphicsLayer { + val bp = effectiveBackProgress + val fp = forwardProgress.value + when { + bp > 0.001f -> applyDismissTransform(bp) + fp < 0.999f && forwardTarget == Screen.Licenses -> applyEnterTransform(fp) } - ) - } + } + ) + } - Screen.Licenses -> { - PredictiveBackHandler( - enabled = backProgress == 0f, - 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 = !backAnimProgress.isRunning && !isHandlingBack && forwardTarget == null, + onProgress = { backProgress = it }, + onBack = handleBack, + onCancel = handleCancel + ) } } }