diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de1b63..cd2c0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,33 +3,16 @@ ## [Unreleased] ### Added +- `NavAnimation.directionAware()` for forward/backward navigation transitions +- Sample Home → Details → Checkout flow in the demo app + +## [1.0.0] - 2026-06-14 + +### Added +- Maven Central and JitPack publishing - Custom animation builder via `NavAnimation.custom()` ## [0.1.0] - 2026-04-20 ### Added - Preset animations and `animatedComposable()` extension - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 3a2608e..43294e9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ * Quick start * Preset animations * Custom animations +* Direction-aware navigation * Sample app * Project structure * Contributing @@ -28,6 +29,7 @@ | --- | --- | | **Preset transitions** | `fade`, `slideLeft`, `slideRight`, `slideUp`, `scale` | | **Custom builder** | Compose transitions with shared duration and easing | +| **Direction-aware** | `directionAware()` maps forward push and backward pop animations | | **Pop support** | Matching back-stack enter/exit animations | | **Simple API** | One extension: `animatedComposable()` | | **Defaults** | 300 ms duration, `FastOutSlowInEasing` | @@ -41,6 +43,7 @@ The sample app demo above shows: - Home list navigation with profile entry - Detail screen transitions using `slideLeft` +- Direction-aware Home → Details → Checkout flow (`slideLeft` forward, `slideRight` back) - Profile screen with custom horizontal slide - Sheet screen with mixed slide-up and fade animations @@ -170,13 +173,47 @@ NavAnimation.custom( --- +## Direction-aware navigation + +Use separate forward and backward specs. Compose Navigation applies `enter`/`exit` on push and `popEnter`/`popExit` on back — no stack-depth tracking required. + +```kotlin +val navAnimation = NavAnimation.directionAware( + forward = NavAnimation.slideLeft(), + backward = NavAnimation.slideRight(), +) + +NavHost(navController, startDestination = "home") { + animatedComposable(route = "home", animation = navAnimation) { HomeScreen() } + animatedComposable(route = "details", animation = navAnimation) { DetailsScreen() } + animatedComposable(route = "checkout", animation = navAnimation) { CheckoutScreen() } +} +``` + +Works with presets and `NavAnimation.custom()`: + +```kotlin +NavAnimation.directionAware( + forward = NavAnimation.custom( + enter = { fadeIn(animationSpec = tweenSpec()) }, + exit = { fadeOut(animationSpec = tweenSpec()) }, + ), + backward = NavAnimation.custom( + enter = { slideInHorizontally(animationSpec = tweenSpec()) }, + exit = { slideOutHorizontally(animationSpec = tweenSpec()) }, + ), +) +``` + +--- + ## Sample app ```bash ./gradlew :app:installDebug ``` -Demonstrates preset `slideLeft`, a custom horizontal profile screen, and a mixed slide-up / fade sheet screen. +Demonstrates preset `slideLeft`, direction-aware checkout flow, a custom horizontal profile screen, and a mixed slide-up / fade sheet screen. --- diff --git a/app/src/main/java/com/example/composenavmotion/MainActivity.kt b/app/src/main/java/com/example/composenavmotion/MainActivity.kt index 24f0b48..bb8789b 100644 --- a/app/src/main/java/com/example/composenavmotion/MainActivity.kt +++ b/app/src/main/java/com/example/composenavmotion/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.composenavmotion.NavAnimation import com.composenavmotion.animatedComposable +import com.example.composenavmotion.ui.checkout.CheckoutScreen import com.example.composenavmotion.ui.detail.DetailScreen import com.example.composenavmotion.ui.home.HomeScreen import com.example.composenavmotion.ui.profile.ProfileScreen @@ -32,21 +33,42 @@ class MainActivity : ComponentActivity() { setContent { ComposeNavMotionTheme { val navController = rememberNavController() + val directionAwareAnimation = NavAnimation.directionAware( + forward = NavAnimation.slideLeft(), + backward = NavAnimation.slideRight(), + ) Scaffold { innerPadding -> NavHost( navController = navController, startDestination = "home", modifier = Modifier.padding(innerPadding), ) { - animatedComposable(route = "home", animation = NavAnimation.fade()) { + animatedComposable(route = "home", animation = directionAwareAnimation) { HomeScreen( onOpenDetail = { item -> navController.navigate("details/${item.id}") }, onOpenSheet = { navController.navigate("sheet") }, onOpenProfile = { navController.navigate("profile") }, + onOpenCheckoutFlow = { navController.navigate("checkout-details") }, ) } + animatedComposable( + route = "checkout-details", + animation = directionAwareAnimation, + ) { + DetailScreen( + itemId = "checkout-flow", + onBack = { navController.popBackStack() }, + onContinueToCheckout = { navController.navigate("checkout") }, + ) + } + animatedComposable( + route = "checkout", + animation = directionAwareAnimation, + ) { + CheckoutScreen(onBack = { navController.popBackStack() }) + } animatedComposable( route = "details/{itemId}", animation = NavAnimation.slideLeft(), diff --git a/app/src/main/java/com/example/composenavmotion/ui/checkout/CheckoutScreen.kt b/app/src/main/java/com/example/composenavmotion/ui/checkout/CheckoutScreen.kt new file mode 100644 index 0000000..ab03b90 --- /dev/null +++ b/app/src/main/java/com/example/composenavmotion/ui/checkout/CheckoutScreen.kt @@ -0,0 +1,48 @@ +package com.example.composenavmotion.ui.checkout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.composenavmotion.ui.components.SampleTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CheckoutScreen( + onBack: () -> Unit, +) { + Scaffold( + topBar = { + SampleTopBar(title = "Checkout", onBack = onBack) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Order summary", style = MaterialTheme.typography.headlineSmall) + Text( + text = "Pop back to Details or Home to see backward slideRight.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 12.dp), + ) + Text( + text = "Direction-aware: slideLeft forward, slideRight backward", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/app/src/main/java/com/example/composenavmotion/ui/detail/DetailScreen.kt b/app/src/main/java/com/example/composenavmotion/ui/detail/DetailScreen.kt index a1283ce..5e8ec66 100644 --- a/app/src/main/java/com/example/composenavmotion/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/example/composenavmotion/ui/detail/DetailScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -20,6 +21,7 @@ import com.example.composenavmotion.ui.home.sampleHomeItems fun DetailScreen( itemId: String, onBack: () -> Unit, + onContinueToCheckout: (() -> Unit)? = null, ) { val item = sampleHomeItems.firstOrNull { it.id == itemId } val title = item?.title ?: "Details" @@ -44,10 +46,22 @@ fun DetailScreen( modifier = Modifier.padding(top = 12.dp), ) Text( - text = "Opened with slideLeft preset", + text = if (onContinueToCheckout != null) { + "Forward navigation uses slideLeft" + } else { + "Opened with slideLeft preset" + }, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp), ) + onContinueToCheckout?.let { onContinue -> + Button( + onClick = onContinue, + modifier = Modifier.padding(top = 24.dp), + ) { + Text("Continue to Checkout") + } + } } } } diff --git a/app/src/main/java/com/example/composenavmotion/ui/home/HomeItem.kt b/app/src/main/java/com/example/composenavmotion/ui/home/HomeItem.kt index 90d5897..61b20ce 100644 --- a/app/src/main/java/com/example/composenavmotion/ui/home/HomeItem.kt +++ b/app/src/main/java/com/example/composenavmotion/ui/home/HomeItem.kt @@ -5,6 +5,7 @@ data class HomeItem( val title: String, val subtitle: String, val opensSheet: Boolean = false, + val opensCheckoutFlow: Boolean = false, ) val sampleHomeItems = listOf( @@ -12,5 +13,11 @@ val sampleHomeItems = listOf( HomeItem("slide-left", "Slide left transition", "NavAnimation.slideLeft() preset"), HomeItem("slide-right", "Slide right transition", "NavAnimation.slideRight() preset"), HomeItem("scale", "Scale transition", "NavAnimation.scale() preset"), + HomeItem( + "checkout-flow", + "Direction-aware checkout", + "Home → Details → Checkout with slideLeft / slideRight", + opensCheckoutFlow = true, + ), HomeItem("sheet", "Sheet transition", "Custom slide up with fade", opensSheet = true), ) diff --git a/app/src/main/java/com/example/composenavmotion/ui/home/HomeScreen.kt b/app/src/main/java/com/example/composenavmotion/ui/home/HomeScreen.kt index 55942e8..8358957 100644 --- a/app/src/main/java/com/example/composenavmotion/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/example/composenavmotion/ui/home/HomeScreen.kt @@ -26,6 +26,7 @@ fun HomeScreen( onOpenDetail: (HomeItem) -> Unit, onOpenSheet: () -> Unit, onOpenProfile: () -> Unit, + onOpenCheckoutFlow: () -> Unit, ) { Scaffold( topBar = { @@ -53,10 +54,10 @@ fun HomeScreen( modifier = Modifier .fillMaxWidth() .clickable { - if (item.opensSheet) { - onOpenSheet() - } else { - onOpenDetail(item) + when { + item.opensSheet -> onOpenSheet() + item.opensCheckoutFlow -> onOpenCheckoutFlow() + else -> onOpenDetail(item) } }, headlineContent = { Text(item.title) }, diff --git a/nav-animation/src/main/java/com/composenavmotion/NavAnimation.kt b/nav-animation/src/main/java/com/composenavmotion/NavAnimation.kt index 829b22b..5886639 100644 --- a/nav-animation/src/main/java/com/composenavmotion/NavAnimation.kt +++ b/nav-animation/src/main/java/com/composenavmotion/NavAnimation.kt @@ -56,4 +56,29 @@ object NavAnimation { val config = AnimationConfig(duration, easing) return NavAnimationSpec(config.enter(), config.exit(), config.popEnter(), config.popExit(), config) } + + /** + * Combines separate forward and backward [NavAnimationSpec] instances for direction-aware navigation. + * + * Forward navigation (push) uses [forward] enter and exit transitions. + * Back navigation (pop) uses [backward] enter and exit transitions as pop enter and pop exit. + * + * Example: + * ``` + * val animation = NavAnimation.directionAware( + * forward = NavAnimation.slideLeft(), + * backward = NavAnimation.slideRight(), + * ) + * ``` + */ + fun directionAware( + forward: NavAnimationSpec, + backward: NavAnimationSpec, + ): NavAnimationSpec = NavAnimationSpec( + enterTransition = forward.enterTransition, + exitTransition = forward.exitTransition, + popEnterTransition = backward.enterTransition, + popExitTransition = backward.exitTransition, + config = forward.config, + ) } diff --git a/nav-animation/src/main/java/com/composenavmotion/NavAnimationSpec.kt b/nav-animation/src/main/java/com/composenavmotion/NavAnimationSpec.kt index 303f058..cf14645 100644 --- a/nav-animation/src/main/java/com/composenavmotion/NavAnimationSpec.kt +++ b/nav-animation/src/main/java/com/composenavmotion/NavAnimationSpec.kt @@ -3,6 +3,12 @@ package com.composenavmotion import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +/** + * Resolved enter, exit, and pop transitions for a navigation destination. + * + * Use [NavAnimation.directionAware] to map separate forward and backward specs onto + * [enterTransition]/[exitTransition] and [popEnterTransition]/[popExitTransition]. + */ data class NavAnimationSpec( val enterTransition: EnterTransition, val exitTransition: ExitTransition, diff --git a/nav-animation/src/main/java/com/composenavmotion/NavGraphBuilderExt.kt b/nav-animation/src/main/java/com/composenavmotion/NavGraphBuilderExt.kt index 1ca7ce8..07f3542 100644 --- a/nav-animation/src/main/java/com/composenavmotion/NavGraphBuilderExt.kt +++ b/nav-animation/src/main/java/com/composenavmotion/NavGraphBuilderExt.kt @@ -8,6 +8,12 @@ import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +/** + * Registers a composable destination with enter, exit, and pop transitions from [animation]. + * + * Pop transitions power back navigation. Pair with [NavAnimation.directionAware] so forward + * pushes and backward pops use different visual directions. + */ fun NavGraphBuilder.animatedComposable( route: String, animation: NavAnimationSpec = NavAnimation.fade(), diff --git a/nav-animation/src/test/java/com/composenavmotion/DirectionAwareNavAnimationTest.kt b/nav-animation/src/test/java/com/composenavmotion/DirectionAwareNavAnimationTest.kt new file mode 100644 index 0000000..84d995e --- /dev/null +++ b/nav-animation/src/test/java/com/composenavmotion/DirectionAwareNavAnimationTest.kt @@ -0,0 +1,74 @@ +package com.composenavmotion + +import org.junit.Assert.assertSame +import org.junit.Test +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally + +class DirectionAwareNavAnimationTest { + @Test + fun directionAware_forwardEnter_usesForwardEnter() { + val forward = NavAnimation.slideLeft() + val backward = NavAnimation.slideRight() + val spec = NavAnimation.directionAware(forward, backward) + assertSame(forward.enterTransition, spec.enterTransition) + } + + @Test + fun directionAware_forwardExit_usesForwardExit() { + val forward = NavAnimation.slideLeft() + val backward = NavAnimation.slideRight() + val spec = NavAnimation.directionAware(forward, backward) + assertSame(forward.exitTransition, spec.exitTransition) + } + + @Test + fun directionAware_popEnter_usesBackwardEnter() { + val forward = NavAnimation.slideLeft() + val backward = NavAnimation.slideRight() + val spec = NavAnimation.directionAware(forward, backward) + assertSame(backward.enterTransition, spec.popEnterTransition) + } + + @Test + fun directionAware_popExit_usesBackwardExit() { + val forward = NavAnimation.slideLeft() + val backward = NavAnimation.slideRight() + val spec = NavAnimation.directionAware(forward, backward) + assertSame(backward.exitTransition, spec.popExitTransition) + } + + @Test + fun directionAware_worksWithPresetCombinations() { + val presets = listOf( + NavAnimation.fade() to NavAnimation.fade(), + NavAnimation.slideLeft() to NavAnimation.slideRight(), + NavAnimation.slideUp() to NavAnimation.slideUp(), + NavAnimation.scale() to NavAnimation.scale(), + ) + presets.forEach { (forward, backward) -> + val spec = NavAnimation.directionAware(forward, backward) + assertSame(forward.enterTransition, spec.enterTransition) + assertSame(backward.enterTransition, spec.popEnterTransition) + } + } + + @Test + fun directionAware_worksWithCustomAnimations() { + val forward = NavAnimation.custom( + enter = { fadeIn(animationSpec = tweenSpec()) }, + exit = { fadeOut(animationSpec = tweenSpec()) }, + ) + val backward = NavAnimation.custom( + enter = { slideInHorizontally(animationSpec = tweenSpec()) }, + exit = { slideOutHorizontally(animationSpec = tweenSpec()) }, + ) + val spec = NavAnimation.directionAware(forward, backward) + assertSame(forward.enterTransition, spec.enterTransition) + assertSame(forward.exitTransition, spec.exitTransition) + assertSame(backward.enterTransition, spec.popEnterTransition) + assertSame(backward.exitTransition, spec.popExitTransition) + } +} diff --git a/nav-animation/src/test/java/com/composenavmotion/NavAnimationTest.kt b/nav-animation/src/test/java/com/composenavmotion/NavAnimationTest.kt index 3fa6cec..bdbbfab 100644 --- a/nav-animation/src/test/java/com/composenavmotion/NavAnimationTest.kt +++ b/nav-animation/src/test/java/com/composenavmotion/NavAnimationTest.kt @@ -8,6 +8,14 @@ import org.junit.Assert.assertNotNull import org.junit.Test class NavAnimationTest { + @Test fun directionAware_slidePreset_mapsForwardAndBackward() { + val spec = NavAnimation.directionAware( + forward = NavAnimation.slideLeft(), + backward = NavAnimation.slideRight(), + ) + assertNotNull(spec.popExitTransition) + } + @Test fun custom_mixedAnimation_buildsSpec() { val spec = NavAnimation.custom( enter = { slideInVertically(animationSpec = tweenSpec()) },