From ee72b8e106e0f2d65b315a6487053cb67ad582d3 Mon Sep 17 00:00:00 2001 From: wxxsfxyzm <65166044+wxxsfxyzm@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:04:31 +0800 Subject: [PATCH] feat(ui): improve predictive back animations (#2675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This pull request introduces custom screen transition animations to enhance the overall user experience during navigation. The key change is the implementation of a custom slide/fade effect for navigating from main screens (i.e., screens hosted in the bottom navigation bar) to detail screens. Transitions between the bottom navigation bar tabs themselves retain a simple, clean cross-fade effect to ensure a fast and smooth user interaction. This PR also addresses the root cause of an issue where custom animations were being overridden by the navigation library's defaults. ## Root Cause During implementation, it was discovered that custom transition animations defined in the `defaultTransitions` parameter of the `DestinationsNavHost` in `MainActivity` were not being applied. Instead, a default fade-in/fade-out animation was always present. The root cause was traced to the `compose-destinations` KSP (Kotlin Symbol Processing) code generator. By default, the generator creates a `NavGraphSpec` (e.g., `RootNavGraph.kt`) that includes its own `defaultTransitions` property. This property, defined at compile-time within the generated graph object, has a higher precedence than the `defaultTransitions` parameter supplied to the `DestinationsNavHost` composable at runtime. As a result, our intended custom animations were being ignored and overridden by the generated default. ## Solution To resolve this precedence issue permanently, this PR adopts the official configuration method recommended by the `compose-destinations` library. - The following KSP argument has been added to the `app/build.gradle.kts` file: ```kotlin ksp { arg("compose-destinations.defaultTransitions", "none") } ``` - This argument instructs the code generator to omit the `defaultTransitions` property from the generated `NavGraphSpec`. - By removing the higher-priority, generated default, the `defaultTransitions` parameter on `DestinationsNavHost` now functions as the effective default, allowing our custom animation logic to execute as intended. ## Animation Logic Details The new animation logic is conditional and defined within `MainActivity`. It distinguishes between two primary navigation types: - Main Screen → Detail Screen: - Enter: The new detail screen slides in from the right. - Exit: The old main screen slides out to the left while fading out. - Detail Screen → Main Screen (on Pop): - Pop Enter: The main screen slides back in from the left while fading in. - Pop Exit: The detail screen slides out to the right. - Between Bottom Navigation Tabs: - A simple cross-fade (`fadeIn`/`fadeOut`) is maintained for these transitions to provide a quick and non-disruptive experience when switching between primary sections of the app. --- manager/app/build.gradle.kts | 4 ++ .../me/weishu/kernelsu/ui/MainActivity.kt | 53 ++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/manager/app/build.gradle.kts b/manager/app/build.gradle.kts index 1f98e4a1..ce7cf2f1 100644 --- a/manager/app/build.gradle.kts +++ b/manager/app/build.gradle.kts @@ -92,6 +92,10 @@ android { } } +ksp { + arg("compose-destinations.defaultTransitions", "none") +} + dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt index 5737c524..6db05aa6 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/MainActivity.kt @@ -11,6 +11,9 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.displayCutout @@ -59,13 +62,16 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) - val isManager = Natives.becomeManager(ksuApp.packageName) - if (isManager) install() + val isManager = Natives.becomeManager(ksuApp.packageName) + if (isManager) install() setContent { KernelSUTheme { val navController = rememberNavController() val snackBarHostState = remember { SnackbarHostState() } + val bottomBarRoutes = remember { + BottomBarDestination.entries.map { it.direction.route }.toSet() + } Scaffold( bottomBar = { BottomBar(navController) }, contentWindowInsets = WindowInsets(0, 0, 0, 0) @@ -78,10 +84,45 @@ class MainActivity : ComponentActivity() { navGraph = NavGraphs.root, navController = navController, defaultTransitions = object : NavHostAnimatedDestinationStyle() { - override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = { fadeIn(animationSpec = tween(340)) } - override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = { fadeOut(animationSpec = tween(340)) } + override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + // If the target is a detail page (not a bottom navigation page), slide in from the right + if (targetState.destination.route !in bottomBarRoutes) { + slideInHorizontally(initialOffsetX = { it }) + } else { + // Otherwise (switching between bottom navigation pages), use fade in + fadeIn(animationSpec = tween(340)) + } + } + + override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + // If navigating from the home page (bottom navigation page) to a detail page, slide out to the left + if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) { + slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut() + } else { + // Otherwise (switching between bottom navigation pages), use fade out + fadeOut(animationSpec = tween(340)) + } + } + + override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + // If returning to the home page (bottom navigation page), slide in from the left + if (targetState.destination.route in bottomBarRoutes) { + slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn() + } else { + // Otherwise (e.g., returning between multiple detail pages), use default fade in + fadeIn(animationSpec = tween(340)) + } + } + + override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + // If returning from a detail page (not a bottom navigation page), scale down and fade out + if (initialState.destination.route !in bottomBarRoutes) { + scaleOut(targetScale = 0.9f) + fadeOut() + } else { + // Otherwise, use default fade out + fadeOut(animationSpec = tween(340)) + } + } } ) }