ComposeGears / Tiamat

Simple Compose multiplatform navigation library
https://composegears.github.io/Tiamat/
Apache License 2.0
193 stars 4 forks source link
compose-multiplatform composemultiplatform kmp-library navigation wasm

Tiamat

Compose multiplatform navigation library

API

Add the dependency below to your module's build.gradle.kts file:

Module Version
tiamat Maven Central
tiamat-koin Maven Central

Multiplatform

sourceSets {
    commonMain.dependencies {
        // core library
        implementation("io.github.composegears:tiamat:$version")
        // Koin integration (https://github.com/InsertKoinIO/koin) 
        implementation("io.github.composegears:tiamat-koin:$version")
    }
}

Android / jvm

Use same dependencies in the dependencies { ... } section

Why Tiamat?

Setup

1) Define your screens in one of 3 available ways:

see example: App.kt

Overview

Screen

The screens in Tiamat should be an entities (similar to composable functions)

the Args generic define the type of data, acceptable by screen as input parameters in the NavController:navigate fun

val RootScreen by navDestination<Unit> {
    // ...
    val nc = navController()
    // ...
    nc.navigate(DataScreen, DataScreenArgs(1))
    // ...
}

data class DataScreenArgs(val t: Int)

val DataScreen by navDestination<DataScreenArgs> {
    val args = navArgs()
}

The screen content scoped in NavDestinationScope<Args>

The scope provides a number of composable functions:

NavController

You may create NavController using one of rememberNavController functions:

fun rememberNavController(
    key: String? = null,
    storageMode: StorageMode? = null,
    startDestination: NavDestination<*>? = null,
    destinations: Array<NavDestination<*>>,
    configuration: NavController.() -> Unit = {}
)

and display as part of any composable function

@Composable
fun Content() {
    val navController = rememberNavController( /*... */)
    Navigation(
        navController = navController,
        modifier = Modifier.fillMaxSize().systemBarsPadding()
    )
}

Extensions

You can attach an extension to any destination, common case is to track screens


// define extension
class AnalyticsExt<T>(private val name: String) : Extension<T>() {
    @Composable
    override fun NavDestinationScope<T>.content() {
        LaunchedEffect(Unit) {
            val service = ... // receive tracker
            service.trackScreen(name)
        }
    }
}

// apply ext to screen
val SomeScreen by navDestination<Unit>(
    AnalyticsExt("SomeScreen")
) {
    // screen content
}

NavController will keep the screens data, view models, and states during navigation

[!IMPORTANT] The data may be cleared by system (eg: Android may clear memory)

Upon restoration state there is few cases depend on storageMode

Storage mode

Known limitations

[!IMPORTANT] Type checking has run into a recursive problem. Easiest workaround: specify types of your declarations explicitly ide error.

val SomeScreen1 by navDestination<Unit> {
  val navController = navController()
  Button(
      onClick = { navController.navigate(SomeScreen2) }, // << error here
      content = { Text("goScreen2") }
  )
}

val SomeScreen2 by navDestination<Unit> {
val navController = navController()
  Button(
      onClick = { navController.navigate(SomeScreen1) }, // << or here
      content = { Text("goScreen2") }
  )
}

Appears when it is circular initialization happen (Screen1 knows about Screen2 whot knows about Screen1 ...)

Solution: just define types of root(any in chain) screens explicitly

val SomeScreen1: NavDestination<Unit> by navDestination {  /* ... */ }

[!IMPORTANT] Why is my system back button works wired with custom back handler?

While using custom back handler do not forget 2 rules 1) Always place NavBackHandler before Navigation 2) use Navigation(handleSystemBackEvent = false) flag to disable extra back handler

Samples

Simple back and forward navigation:

1-simple-fb.webm

Bottom bar navigation:

2-bot-bar.webm

Passing data to next screen:

3-data-params.webm

Passing data to previous screen:

4-data-result.webm

Custom transition:

5-custom-transition.webm

Examples code

Hint

Multiplatform

I want to navigate true multiple nav steps in 1 call (e.g handle deeplink)

// there is 2 common ideas behind handle complex navigation

