Merge pull request #5 from xunrua/main

预测性返回
This commit is contained in:
Sonetto 2026-05-20 00:14:14 +08:00 committed by GitHub
commit 9d0d8bbfb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 172 additions and 292 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -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<BackEventCompat> ->
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()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -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<Screen?>(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: AboutScreenAbout 或 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: LicensesScreenLicenses 页面时组合)
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
)
}
}
}