feat: Android 13+ 预测性返回手势(Predictive Back)

- BackHandler 升级为 PredictiveBackHandler expect/actual
- Android 13+ 启用系统级预测返回,跟手阶段同步位移/缩放页面
- Android 低版本回退至普通 BackHandler
- iOS 保持空实现(无系统返回手势)
- 页面返回动画统一 250ms 时长,提升流畅感
- AndroidManifest 启用 enableOnBackInvokedCallback
This commit is contained in:
xfy 2026-05-19 17:58:49 +08:00
parent 58ab7eab4e
commit fc3c8ec882
5 changed files with 90 additions and 18 deletions

View File

@ -3,6 +3,7 @@
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"

View File

@ -2,6 +2,9 @@ package plus.rua.project
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
@ -14,6 +17,25 @@ actual fun getGifUri(gifFile: String): String = "file:///android_asset/gifs/$gif
actual fun getAppIconUri(): String = "file:///android_asset/app_icon.png"
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack)
}
actual fun PredictiveBackHandler(
enabled: Boolean,
onProgress: (Float) -> Unit,
onBack: () -> Unit,
onCancel: () -> Unit
) {
if (Build.VERSION.SDK_INT >= 34) {
val scope = rememberCoroutineScope()
androidx.activity.compose.PredictiveBackHandler(enabled) { progress ->
try {
progress.collect { backEvent ->
onProgress(backEvent.progress)
}
onBack()
} catch (e: CancellationException) {
onCancel()
}
}
} else {
androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack)
}
}

View File

@ -1,6 +1,7 @@
package plus.rua.project
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@ -13,10 +14,12 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import plus.rua.project.ui.AboutScreen
import plus.rua.project.ui.CalendarMonthView
@ -31,6 +34,20 @@ private enum class Screen { Main, About, Licenses }
@Preview(name = "Calendar App")
fun App() {
var currentScreen by remember { mutableStateOf(Screen.Main) }
var backProgress by remember { mutableFloatStateOf(0f) }
val handleBack: () -> Unit = {
backProgress = 0f
when (currentScreen) {
Screen.About -> currentScreen = Screen.Main
Screen.Licenses -> currentScreen = Screen.About
else -> {}
}
}
val handleCancel: () -> Unit = {
backProgress = 0f
}
val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) {
@ -38,13 +55,13 @@ fun App() {
targetState = currentScreen,
transitionSpec = {
if (targetState.ordinal > initialState.ordinal) {
// 导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出
// 向导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it / 4 } + fadeOut())
} else {
// 向后导航:新页面从左侧滑入,旧页面略微右移+淡
(slideInHorizontally { -it } + fadeIn()) togetherWith
(slideOutHorizontally { it / 4 } + fadeOut())
// 返回导航:新页面从左侧滑入,旧页面向右侧滑
(slideInHorizontally(animationSpec = tween(250)) { -it } + fadeIn(animationSpec = tween(250))) togetherWith
(slideOutHorizontally(animationSpec = tween(250)) { it } + fadeOut(animationSpec = tween(250)))
}
},
modifier = Modifier.fillMaxSize()
@ -55,16 +72,36 @@ fun App() {
onNavigateToAbout = { currentScreen = Screen.About }
)
Screen.About -> {
BackHandler { currentScreen = Screen.Main }
PredictiveBackHandler(
enabled = backProgress == 0f,
onProgress = { backProgress = it },
onBack = handleBack,
onCancel = handleCancel
)
AboutScreen(
onBack = { currentScreen = Screen.Main },
onNavigateToLicenses = { currentScreen = Screen.Licenses }
onNavigateToLicenses = { currentScreen = Screen.Licenses },
modifier = Modifier.graphicsLayer {
translationX = backProgress * size.width * 0.3f
scaleX = 1f - backProgress * 0.05f
scaleY = 1f - backProgress * 0.05f
}
)
}
Screen.Licenses -> {
BackHandler { currentScreen = Screen.About }
PredictiveBackHandler(
enabled = backProgress == 0f,
onProgress = { backProgress = it },
onBack = handleBack,
onCancel = handleCancel
)
LicensesScreen(
onBack = { currentScreen = Screen.About }
onBack = { currentScreen = Screen.About },
modifier = Modifier.graphicsLayer {
translationX = backProgress * size.width * 0.3f
scaleX = 1f - backProgress * 0.05f
scaleY = 1f - backProgress * 0.05f
}
)
}
}

View File

@ -19,10 +19,17 @@ expect fun getGifUri(gifFile: String): String
expect fun getAppIconUri(): String
/**
* 拦截系统返回手势
* 预测性返回手势处理器Android 13+
*
* @param enabled 是否启用拦截
* @param onBack 返回回调
* @param enabled 是否启用
* @param onProgress 手势进度回调0.0~1.0跟手过程中持续调用
* @param onBack 手势完成回调滑动距离足够执行返回
* @param onCancel 手势取消回调滑动距离不足回弹
*/
@Composable
expect fun BackHandler(enabled: Boolean = true, onBack: () -> Unit)
expect fun PredictiveBackHandler(
enabled: Boolean = true,
onProgress: (Float) -> Unit = {},
onBack: () -> Unit,
onCancel: () -> Unit = {}
)

View File

@ -15,6 +15,11 @@ actual fun getGifUri(gifFile: String): String = "compose.resource://files/$gifFi
actual fun getAppIconUri(): String = "compose.resource://files/app_icon.png"
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
// iOS 没有系统返回键,由导航栏按钮处理
}
actual fun PredictiveBackHandler(
enabled: Boolean,
onProgress: (Float) -> Unit,
onBack: () -> Unit,
onCancel: () -> Unit
) {
// iOS 没有预测性返回手势,由导航栏按钮处理
}