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 <application
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"

View File

@ -2,6 +2,9 @@ package plus.rua.project
import android.os.Build import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
class AndroidPlatform : Platform { class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" 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" actual fun getAppIconUri(): String = "file:///android_asset/app_icon.png"
@Composable @Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { actual fun PredictiveBackHandler(
androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack) 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 package plus.rua.project
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
@ -13,10 +14,12 @@ 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.getValue import androidx.compose.runtime.getValue
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.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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import plus.rua.project.ui.AboutScreen import plus.rua.project.ui.AboutScreen
import plus.rua.project.ui.CalendarMonthView import plus.rua.project.ui.CalendarMonthView
@ -31,6 +34,20 @@ private enum class Screen { Main, About, Licenses }
@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) }
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() val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
@ -38,13 +55,13 @@ fun App() {
targetState = currentScreen, targetState = currentScreen,
transitionSpec = { transitionSpec = {
if (targetState.ordinal > initialState.ordinal) { if (targetState.ordinal > initialState.ordinal) {
// 导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出 // 向导航:新页面从右侧滑入覆盖,旧页面略微左移+淡出
(slideInHorizontally { it } + fadeIn()) togetherWith (slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it / 4 } + fadeOut()) (slideOutHorizontally { -it / 4 } + fadeOut())
} else { } else {
// 向后导航:新页面从左侧滑入,旧页面略微右移+淡 // 返回导航:新页面从左侧滑入,旧页面向右侧滑
(slideInHorizontally { -it } + fadeIn()) togetherWith (slideInHorizontally(animationSpec = tween(250)) { -it } + fadeIn(animationSpec = tween(250))) togetherWith
(slideOutHorizontally { it / 4 } + fadeOut()) (slideOutHorizontally(animationSpec = tween(250)) { it } + fadeOut(animationSpec = tween(250)))
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -55,16 +72,36 @@ fun App() {
onNavigateToAbout = { currentScreen = Screen.About } onNavigateToAbout = { currentScreen = Screen.About }
) )
Screen.About -> { Screen.About -> {
BackHandler { currentScreen = Screen.Main } PredictiveBackHandler(
enabled = backProgress == 0f,
onProgress = { backProgress = it },
onBack = handleBack,
onCancel = handleCancel
)
AboutScreen( AboutScreen(
onBack = { currentScreen = Screen.Main }, 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 -> { Screen.Licenses -> {
BackHandler { currentScreen = Screen.About } PredictiveBackHandler(
enabled = backProgress == 0f,
onProgress = { backProgress = it },
onBack = handleBack,
onCancel = handleCancel
)
LicensesScreen( 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 expect fun getAppIconUri(): String
/** /**
* 拦截系统返回手势 * 预测性返回手势处理器Android 13+
* *
* @param enabled 是否启用拦截 * @param enabled 是否启用
* @param onBack 返回回调 * @param onProgress 手势进度回调0.0~1.0跟手过程中持续调用
* @param onBack 手势完成回调滑动距离足够执行返回
* @param onCancel 手势取消回调滑动距离不足回弹
*/ */
@Composable @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" actual fun getAppIconUri(): String = "compose.resource://files/app_icon.png"
@Composable @Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { actual fun PredictiveBackHandler(
// iOS 没有系统返回键,由导航栏按钮处理 enabled: Boolean,
} onProgress: (Float) -> Unit,
onBack: () -> Unit,
onCancel: () -> Unit
) {
// iOS 没有预测性返回手势,由导航栏按钮处理
}