Skip to content

Commit

Permalink
feat: improved animation behaviors
Browse files Browse the repository at this point in the history
  • Loading branch information
programadorthi committed Jan 25, 2024
1 parent 75e533c commit 07baf57
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 70 deletions.
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[*.{kt,kts}]
ktlint_code_style = ktlint_official
ktlint_function_naming_ignore_when_annotated_with = Composable
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,19 @@ 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:

```kotlin
val routing = routing {
composable(path = "/login") {
// Your composable or any compose behavior here
call.popped // True if it was popped
val result = call.popResult<T>() // To get the pop result after pop one composable
val typedValue = call.resource<T>() // To get the type-safe navigated value
}
}

Expand All @@ -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<T>() // 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.
Expand All @@ -223,15 +234,16 @@ val routing = routing {
popExitTransition = {...},
) {
// Your composable or any compose behavior here
call.animatedVisibilityScope // If you need do something during animation
}
}

@Composable
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
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnimatedVisibilityScope> =
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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -41,34 +39,38 @@ public fun Routing(
popEnterTransition: Animation<EnterTransition> = enterTransition,
popExitTransition: Animation<ExitTransition> = 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)
},
)
}
Expand All @@ -92,7 +94,7 @@ public fun Routing(
popExitTransition: Animation<ExitTransition> = exitTransition,
configuration: Route.() -> Unit,
initial: ComposeAnimatedContent,
content: ComposeAnimatedContent = { CurrentContent() },
content: ComposeAnimatedContent = { call -> call.content(call) },
) {
val routing =
remember {
Expand Down Expand Up @@ -122,25 +124,3 @@ public fun Routing(
content = content,
)
}

private fun transitionSpec(
scope: AnimatedContentTransitionScope<ApplicationCall>,
enterTransition: Animation<EnterTransition>,
exitTransition: Animation<ExitTransition>,
popEnterTransition: Animation<EnterTransition> = enterTransition,
popExitTransition: Animation<ExitTransition> = 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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,9 +72,8 @@ public fun Route.composable(
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
routing = routing,
) {
body()
}
bodyComposable = body,
)
}
}

Expand All @@ -85,7 +83,7 @@ public inline fun <reified T : Any> Route.composable(
noinline exitTransition: Animation<ExitTransition>? = null,
noinline popEnterTransition: Animation<EnterTransition>? = enterTransition,
noinline popExitTransition: Animation<ExitTransition>? = exitTransition,
noinline body: @Composable PipelineContext<Unit, ApplicationCall>.(T) -> Unit,
crossinline body: @Composable PipelineContext<Unit, ApplicationCall>.(T) -> Unit,
): Route {
val routing = asRouting ?: error("Your route $this must have a parent Routing")
return handle<T> { resource ->
Expand All @@ -108,7 +106,7 @@ public inline fun <reified T : Any> Route.composable(
noinline exitTransition: Animation<ExitTransition>? = null,
noinline popEnterTransition: Animation<EnterTransition>? = enterTransition,
noinline popExitTransition: Animation<ExitTransition>? = exitTransition,
noinline body: @Composable PipelineContext<Unit, ApplicationCall>.(T) -> Unit,
crossinline body: @Composable PipelineContext<Unit, ApplicationCall>.(T) -> Unit,
): Route {
val routing = asRouting ?: error("Your route $this must have a parent Routing")
return handle<T>(method = method) { resource ->
Expand All @@ -132,7 +130,7 @@ public fun <T> PipelineContext<Unit, ApplicationCall>.composable(
exitTransition: Animation<ExitTransition>? = null,
popEnterTransition: Animation<EnterTransition>? = enterTransition,
popExitTransition: Animation<ExitTransition>? = exitTransition,
body: ComposeContent,
bodyComposable: @Composable PipelineContext<Unit, ApplicationCall>.() -> Unit,
) {
call.enterTransition = enterTransition
call.exitTransition = exitTransition
Expand All @@ -142,6 +140,8 @@ public fun <T> PipelineContext<Unit, ApplicationCall>.composable(
composable(
routing = routing,
resource = resource,
body = body,
body = {
bodyComposable()
},
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -202,6 +203,7 @@ internal class ComposeAnimationRoutingTest {
routing(parentCoroutineContext = coroutineContext + job) {
composable(
path = "/path",
method = RouteMethod.Push,
enterTransition = {
previous = initialState
next = targetState
Expand Down Expand Up @@ -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",
)
}
}
Loading

0 comments on commit 07baf57

Please sign in to comment.