Skip to content

Commit

Permalink
feat: compose animation routing support
Browse files Browse the repository at this point in the history
  • Loading branch information
programadorthi committed Jan 19, 2024
1 parent d2521f9 commit c8aab69
Show file tree
Hide file tree
Showing 16 changed files with 973 additions and 7 deletions.
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,11 @@ val router = routing(

## Compose Routing (compose module)

Are you using Compose Jetpack or Multiplatform? This module is for you.
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 {
val routing = routing {
composable(path = "/login") {
// Your composable or any compose behavior here
}
Expand All @@ -205,6 +205,49 @@ fun MyComposeApp() {
routing.call(uri = "/login")
```

## Compose Animation Routing (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:

```kotlin
val routing = routing {
// You can override global behaviors to each composable
composable(
path = "/login",
enterTransition = {...},
exitTransition = {...},
popEnterTransition = {...},
popExitTransition = {...},
) {
// Your composable or any compose behavior here
}
}

@Composable
fun MyComposeApp() {
Routing(
routing = routing,
enterTransition = {...}, // on enter new composable in forward direction
exitTransition = {...}, // on exit previous composable in forward direction
popEnterTransition = {...}, // on enter previous composable in backward direction
popExitTransition = {...}, // on exit current composable in backward direction
) {
// Initial content
}
}

// And in any place that have the routing instance call:
routing.call(uri = "/login")
```

> The kotlin-routing author is not expert in Compose Animation. So, yes, the behavior here is close
> to [Navigation with Compose](https://developer.android.com/jetpack/compose/navigation) and will help people that come from it.
## Other modules to interest

- `auth` - [Authentication and Authorization](https://ktor.io/docs/authentication.html)
Expand Down
1 change: 1 addition & 0 deletions compose-animation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build
80 changes: 80 additions & 0 deletions compose-animation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
alias(libs.plugins.jetbrains.compose)
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlinx.kover")
alias(libs.plugins.maven.publish)
}

configureCommon()
configureJvm()
setupJvmToolchain()

// TODO: org.jetbrains.compose.animation has targets limitation. That is the reason to duplicate configs below
kotlin {
explicitApi()

setCompilationOptions()
configureSourceSets()

js(IR) {
nodejs()
browser()
}

configureJs()

macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets {
commonMain {
dependencies {
api(projects.resources)
implementation(compose.runtime)
implementation(compose.animation)
}
}

val jvmMain by getting {
dependsOn(commonMain.get())
dependencies {
api(libs.slf4j.api)
}
}
val jvmTest by getting {
dependsOn(commonTest.get())
dependencies {
implementation(libs.test.junit)
implementation(libs.test.coroutines.debug)
implementation(libs.test.kotlin.test.junit)
implementation(compose.desktop.uiTestJUnit4)
implementation(compose.desktop.currentOs)
}
}

val nativeMain by creating {
dependsOn(commonMain.get())
}

val macosMain by creating {
dependsOn(nativeMain)
}
val macosX64Main by getting {
dependsOn(macosMain)
}
val macosArm64Main by getting {
dependsOn(macosMain)
}
val iosX64Main by getting {
dependsOn(nativeMain)
}
val iosArm64Main by getting {
dependsOn(nativeMain)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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<T> = @JvmSuppressWildcards
AnimatedContentTransitionScope<ComposeEntry>.() -> 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<EnterTransition>? = null
internal var exitTransition: Animation<ExitTransition>? = null
internal var popEnterTransition: Animation<EnterTransition>? = enterTransition
internal var popExitTransition: Animation<ExitTransition>? = exitTransition

public var popped: Boolean = false
internal set

public inline fun <reified T> popResult(): T? =
when (popResult) {
is T -> popResult as T
else -> null
}

public inline fun <reified T> popResult(default: T): T = popResult() ?: default

public inline fun <reified T> popResult(default: () -> T): T = popResult() ?: default()

public inline fun <reified T> resource(): T? =
when (resource) {
is T -> resource as T
else -> null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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<SnapshotStateList<ComposeEntry>> =
AttributeKey("ComposeRoutingAttributeKey")

private val ComposeRoutingPoppedEntryAttributeKey: AttributeKey<ComposeEntry> =
AttributeKey("ComposeRoutingPoppedEntryAttributeKey")

internal var Routing.contentList: SnapshotStateList<ComposeEntry>
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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package dev.programadorthi.routing.compose

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.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.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
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

public val LocalRouting: ProvidableCompositionLocal<Routing> =
staticCompositionLocalOf {
error("Composition local LocalRouting not found")
}

@Composable
public fun Routing(
routing: Routing,
enterTransition: Animation<EnterTransition> = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90))
},
exitTransition: Animation<ExitTransition> = {
fadeOut(animationSpec = tween(90))
},
popEnterTransition: Animation<EnterTransition> = enterTransition,
popExitTransition: Animation<ExitTransition> = exitTransition,
initial: @Composable AnimatedContentScope.() -> Unit,
) {
CompositionLocalProvider(LocalRouting provides routing) {
val stateList =
remember(routing) {
mutableStateListOf<ComposeEntry>().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
},
)
}
}
AnimatedContent(
targetState = stateList.last(),
transitionSpec = {
transitionSpec(
scope = this,
enterTransition = enterTransition,
exitTransition = exitTransition,
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
)
},
content = { entry ->
entry.content(this)
},
)
}
}

@Composable
public fun Routing(
rootPath: String = "/",
parent: Routing? = null,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
log: Logger = KtorSimpleLogger("kotlin-routing"),
developmentMode: Boolean = false,
enterTransition: Animation<EnterTransition> = {
fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90))
},
exitTransition: Animation<ExitTransition> = {
fadeOut(animationSpec = tween(90))
},
popEnterTransition: Animation<EnterTransition> = enterTransition,
popExitTransition: Animation<ExitTransition> = exitTransition,
configuration: Route.() -> Unit,
initial: @Composable AnimatedContentScope.() -> Unit,
) {
val routing =
remember {
routing(
rootPath = rootPath,
parent = parent,
parentCoroutineContext = coroutineContext,
log = log,
developmentMode = developmentMode,
configuration = configuration,
)
}

DisposableEffect(routing) {
onDispose {
routing.dispose()
}
}

Routing(
routing = routing,
enterTransition = enterTransition,
exitTransition = exitTransition,
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
initial = initial,
)
}

private fun transitionSpec(
scope: AnimatedContentTransitionScope<ComposeEntry>,
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))
}
Loading

0 comments on commit c8aab69

Please sign in to comment.