diff --git a/.editorconfig b/.editorconfig index 6c67144..d753453 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,3 @@ [*.{kt,kts}] +ktlint_code_style = ktlint_official ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file diff --git a/README.md b/README.md index 82c3399..f8a5162 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,9 @@ val router = routing( ## Compose Routing (compose module) +> This module is just for study or simple compose application. +> I recommend use Voyager module for more robust application. + Are you using Jetpack or Multiplatform Compose Runtime only? This module is for you. Easily route any composable you have just doing: @@ -191,6 +194,9 @@ Easily route any composable you have just doing: val routing = routing { composable(path = "/login") { // Your composable or any compose behavior here + call.popped // True if it was popped + val result = call.popResult() // To get the pop result after pop one composable + val typedValue = call.resource() // To get the type-safe navigated value } } @@ -203,10 +209,15 @@ fun MyComposeApp() { // And in any place that have the routing instance call: routing.call(uri = "/login") + +val lastPoppedCall = routing.poppedCall() // The call that was popped after call `routing.pop()` +val result = lastPoppedCall?.popResult() // To get the result after call `routing.pop(result = T)` ``` ## Compose Animation (compose animation module) +> This module is just for study or simple compose application. +> I recommend use Voyager module for more robust application. > At the moment Compose Animation has limited targets and is not available to all routing targets Are you using Jetpack or Multiplatform Compose that requires animation? This module is for you. @@ -223,6 +234,7 @@ val routing = routing { popExitTransition = {...}, ) { // Your composable or any compose behavior here + call.animatedVisibilityScope // If you need do something during animation } } @@ -230,8 +242,8 @@ val routing = routing { fun MyComposeApp() { Routing( routing = routing, - enterTransition = {...}, // on enter new composable in forward direction - exitTransition = {...}, // on exit previous composable in forward direction + enterTransition = {...}, // on enter next composable in forward direction + exitTransition = {...}, // on exit current composable in forward direction popEnterTransition = {...}, // on enter previous composable in backward direction popExitTransition = {...}, // on exit current composable in backward direction ) { diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimationScope.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimationScope.kt new file mode 100644 index 0000000..d0bbe0f --- /dev/null +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimationScope.kt @@ -0,0 +1,18 @@ +package dev.programadorthi.routing.compose.animation + +import androidx.compose.animation.AnimatedVisibilityScope +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey + +private val ComposeRoutingAnimationScopeAttributeKey: AttributeKey = + AttributeKey("ComposeRoutingAnimationScopeAttributeKey") + +public var ApplicationCall.animatedVisibilityScope: AnimatedVisibilityScope? + get() = attributes.getOrNull(ComposeRoutingAnimationScopeAttributeKey) + internal set(value) { + if (value != null) { + attributes.put(ComposeRoutingAnimationScopeAttributeKey, value) + } else { + attributes.remove(ComposeRoutingAnimationScopeAttributeKey) + } + } diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt index add2551..d2625b7 100644 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt @@ -1,9 +1,7 @@ package dev.programadorthi.routing.compose.animation import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween @@ -14,8 +12,8 @@ import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember -import dev.programadorthi.routing.compose.CurrentContent import dev.programadorthi.routing.compose.Routing +import dev.programadorthi.routing.compose.content import dev.programadorthi.routing.compose.popped import dev.programadorthi.routing.core.Route import dev.programadorthi.routing.core.Routing @@ -41,34 +39,38 @@ public fun Routing( popEnterTransition: Animation = enterTransition, popExitTransition: Animation = exitTransition, initial: ComposeAnimatedContent, - content: ComposeAnimatedContent = { CurrentContent() }, + content: ComposeAnimatedContent = { call -> + call.content(call) + }, ) { - val routingUri = - remember(routing) { - routing.toString() - } - Routing( routing = routing, - initial = { }, - ) { stateCall -> + initial = { call -> + call.animatedVisibilityScope?.initial(call) + }, + ) { call -> AnimatedContent( - targetState = stateCall, + targetState = call, transitionSpec = { - transitionSpec( - scope = this, - enterTransition = enterTransition, - exitTransition = exitTransition, - popEnterTransition = popEnterTransition, - popExitTransition = popExitTransition, - ) + val previousCall = initialState + val nextCall = targetState + val enter = + when { + previousCall.popped -> nextCall.popEnterTransition ?: popEnterTransition + else -> nextCall.enterTransition ?: enterTransition + } + val exit = + when { + previousCall.popped -> previousCall.popExitTransition ?: popExitTransition + else -> previousCall.exitTransition ?: exitTransition + } + + enter(this) togetherWith exit(this) }, - content = { call -> - if (call.uri == routingUri) { - initial(call) - } else { - content(call) - } + content = { animatedCall -> + animatedCall.animatedVisibilityScope = this + + content(animatedCall) }, ) } @@ -92,7 +94,7 @@ public fun Routing( popExitTransition: Animation = exitTransition, configuration: Route.() -> Unit, initial: ComposeAnimatedContent, - content: ComposeAnimatedContent = { CurrentContent() }, + content: ComposeAnimatedContent = { call -> call.content(call) }, ) { val routing = remember { @@ -122,25 +124,3 @@ public fun Routing( content = content, ) } - -private fun transitionSpec( - scope: AnimatedContentTransitionScope, - enterTransition: Animation, - exitTransition: Animation, - popEnterTransition: Animation = enterTransition, - popExitTransition: Animation = exitTransition, -): ContentTransform = - with(scope) { - val previousEntry = initialState - val nextEntry = targetState - - if (previousEntry.popped) { - val enter = nextEntry.popEnterTransition ?: popEnterTransition - val exit = previousEntry.popExitTransition ?: popExitTransition - return@with enter(this).togetherWith(exit(this)) - } - - val enter = nextEntry.enterTransition ?: enterTransition - val exit = previousEntry.exitTransition ?: exitTransition - return@with enter(this).togetherWith(exit(this)) - } diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt index 4a484e4..b6e66f6 100644 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt @@ -3,7 +3,6 @@ package dev.programadorthi.routing.compose.animation import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable -import dev.programadorthi.routing.compose.ComposeContent import dev.programadorthi.routing.compose.composable import dev.programadorthi.routing.core.Route import dev.programadorthi.routing.core.RouteMethod @@ -73,9 +72,8 @@ public fun Route.composable( popEnterTransition = popEnterTransition, popExitTransition = popExitTransition, routing = routing, - ) { - body() - } + bodyComposable = body, + ) } } @@ -85,7 +83,7 @@ public inline fun Route.composable( noinline exitTransition: Animation? = null, noinline popEnterTransition: Animation? = enterTransition, noinline popExitTransition: Animation? = exitTransition, - noinline body: @Composable PipelineContext.(T) -> Unit, + crossinline body: @Composable PipelineContext.(T) -> Unit, ): Route { val routing = asRouting ?: error("Your route $this must have a parent Routing") return handle { resource -> @@ -108,7 +106,7 @@ public inline fun Route.composable( noinline exitTransition: Animation? = null, noinline popEnterTransition: Animation? = enterTransition, noinline popExitTransition: Animation? = exitTransition, - noinline body: @Composable PipelineContext.(T) -> Unit, + crossinline body: @Composable PipelineContext.(T) -> Unit, ): Route { val routing = asRouting ?: error("Your route $this must have a parent Routing") return handle(method = method) { resource -> @@ -132,7 +130,7 @@ public fun PipelineContext.composable( exitTransition: Animation? = null, popEnterTransition: Animation? = enterTransition, popExitTransition: Animation? = exitTransition, - body: ComposeContent, + bodyComposable: @Composable PipelineContext.() -> Unit, ) { call.enterTransition = enterTransition call.exitTransition = exitTransition @@ -142,6 +140,8 @@ public fun PipelineContext.composable( composable( routing = routing, resource = resource, - body = body, + body = { + bodyComposable() + }, ) } diff --git a/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt b/compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationRoutingTest.kt similarity index 67% rename from compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt rename to compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationRoutingTest.kt index d8d248d..668805a 100644 --- a/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt +++ b/compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationRoutingTest.kt @@ -1,10 +1,11 @@ -package dev.programadorthi.routing.compose +package dev.programadorthi.routing.compose.animation import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.ui.test.junit4.createComposeRule -import dev.programadorthi.routing.compose.animation.Routing -import dev.programadorthi.routing.compose.animation.composable +import dev.programadorthi.routing.compose.pop +import dev.programadorthi.routing.compose.popped +import dev.programadorthi.routing.compose.poppedCall import dev.programadorthi.routing.core.RouteMethod import dev.programadorthi.routing.core.application.ApplicationCall import dev.programadorthi.routing.core.push @@ -202,6 +203,7 @@ internal class ComposeAnimationRoutingTest { routing(parentCoroutineContext = coroutineContext + job) { composable( path = "/path", + method = RouteMethod.Push, enterTransition = { previous = initialState next = targetState @@ -243,4 +245,118 @@ internal class ComposeAnimationRoutingTest { assertEquals(RouteMethod.Push, exitPrevious?.routeMethod) assertEquals(Parameters.Empty, exitPrevious?.parameters) } + + @Test + fun shouldInitialContentBeCalledWithTransitions() = + runTest { + // GIVEN + val job = Job() + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null + var initialContent = "" + + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + composable(path = "/path") { + } + } + + // WHEN + rule.setContent { + Routing( + routing = routing, + initial = { + initialContent = "this is the initial content" + }, + enterTransition = { + previous = initialState + next = targetState + fadeIn() + }, + exitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() + }, + ) + } + + rule.mainClock.advanceTimeBy(0L) // Ask for recomposition + + // THEN + assertEquals("this is the initial content", initialContent) + assertEquals(previous, exitNext) + assertEquals(next, exitPrevious) + } + + @Test + fun shouldInitialContentNotBeCalledWithTransitionsInASecondTime() = + runTest { + // GIVEN + val job = Job() + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null + var initialContentCount = 0 + + val routing = + routing(parentCoroutineContext = coroutineContext + job) { + composable(path = "/path") { + } + } + + // WHEN + rule.setContent { + Routing( + routing = routing, + initial = { + initialContentCount += 1 + }, + enterTransition = { + previous = initialState + next = targetState + fadeIn() + }, + exitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() + }, + ) + } + + // Render initial content + rule.mainClock.advanceTimeBy(0L) // Ask for recomposition + + // Go to other composition + routing.push(path = "/path") + advanceTimeBy(99) // Ask for routing + rule.mainClock.advanceTimeBy(0L) // Ask for recomposition + + // Back to initial content + routing.pop() + rule.mainClock.advanceTimeBy(0L) // Ask for recomposition + + // THEN + assertEquals(3, initialContentCount) + assertEquals(previous, exitPrevious) + assertEquals(next, exitNext) + assertEquals("/path", "${previous?.uri}") + assertEquals("", "${previous?.name}") + assertEquals(RouteMethod.Push, previous?.routeMethod) + assertEquals(Parameters.Empty, previous?.parameters) + assertEquals("/", "${next?.uri}") + assertEquals("", "${next?.name}") + assertEquals(RouteMethod.Empty, next?.routeMethod) + assertEquals(Parameters.Empty, next?.parameters) + assertEquals(true, previous?.popped, "Previous call should be popped") + assertEquals( + routing.poppedCall(), + previous, + "Previous call should be equals to popped call", + ) + } } diff --git a/compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationsTest.kt b/compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationsTest.kt new file mode 100644 index 0000000..09be316 --- /dev/null +++ b/compose-animation/jvm/test/dev/programadorthi/routing/compose/animation/ComposeAnimationsTest.kt @@ -0,0 +1,208 @@ +package dev.programadorthi.routing.compose.animation + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import dev.programadorthi.routing.core.application +import dev.programadorthi.routing.core.application.ApplicationCall +import dev.programadorthi.routing.core.routing +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class ComposeAnimationsTest { + private val routing = routing { } + + @Test + fun shouldApplicationCallHaveNullEnterTransitionOnYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + val enterTransition = applicationCall.enterTransition + + // THEN + assertNull(enterTransition, "Application call should never starts with an enter transition") + } + + @Test + fun shouldApplicationCallPutAnEnterTransitionToYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.enterTransition = { fadeIn() } + val enterTransition = applicationCall.enterTransition + + // THEN + assertNotNull( + enterTransition, + "Application call should put an enter transition to your attributes", + ) + } + + @Test + fun shouldApplicationCallRemoveAnEnterTransitionFromYourAttributesWhenTransitionIsNull() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.enterTransition = { fadeIn() } + val putEnterTransition = applicationCall.enterTransition + applicationCall.enterTransition = null + val removedEnterTransition = applicationCall.enterTransition + + // THEN + assertNotNull(putEnterTransition, "Application call enter transition should be registered") + assertNull(removedEnterTransition, "Application call enter transition should be removed") + } + + @Test + fun shouldApplicationCallHaveNullExitTransitionOnYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + val exitTransition = applicationCall.exitTransition + + // THEN + assertNull(exitTransition, "Application call should never starts with an exit transition") + } + + @Test + fun shouldApplicationCallPutAnExitTransitionToYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.exitTransition = { fadeOut() } + val exitTransition = applicationCall.exitTransition + + // THEN + assertNotNull( + exitTransition, + "Application call should put an exit transition to your attributes", + ) + } + + @Test + fun shouldApplicationCallRemoveAnExitTransitionFromYourAttributesWhenTransitionIsNull() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.exitTransition = { fadeOut() } + val putExitTransition = applicationCall.exitTransition + applicationCall.exitTransition = null + val removedExitTransition = applicationCall.exitTransition + + // THEN + assertNotNull(putExitTransition, "Application call exit transition should be registered") + assertNull(removedExitTransition, "Application call exit transition should be removed") + } + + @Test + fun shouldApplicationCallHaveNullPopEnterTransitionOnYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + val popEnterTransition = applicationCall.popEnterTransition + + // THEN + assertNull( + popEnterTransition, + "Application call should never starts with a pop enter transition", + ) + } + + @Test + fun shouldApplicationCallPutAPopEnterTransitionToYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.popEnterTransition = { fadeIn() } + val popEnterTransition = applicationCall.popEnterTransition + + // THEN + assertNotNull( + popEnterTransition, + "Application call should put a pop enter transition to your attributes", + ) + } + + @Test + fun shouldApplicationCallRemoveAPopEnterTransitionFromYourAttributesWhenTransitionIsNull() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.popEnterTransition = { fadeIn() } + val putPopEnterTransition = applicationCall.popEnterTransition + applicationCall.popEnterTransition = null + val removedPopEnterTransition = applicationCall.popEnterTransition + + // THEN + assertNotNull( + putPopEnterTransition, + "Application call pop enter transition should be registered", + ) + assertNull( + removedPopEnterTransition, + "Application call pop enter transition should be removed", + ) + } + + @Test + fun shouldApplicationCallHaveNullPopExitTransitionOnYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + val popExitTransition = applicationCall.popExitTransition + + // THEN + assertNull( + popExitTransition, + "Application call should never starts with a pop exit transition", + ) + } + + @Test + fun shouldApplicationCallPutAPopExitTransitionToYourAttributes() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.popExitTransition = { fadeOut() } + val popExitTransition = applicationCall.popExitTransition + + // THEN + assertNotNull( + popExitTransition, + "Application call should put a pop exit transition to your attributes", + ) + } + + @Test + fun shouldApplicationCallRemoveAPopExitTransitionFromYourAttributesWhenTransitionIsNull() { + // GIVEN + val applicationCall = ApplicationCall(application = routing.application, uri = "/path") + + // WHEN + applicationCall.popExitTransition = { fadeOut() } + val putPopExitTransition = applicationCall.popExitTransition + applicationCall.popExitTransition = null + val removedPopExitTransition = applicationCall.popExitTransition + + // THEN + assertNotNull( + putPopExitTransition, + "Application call pop exit transition should be registered", + ) + assertNull( + removedPopExitTransition, + "Application call pop exit transition should be removed", + ) + } +} diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt index de4c1f6..70c28bb 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt @@ -9,12 +9,8 @@ public typealias ComposeContent = @Composable (ApplicationCall) -> Unit private val ComposeRoutingContentAttributeKey: AttributeKey = AttributeKey("ComposeRoutingContentAttributeKey") -internal var ApplicationCall.content: ComposeContent? - get() = attributes.getOrNull(ComposeRoutingContentAttributeKey) - set(value) { - if (value != null) { - attributes.put(ComposeRoutingContentAttributeKey, value) - } else { - attributes.remove(ComposeRoutingContentAttributeKey) - } +public var ApplicationCall.content: ComposeContent + get() = attributes[ComposeRoutingContentAttributeKey] + internal set(value) { + attributes.put(ComposeRoutingContentAttributeKey, value) } diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt index 85a3471..ece0ca7 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt @@ -26,7 +26,7 @@ public val LocalRouting: ProvidableCompositionLocal = public fun CurrentContent() { val routing = LocalRouting.current val lastCall = routing.callStack.last() - lastCall.content?.invoke(lastCall) + lastCall.content(lastCall) } @Composable diff --git a/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt b/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt index d83e268..aab3f2f 100644 --- a/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt +++ b/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt @@ -315,6 +315,7 @@ internal class ComposeRoutingTest { clock.sendFrame(0L) // Ask for recomposition routing.pop() + advanceTimeBy(99) // Ask for routing clock.sendFrame(0L) // Ask for recomposition // THEN