diff --git a/README.md b/README.md index 5fabdcd..82c3399 100644 --- a/README.md +++ b/README.md @@ -205,11 +205,9 @@ fun MyComposeApp() { routing.call(uri = "/login") ``` -## Compose Animation Routing (compose animation module) +## Compose Animation (compose animation module) > At the moment Compose Animation has limited targets and is not available to all routing targets -> So, this module is a copy of `compose` module with animation support to specific targets -> Use one or other. NEVER BOTH! Are you using Jetpack or Multiplatform Compose that requires animation? This module is for you. Easily route any composable you have just doing: diff --git a/compose-animation/build.gradle.kts b/compose-animation/build.gradle.kts index 535bac7..b138b50 100644 --- a/compose-animation/build.gradle.kts +++ b/compose-animation/build.gradle.kts @@ -34,7 +34,7 @@ kotlin { sourceSets { commonMain { dependencies { - api(projects.resources) + api(projects.compose) implementation(compose.runtime) implementation(compose.animation) } diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeEntry.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeEntry.kt deleted file mode 100644 index eeb86c6..0000000 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeEntry.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.programadorthi.routing.compose - -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.runtime.Composable -import dev.programadorthi.routing.core.application.ApplicationCall -import kotlin.jvm.JvmSuppressWildcards - -internal typealias Animation = @JvmSuppressWildcards -AnimatedContentTransitionScope.() -> T - -public data class ComposeEntry( - val call: ApplicationCall, - internal val content: @Composable AnimatedContentScope.() -> Unit, -) { - @PublishedApi - internal var popResult: Any? = null - - @PublishedApi - internal var resource: Any? = null - - internal var enterTransition: Animation? = null - internal var exitTransition: Animation? = null - internal var popEnterTransition: Animation? = enterTransition - internal var popExitTransition: Animation? = exitTransition - - public var popped: Boolean = false - internal set - - public inline fun popResult(): T? = - when (popResult) { - is T -> popResult as T - else -> null - } - - public inline fun popResult(default: T): T = popResult() ?: default - - public inline fun popResult(default: () -> T): T = popResult() ?: default() - - public inline fun resource(): T? = - when (resource) { - is T -> resource as T - else -> null - } -} diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeManager.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeManager.kt deleted file mode 100644 index 7d7372a..0000000 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeManager.kt +++ /dev/null @@ -1,27 +0,0 @@ -package dev.programadorthi.routing.compose - -import androidx.compose.runtime.snapshots.SnapshotStateList -import dev.programadorthi.routing.core.Routing -import io.ktor.util.AttributeKey - -private val ComposeRoutingAttributeKey: AttributeKey> = - AttributeKey("ComposeRoutingAttributeKey") - -private val ComposeRoutingPoppedEntryAttributeKey: AttributeKey = - AttributeKey("ComposeRoutingPoppedEntryAttributeKey") - -internal var Routing.contentList: SnapshotStateList - get() = attributes[ComposeRoutingAttributeKey] - set(value) { - attributes.put(ComposeRoutingAttributeKey, value) - } - -internal var Routing.poppedEntry: ComposeEntry? - get() = attributes.getOrNull(ComposeRoutingPoppedEntryAttributeKey) - set(value) { - if (value != null) { - attributes.put(ComposeRoutingPoppedEntryAttributeKey, value) - } else { - attributes.remove(ComposeRoutingPoppedEntryAttributeKey) - } - } diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt deleted file mode 100644 index e1f3e2b..0000000 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.programadorthi.routing.compose - -import dev.programadorthi.routing.core.Routing - -public fun Routing.canPop(): Boolean = contentList.size > 1 - -public fun Routing.pop(result: Any? = null) { - if (!canPop()) return - - val last = contentList.removeLastOrNull() ?: return - last.popped = true - last.popResult = result - this.poppedEntry = last -} - -public fun Routing.poppedEntry(): ComposeEntry? = poppedEntry diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimations.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimations.kt new file mode 100644 index 0000000..b512b67 --- /dev/null +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeAnimations.kt @@ -0,0 +1,63 @@ +package dev.programadorthi.routing.compose.animation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey +import kotlin.jvm.JvmSuppressWildcards + +internal typealias Animation = @JvmSuppressWildcards +AnimatedContentTransitionScope.() -> T + +private val ComposeRoutingEnterTransitionAttributeKey: AttributeKey> = + AttributeKey("ComposeRoutingEnterTransitionAttributeKey") + +private val ComposeRoutingExitTransitionAttributeKey: AttributeKey> = + AttributeKey("ComposeRoutingExitTransitionAttributeKey") + +private val ComposeRoutingPopEnterTransitionAttributeKey: AttributeKey> = + AttributeKey("ComposeRoutingPopEnterTransitionAttributeKey") + +private val ComposeRoutingPopExitTransitionAttributeKey: AttributeKey> = + AttributeKey("ComposeRoutingPopExitTransitionAttributeKey") + +internal var ApplicationCall.enterTransition: Animation? + get() = attributes.getOrNull(ComposeRoutingEnterTransitionAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingEnterTransitionAttributeKey, value) + } else { + attributes.remove(ComposeRoutingEnterTransitionAttributeKey) + } + } + +internal var ApplicationCall.exitTransition: Animation? + get() = attributes.getOrNull(ComposeRoutingExitTransitionAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingExitTransitionAttributeKey, value) + } else { + attributes.remove(ComposeRoutingExitTransitionAttributeKey) + } + } + +internal var ApplicationCall.popEnterTransition: Animation? + get() = attributes.getOrNull(ComposeRoutingPopEnterTransitionAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingPopEnterTransitionAttributeKey, value) + } else { + attributes.remove(ComposeRoutingPopEnterTransitionAttributeKey) + } + } + +internal var ApplicationCall.popExitTransition: Animation? + get() = attributes.getOrNull(ComposeRoutingPopExitTransitionAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingPopExitTransitionAttributeKey, value) + } else { + attributes.remove(ComposeRoutingPopExitTransitionAttributeKey) + } + } diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt similarity index 70% rename from compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt rename to compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt index bb33fe5..add2551 100644 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRouting.kt @@ -1,8 +1,8 @@ -package dev.programadorthi.routing.compose +package dev.programadorthi.routing.compose.animation import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope 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 @@ -12,15 +12,13 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf +import dev.programadorthi.routing.compose.CurrentContent +import dev.programadorthi.routing.compose.Routing +import dev.programadorthi.routing.compose.popped import dev.programadorthi.routing.core.Route import dev.programadorthi.routing.core.Routing -import dev.programadorthi.routing.core.application import dev.programadorthi.routing.core.application.ApplicationCall import dev.programadorthi.routing.core.routing import io.ktor.util.logging.KtorSimpleLogger @@ -28,10 +26,7 @@ import io.ktor.util.logging.Logger import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -public val LocalRouting: ProvidableCompositionLocal = - staticCompositionLocalOf { - error("Composition local LocalRouting not found") - } +public typealias ComposeAnimatedContent = @Composable AnimatedVisibilityScope.(ApplicationCall) -> Unit @Composable public fun Routing( @@ -45,32 +40,20 @@ public fun Routing( }, popEnterTransition: Animation = enterTransition, popExitTransition: Animation = exitTransition, - initial: @Composable AnimatedContentScope.() -> Unit, + initial: ComposeAnimatedContent, + content: ComposeAnimatedContent = { CurrentContent() }, ) { - CompositionLocalProvider(LocalRouting provides routing) { - val stateList = - remember(routing) { - mutableStateListOf().apply { - routing.contentList = this - add( - ComposeEntry( - content = initial, - call = - ApplicationCall( - application = routing.application, - uri = routing.toString(), - ), - ).apply { - this.enterTransition = enterTransition - this.exitTransition = exitTransition - this.popEnterTransition = popEnterTransition - this.popExitTransition = popExitTransition - }, - ) - } - } + val routingUri = + remember(routing) { + routing.toString() + } + + Routing( + routing = routing, + initial = { }, + ) { stateCall -> AnimatedContent( - targetState = stateList.last(), + targetState = stateCall, transitionSpec = { transitionSpec( scope = this, @@ -80,8 +63,12 @@ public fun Routing( popExitTransition = popExitTransition, ) }, - content = { entry -> - entry.content(this) + content = { call -> + if (call.uri == routingUri) { + initial(call) + } else { + content(call) + } }, ) } @@ -104,7 +91,8 @@ public fun Routing( popEnterTransition: Animation = enterTransition, popExitTransition: Animation = exitTransition, configuration: Route.() -> Unit, - initial: @Composable AnimatedContentScope.() -> Unit, + initial: ComposeAnimatedContent, + content: ComposeAnimatedContent = { CurrentContent() }, ) { val routing = remember { @@ -131,11 +119,12 @@ public fun Routing( popEnterTransition = popEnterTransition, popExitTransition = popExitTransition, initial = initial, + content = content, ) } private fun transitionSpec( - scope: AnimatedContentTransitionScope, + scope: AnimatedContentTransitionScope, enterTransition: Animation, exitTransition: Animation, popEnterTransition: Animation = enterTransition, diff --git a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt similarity index 81% rename from compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt rename to compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt index f8765ea..4a484e4 100644 --- a/compose-animation/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt +++ b/compose-animation/common/src/dev/programadorthi/routing/compose/animation/ComposeRoutingBuilder.kt @@ -1,9 +1,10 @@ -package dev.programadorthi.routing.compose +package dev.programadorthi.routing.compose.animation -import androidx.compose.animation.AnimatedContentScope 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 import dev.programadorthi.routing.core.Routing @@ -131,35 +132,16 @@ public fun PipelineContext.composable( exitTransition: Animation? = null, popEnterTransition: Animation? = enterTransition, popExitTransition: Animation? = exitTransition, - body: @Composable AnimatedContentScope.() -> Unit, + body: ComposeContent, ) { - routing.poppedEntry = null // Removing last popped entry + call.enterTransition = enterTransition + call.exitTransition = exitTransition + call.popEnterTransition = popEnterTransition + call.popExitTransition = popExitTransition - val stateList = routing.contentList - val composeEntry = - ComposeEntry( - call = call, - content = body, - ).apply { - this.resource = resource - this.enterTransition = enterTransition - this.exitTransition = exitTransition - this.popEnterTransition = popEnterTransition - this.popExitTransition = popExitTransition - } - - when (call.routeMethod) { - RouteMethod.Push -> stateList.add(composeEntry) - RouteMethod.Replace -> stateList[stateList.lastIndex.coerceAtLeast(0)] = composeEntry - RouteMethod.ReplaceAll -> { - stateList.clear() - stateList.add(composeEntry) - } - - else -> - error( - "Compose needs a stack route method to work. You called a composable ${call.uri} " + - "using route method ${call.routeMethod} that is not supported", - ) - } + composable( + routing = routing, + resource = resource, + body = body, + ) } diff --git a/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt b/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt index 214fcac..d8d248d 100644 --- a/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt +++ b/compose-animation/jvm/test/dev/programadorthi/routing/compose/ComposeAnimationRoutingTest.kt @@ -1,15 +1,13 @@ package dev.programadorthi.routing.compose +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.ui.test.junit4.createComposeRule -import dev.programadorthi.routing.compose.helper.FakeContent +import dev.programadorthi.routing.compose.animation.Routing +import dev.programadorthi.routing.compose.animation.composable import dev.programadorthi.routing.core.RouteMethod import dev.programadorthi.routing.core.application.ApplicationCall -import dev.programadorthi.routing.core.application.call -import dev.programadorthi.routing.core.call import dev.programadorthi.routing.core.push -import dev.programadorthi.routing.core.replace -import dev.programadorthi.routing.core.replaceAll -import dev.programadorthi.routing.core.route import dev.programadorthi.routing.core.routing import io.ktor.http.Parameters import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,8 +17,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) internal class ComposeAnimationRoutingTest { @@ -28,385 +24,223 @@ internal class ComposeAnimationRoutingTest { val rule = createComposeRule() @Test - fun shouldInvokeInitialContentWhenThereIsNoEmittedComposable() { - // GIVEN - val routing = routing {} - val fakeContent = FakeContent() - - // WHEN - rule.setContent { - Routing( - routing = routing, - initial = { - fakeContent.content = "I'm the initial content" - fakeContent.Composable() - }, - ) - } - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - // THEN - assertEquals("I'm the initial content", fakeContent.result) - } - - @Test - fun shouldComposeByPath() = + fun shouldAnimateUsingGlobalEnterAndExitTransitions() = runTest { // GIVEN val job = Job() - val fakeContent = FakeContent() + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null val routing = routing(parentCoroutineContext = coroutineContext + job) { composable(path = "/path") { - fakeContent.content = "I'm the path based content" - fakeContent.Composable() } } - rule.setContent { - Routing( - routing = routing, - initial = { - fakeContent.content = "I'm the initial content" - fakeContent.Composable() - }, - ) - } - // WHEN - routing.call(uri = "/path", routeMethod = RouteMethod.Push) - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - // THEN - assertEquals("I'm the path based content", fakeContent.result) - } - - @Test - fun shouldComposeByName() = - runTest { - // GIVEN - val job = Job() - val fakeContent = FakeContent() - - val routing = - routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/path", name = "path") { - fakeContent.content = "I'm the name based content" - fakeContent.Composable() - } - } - rule.setContent { Routing( routing = routing, - initial = { - fakeContent.content = "I'm the initial content" - fakeContent.Composable() + initial = { }, + enterTransition = { + previous = initialState + next = targetState + fadeIn() }, - ) - } - - // WHEN - routing.call(name = "path", routeMethod = RouteMethod.Push) - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - // THEN - assertEquals("I'm the name based content", fakeContent.result) - } - - @Test - fun shouldComposeByAnyRoute() = - runTest { - // GIVEN - val job = Job() - val fakeContent = FakeContent() - - val routing = - routing(parentCoroutineContext = coroutineContext + job) { - route(path = "/any") { - composable { - fakeContent.content = "I'm the generic based content" - fakeContent.Composable() - } - } - } - - rule.setContent { - Routing( - routing = routing, - initial = { - fakeContent.content = "I'm the initial content" - fakeContent.Composable() + exitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() }, ) } - // WHEN - routing.call(uri = "/any", routeMethod = RouteMethod.Push) + routing.push(path = "/path") advanceTimeBy(99) // Ask for routing rule.mainClock.advanceTimeBy(0L) // Ask for recomposition // THEN - assertEquals("I'm the generic based content", fakeContent.result) + assertEquals(previous, exitPrevious) + assertEquals(next, exitNext) + assertEquals("/", "${previous?.uri}") + assertEquals("", "${previous?.name}") + assertEquals(RouteMethod.Empty, previous?.routeMethod) + assertEquals(Parameters.Empty, previous?.parameters) + assertEquals("/path", "${next?.uri}") + assertEquals("", "${next?.name}") + assertEquals(RouteMethod.Push, next?.routeMethod) + assertEquals(Parameters.Empty, next?.parameters) } @Test - fun shouldPushAComposable() = + fun shouldAnimateUsingGlobalPopEnterAndPopExitTransitions() = runTest { // GIVEN val job = Job() - val fakeContent = FakeContent() - var result: ApplicationCall? = null + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null val routing = routing(parentCoroutineContext = coroutineContext + job) { composable(path = "/path") { - result = call - fakeContent.content = "I'm the push based content" - fakeContent.Composable() } } - rule.setContent { - Routing( - routing = routing, - initial = { - fakeContent.content = "I'm the initial content" - fakeContent.Composable() - }, - ) - } - // WHEN - routing.push(path = "/path") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - // THEN - assertNotNull(result) - assertEquals("I'm the push based content", fakeContent.result) - assertEquals("/path", "${result?.uri}") - assertEquals("", "${result?.name}") - assertEquals(RouteMethod.Push, result?.routeMethod) - assertEquals(Parameters.Empty, result?.parameters) - } - - @Test - fun shouldReplaceAComposable() = - runTest { - // GIVEN - val job = Job() - val pushContent = FakeContent() - val replaceContent = FakeContent() - var result: ApplicationCall? = null - - val routing = - routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/push") { - pushContent.content = "I'm the push based content" - pushContent.Composable() - } - composable(path = "/replace") { - result = call - replaceContent.content = "I'm the replace based content" - replaceContent.Composable() - } - } - rule.setContent { Routing( routing = routing, - initial = { - replaceContent.content = "I'm the initial content" - replaceContent.Composable() + initial = { }, + popEnterTransition = { + previous = initialState + next = targetState + fadeIn() }, - ) - } - - // WHEN - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - routing.replace(path = "/replace") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - // THEN - assertNotNull(result) - assertEquals("I'm the push based content", pushContent.result) - assertEquals("I'm the replace based content", replaceContent.result) - assertEquals("/replace", "${result?.uri}") - assertEquals("", "${result?.name}") - assertEquals(RouteMethod.Replace, result?.routeMethod) - assertEquals(Parameters.Empty, result?.parameters) - } - - @Test - fun shouldReplaceAllComposable() = - runTest { - // GIVEN - val job = Job() - val pushContent = FakeContent() - val replaceContent = FakeContent() - var result: ApplicationCall? = null - - val routing = - routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/push") { - pushContent.content = "I'm the push based content" - pushContent.Composable() - } - composable(path = "/replace") { - result = call - replaceContent.content = "I'm the replace all based content" - replaceContent.Composable() - } - } - - rule.setContent { - Routing( - routing = routing, - initial = { - replaceContent.content = "I'm the initial content" - replaceContent.Composable() + popExitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() }, ) } - // WHEN - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - routing.replaceAll(path = "/replace") + routing.push(path = "/path") advanceTimeBy(99) // Ask for routing rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - // THEN - assertNotNull(result) - assertEquals("I'm the push based content", pushContent.result) - assertEquals("I'm the replace all based content", replaceContent.result) - assertEquals("/replace", "${result?.uri}") - assertEquals("", "${result?.name}") - assertEquals(RouteMethod.ReplaceAll, result?.routeMethod) - assertEquals(Parameters.Empty, result?.parameters) - } - - @Test - fun shouldPopAComposable() = - runTest { - // GIVEN - val job = Job() - var composedCounter = 0 - - val routing = - routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/push") { - composedCounter += 1 - } - } - - rule.setContent { - Routing( - routing = routing, - initial = {}, - ) - } - - // WHEN - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(99L) // Ask for recomposition - - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(99L) // Ask for recomposition - routing.pop() - advanceTimeBy(99) // Ask for routing rule.mainClock.advanceTimeBy(0L) // Ask for recomposition // THEN - assertEquals(5, composedCounter) + 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", + ) } @Test - fun shouldPopAComposableWithResult() = + fun shouldAnimateUsingLocalEnterTransitions() = runTest { // GIVEN val job = Job() - var poppedMessage: String? = null + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null val routing = routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/push") { - poppedMessage = LocalRouting.current.poppedEntry()?.popResult() + composable( + path = "/path", + enterTransition = { + previous = initialState + next = targetState + fadeIn() + }, + ) { } } + // WHEN rule.setContent { Routing( routing = routing, - initial = {}, + initial = { }, + // Initial screen always uses global enter and exit transition + exitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() + }, ) } - // WHEN - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - routing.pop(result = "This is the popped message") + routing.push(path = "/path") advanceTimeBy(99) // Ask for routing rule.mainClock.advanceTimeBy(0L) // Ask for recomposition // THEN - assertEquals("This is the popped message", poppedMessage) + assertEquals(previous, exitPrevious) + assertEquals(next, exitNext) + assertEquals("/", "${previous?.uri}") + assertEquals("", "${previous?.name}") + assertEquals(RouteMethod.Empty, previous?.routeMethod) + assertEquals(Parameters.Empty, previous?.parameters) + assertEquals("/path", "${next?.uri}") + assertEquals("", "${next?.name}") + assertEquals(RouteMethod.Push, next?.routeMethod) + assertEquals(Parameters.Empty, next?.parameters) } @Test - fun shouldPopResultBeNullAfterANewRouting() = + fun shouldAnimateUsingLocalEnterAndExitTransitions() = runTest { // GIVEN val job = Job() - var poppedMessage: String? = null + var previous: ApplicationCall? = null + var next: ApplicationCall? = null + var exitPrevious: ApplicationCall? = null + var exitNext: ApplicationCall? = null val routing = routing(parentCoroutineContext = coroutineContext + job) { - composable(path = "/push") { - poppedMessage = LocalRouting.current.poppedEntry()?.popResult() + composable( + path = "/path", + enterTransition = { + previous = initialState + next = targetState + fadeIn() + }, + exitTransition = { + exitPrevious = initialState + exitNext = targetState + fadeOut() + }, + ) { } } + // WHEN rule.setContent { Routing( routing = routing, - initial = {}, + initial = { }, ) } - // WHEN - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing - rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - - routing.pop(result = "This is the popped message") + routing.push(path = "/path") advanceTimeBy(99) // Ask for routing rule.mainClock.advanceTimeBy(0L) // Ask for recomposition - routing.push(path = "/push") - advanceTimeBy(99) // Ask for routing + routing.pop() rule.mainClock.advanceTimeBy(0L) // Ask for recomposition // THEN - assertNull(poppedMessage, "Pop result should be cleared after other routing call") + assertEquals(previous, exitNext) + assertEquals(next, exitPrevious) + assertEquals("/", "${exitNext?.uri}") + assertEquals("", "${exitNext?.name}") + assertEquals(RouteMethod.Empty, exitNext?.routeMethod) + assertEquals(Parameters.Empty, exitNext?.parameters) + assertEquals("/path", "${exitPrevious?.uri}") + assertEquals("", "${exitPrevious?.name}") + assertEquals(RouteMethod.Push, exitPrevious?.routeMethod) + assertEquals(Parameters.Empty, exitPrevious?.parameters) } } diff --git a/compose-animation/jvm/test/dev/programadorthi/routing/compose/helper/FakeContent.kt b/compose-animation/jvm/test/dev/programadorthi/routing/compose/helper/FakeContent.kt deleted file mode 100644 index 31bc982..0000000 --- a/compose-animation/jvm/test/dev/programadorthi/routing/compose/helper/FakeContent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.programadorthi.routing.compose.helper - -import androidx.compose.runtime.Composable - -internal class FakeContent { - var content = "" - - var result = "" - private set - - @Composable - fun Composable() { - result = content - } -} diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt new file mode 100644 index 0000000..de4c1f6 --- /dev/null +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeContent.kt @@ -0,0 +1,20 @@ +package dev.programadorthi.routing.compose + +import androidx.compose.runtime.Composable +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey + +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) + } + } diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeManager.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeManager.kt index 4c408ac..a07517a 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeManager.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeManager.kt @@ -1,30 +1,15 @@ package dev.programadorthi.routing.compose -import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshots.SnapshotStateList import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application.ApplicationCall import io.ktor.util.AttributeKey -public typealias Content = @Composable () -> Unit - -private val ComposeRoutingAttributeKey: AttributeKey> = +private val ComposeRoutingAttributeKey: AttributeKey> = AttributeKey("ComposeRoutingAttributeKey") -private val ComposeRoutingPopResultAttributeKey: AttributeKey = - AttributeKey("ComposeRoutingPopResultAttributeKey") - -internal var Routing.contentList: SnapshotStateList +internal var Routing.callStack: SnapshotStateList get() = attributes[ComposeRoutingAttributeKey] set(value) { attributes.put(ComposeRoutingAttributeKey, value) } - -internal var Routing.popResult: Any? - get() = attributes.getOrNull(ComposeRoutingPopResultAttributeKey) - set(value) { - if (value != null) { - attributes.put(ComposeRoutingPopResultAttributeKey, value) - } else { - attributes.remove(ComposeRoutingPopResultAttributeKey) - } - } diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposePopResult.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposePopResult.kt new file mode 100644 index 0000000..2c85708 --- /dev/null +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposePopResult.kt @@ -0,0 +1,54 @@ +package dev.programadorthi.routing.compose + +import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey + +private val ComposeRoutingPopResultAttributeKey: AttributeKey = + AttributeKey("ComposeRoutingPopResultAttributeKey") + +private val ComposeRoutingPoppedApplicationCallAttributeKey: AttributeKey = + AttributeKey("ComposeRoutingPoppedApplicationCallAttributeKey") + +private val ComposeRoutingPoppedFlagCallAttributeKey: AttributeKey = + AttributeKey("ComposeRoutingPoppedFlagCallAttributeKey") + +@PublishedApi +internal var ApplicationCall.popResult: Any? + get() = attributes.getOrNull(ComposeRoutingPopResultAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingPopResultAttributeKey, value) + } else { + attributes.remove(ComposeRoutingPopResultAttributeKey) + } + } + +@PublishedApi +internal var Routing.poppedCall: ApplicationCall? + get() = attributes.getOrNull(ComposeRoutingPoppedApplicationCallAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingPoppedApplicationCallAttributeKey, value) + } else { + attributes.remove(ComposeRoutingPoppedApplicationCallAttributeKey) + } + } + +public var ApplicationCall.popped: Boolean + get() = attributes.getOrNull(ComposeRoutingPoppedFlagCallAttributeKey) ?: false + internal set(value) { + attributes.put(ComposeRoutingPoppedFlagCallAttributeKey, value) + } + +public inline fun ApplicationCall.popResult(): T? = + when (popResult) { + is T -> popResult as T + else -> null + } + +public inline fun ApplicationCall.popResult(default: T): T = popResult() ?: default + +public inline fun ApplicationCall.popResult(default: () -> T): T = popResult() ?: default() + +public fun Routing.poppedCall(): ApplicationCall? = poppedCall diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeResource.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeResource.kt new file mode 100644 index 0000000..302f426 --- /dev/null +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeResource.kt @@ -0,0 +1,24 @@ +package dev.programadorthi.routing.compose + +import dev.programadorthi.routing.core.application.ApplicationCall +import io.ktor.util.AttributeKey + +private val ComposeRoutingResourceAttributeKey: AttributeKey = + AttributeKey("ComposeRoutingResourceAttributeKey") + +@PublishedApi +internal var ApplicationCall.resource: Any? + get() = attributes.getOrNull(ComposeRoutingResourceAttributeKey) + set(value) { + if (value != null) { + attributes.put(ComposeRoutingResourceAttributeKey, value) + } else { + attributes.remove(ComposeRoutingResourceAttributeKey) + } + } + +public inline fun ApplicationCall.resource(): T? = + when (resource) { + is T -> resource as T + else -> null + } diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt index a8c4567..85a3471 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeRouting.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import dev.programadorthi.routing.core.Route import dev.programadorthi.routing.core.Routing +import dev.programadorthi.routing.core.application +import dev.programadorthi.routing.core.application.ApplicationCall import dev.programadorthi.routing.core.routing import io.ktor.util.logging.KtorSimpleLogger import io.ktor.util.logging.Logger @@ -20,19 +22,34 @@ public val LocalRouting: ProvidableCompositionLocal = error("Composition local LocalRouting not found") } +@Composable +public fun CurrentContent() { + val routing = LocalRouting.current + val lastCall = routing.callStack.last() + lastCall.content?.invoke(lastCall) +} + @Composable public fun Routing( routing: Routing, - initial: Content, + initial: ComposeContent, + content: ComposeContent = { CurrentContent() }, ) { CompositionLocalProvider(LocalRouting provides routing) { - val stateList = + val router = remember(routing) { - mutableStateListOf().apply { - routing.contentList = this - } + val stack = mutableStateListOf() + val call = + ApplicationCall( + application = routing.application, + uri = routing.toString(), + ) + call.content = initial + stack += call + routing.callStack = stack + routing } - stateList.lastOrNull()?.invoke() ?: initial() + content(router.callStack.last()) } } @@ -44,7 +61,8 @@ public fun Routing( log: Logger = KtorSimpleLogger("kotlin-routing"), developmentMode: Boolean = false, configuration: Route.() -> Unit, - initial: Content, + initial: ComposeContent, + content: ComposeContent = { CurrentContent() }, ) { val routing = remember { @@ -67,5 +85,6 @@ public fun Routing( Routing( routing = routing, initial = initial, + content = content, ) } diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt index 678ae4a..671a296 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingBuilder.kt @@ -31,7 +31,7 @@ public fun Route.composable( public fun Route.composable(body: @Composable PipelineContext.() -> Unit) { val routing = asRouting ?: error("Your route $this must have a parent Routing") handle { - composable(routing) { + composable(routing = routing, resource = null) { body() } } @@ -41,7 +41,7 @@ public fun Route.composable(body: @Composable PipelineContext Route.composable(noinline body: @Composable PipelineContext.(T) -> Unit): Route { val routing = asRouting ?: error("Your route $this must have a parent Routing") return handle { resource -> - composable(routing) { + composable(routing = routing, resource = resource) { body(resource) } } @@ -53,27 +53,34 @@ public inline fun Route.composable( ): Route { val routing = asRouting ?: error("Your route $this must have a parent Routing") return handle(method = method) { resource -> - composable(routing) { + composable(routing = routing, resource = resource) { body(resource) } } } -public fun PipelineContext.composable( +public fun PipelineContext.composable( routing: Routing, - body: @Composable () -> Unit, + resource: T?, + body: ComposeContent, ) { - routing.popResult = null // Clear pop result after each new routing call - val stateList = routing.contentList + routing.poppedCall = null // Clear pop call after each new routing call + + call.popped = false + call.resource = resource + call.content = body + + val callStack = routing.callStack when (call.routeMethod) { - RouteMethod.Push -> stateList.add(body) + RouteMethod.Push -> callStack.add(call) RouteMethod.Replace -> { - stateList.removeLastOrNull() - stateList.add(body) + callStack.removeLastOrNull() + callStack.add(call) } + RouteMethod.ReplaceAll -> { - stateList.clear() - stateList.add(body) + callStack.clear() + callStack.add(call) } else -> diff --git a/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt b/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt index ae1470f..c0c9281 100644 --- a/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt +++ b/compose/common/src/dev/programadorthi/routing/compose/ComposeRoutingExt.kt @@ -2,18 +2,12 @@ package dev.programadorthi.routing.compose import dev.programadorthi.routing.core.Routing -public fun Routing.canPop(): Boolean = contentList.size > 1 +public fun Routing.canPop(): Boolean = callStack.size > 1 public fun Routing.pop(result: Any? = null) { if (!canPop()) return - popResult = result - contentList.removeLastOrNull() + poppedCall = callStack.removeLastOrNull() + poppedCall?.popped = true + poppedCall?.popResult = result } - -@Suppress("UNCHECKED_CAST") -public fun Routing.popResult(): T? = popResult as? T - -public fun Routing.popResult(default: T): T = popResult() ?: default - -public fun Routing.popResult(default: () -> T): T = popResult() ?: default() diff --git a/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt b/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt index 34f5b87..d83e268 100644 --- a/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt +++ b/compose/common/test/dev/programadorthi/routing/compose/ComposeRoutingTest.kt @@ -315,7 +315,6 @@ internal class ComposeRoutingTest { clock.sendFrame(0L) // Ask for recomposition routing.pop() - advanceTimeBy(99) // Ask for routing clock.sendFrame(0L) // Ask for recomposition // THEN @@ -331,7 +330,7 @@ internal class ComposeRoutingTest { val routing = routing(parentCoroutineContext = coroutineContext) { composable(path = "/push") { - poppedMessage = LocalRouting.current.popResult() + poppedMessage = LocalRouting.current.poppedCall?.popResult() } } @@ -368,7 +367,7 @@ internal class ComposeRoutingTest { val routing = routing(parentCoroutineContext = coroutineContext) { composable(path = "/push") { - poppedMessage = LocalRouting.current.popResult() + poppedMessage = LocalRouting.current.poppedCall?.popResult() } }