//---- idea 1 -----
// create some data/param that will be passed via free args 
// each screen handle this arg and opens `next` screen

val DeeplinkScreen by navDestination<Unit> {
    val deeplink = freeArgs<DeeplinkData>() // take free args 

    val deeplinkNavController = rememberNavController(
        key = "deeplinkNavController",
        startDestination = ShopScreen,
        destinations = arrayOf(ShopScreen, CategoryScreen, DetailScreen)
    ) {
        // handle deeplink and open next screen
        // passing eitthe same data or appropriate parts of it
        if (deeplink != null) {  
            editBackStack {
                clear()
                add(ShopScreen)
                add(CategoryScreen, deeplink.categoryId)
            }
            replace(
                dest = DetailScreen,
                navArgs = DetailParams(deeplink.productName, deeplink.productId),
                transition = navigationNone()
            )
            clearFreeArgs()
        }
    }

    Navigation(modifier = Modifier.fillMaxSize(), navController = deeplinkNavController)
}

//---- idea 2 -----
// use route-api

if (deeplink != null) {
    @OptIn(TiamatExperimentalApi::class)
    navController?.route(Route
        .start {
            // we are in the root nav controller
            // in case we are not at DeeplinkScreen
            // clear history and open it
            // else do nothing, route will be continued in the DeeplinkScreen screen
            if (current != DeeplinkScreen) {
                editBackStack {
                    clear()
                    add(MainScreen)
                    add(PlatformExamplesScreen)
                }
                replace(DeeplinkScreen)
            }
        }
        .next {
            // we are in the DeeplinkScreen`s nested nav controller
            // if there is multiple nested controlled - use `next(selector=...)
            // we can check a backstack or just open full flow for deeplink
            editBackStack {
                clear()
                add(ShopScreen)
                add(CategoryScreen, deeplink.categoryId)
            }
            replace(
                dest = DetailScreen,
                navArgs = DetailParams(deeplink.productName, deeplink.productId),
                transition = navigationNone()
            )
        }
    )
    deepLinkController.clearDeepLink()
}

I use startDestination = null + LaunchEffect \ DisposableEffect to make start destination dynamic and see 1 frame of animation

    // LaunchEffect & DisposableEffect are executed on `next` frame, so you may see 1 frame of animation
    // to avoid this effect use `configuration` lambda within `rememberNavController` fun
    // see DeeplinkScreen.kt

    val deeplinkNavController = rememberNavController(
        key = "deeplinkNavController",
        startDestination = ShopScreen,
        destinations = arrayOf(ShopScreen, CategoryScreen, DetailScreen)
    ) { // executed right after being created or restored
        // we can do nav actions before 1st screen bing draw without seeing 1st frame
        if (deeplink != null) {
            editBackStack {
                clear()
                add(ShopScreen)
                add(CategoryScreen, deeplink.categoryId)
            }
            replace(
                dest = DetailScreen,
                navArgs = DetailParams(deeplink.productName, deeplink.productId),
                transition = navigationNone()
            )
            clearFreeArgs()
        }
    }

Desktop

There is no default 'back' action on desktop

If you want to add one into the Tiamat navigation just use the code below:

fun main() = application {
    val backHandler = LocalNavBackHandler.current // < get ref to Global back handler
    Window(
        // ...
        onKeyEvent = { // < add global key event handler
            it.key == Key.Escape && it.type == KeyEventType.KeyUp && backHandler.back() // < call backHandler.back()
        },
        // ...
    ) {
        App()
    }
}

Android

Tiamat-android overrides LocalLifecycleOwner for each destination and compatible with lifecycle-aware components

See an example of camera usage: AndroidViewLifecycleScreen.kt

iOS

Nothing specific (yet)

Run/Build sample

Android: ./gradlew example:app:composeApp:assembleDebug

Desktop: ./gradlew example:app:composeApp:run

Web: ./gradlew example:app:composeApp:wasmJsBrowserRun

iOS: run XCode project or else use KMM plugin iOS target

other commands:

Contributors

Thank you for your help! ❤️

License

Developed by ComposeGears 2024

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.