Git Product home page Git Product logo

navigation-compose-typed's Introduction

Navigation Compose Typed

Compile-time type-safe arguments for the Jetpack Navigation Compose library. Based on KotlinX.Serialization.

Kiwi.com library CI Build GitHub release Maven release

Major features:

  • Complex types' support, including nullability for primitive types - the only condition is that the type has to be serializable with KotlinX.Serializable library.
  • Based on the official Kotlin Serialization compiler plugin - no slowdown with KSP or KAPT.
  • All Jetpack Navigation Compose features: e.g. navigateUp() after a deeplink preserves the top-level shared arguments.
  • Few simple functions, no new complex NavHost or NavController types; this allows covering other Jetpack Navigation Compose extensions.
  • Gradual integration, feel free to onboard just a part of your app.

Watch the talk about this library and its implementation details:

Watch the video

QuickStart

Add this library dependency and KotlinX.Serialization support

plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.8.10"
}

dependencies {
    implementation("com.kiwi.navigation-compose.typed:core:<version>")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.5.0")
}

Warning This library uses Semantic Versioning. Be aware that BC breaks are allowed in minor versions before the major 1.0 version.

Create app's destinations

import com.kiwi.navigationcompose.typed.Destination

sealed interface Destinations : Destination {
    
    @Serializable
    data object Home : Destinations

    @Serializable
    data class Article(
        val id: String,
    ) : Destinations
}

and use them in the navigation graph definition

import com.kiwi.navigationcompose.typed.composable
import com.kiwi.navigationcompose.typed.createRoutePattern

NavGraph(
    startDestination = createRoutePattern<Destinations.Home>(),
) {
    composable<Destinations.Home> {
        Home()
    }
    composable<Destinations.Article> {
        // this is Destinations.Article
        Article(id)
    }
}

Now, it is time to navigate! Create a Destination instance and pass it to the navigate extension method on the standard NavController.

import com.kiwi.navigationcompose.typed.Destination
import com.kiwi.navigationcompose.typed.navigate

@Composable
fun AppNavHost() {
    val navController = rememberNavController()
    NavGraph(
        navController = navController,
    ) {
        composable<Destinations.Home> {
            Home(navController::navigate)
        }
    }
}

@Composable
private fun Home(
    onNavigate: (Destination) -> Unit,
) {
    Home(
        onArticleClick = { id -> onNavigate(Destinations.Article(id)) },
    )
}

@Composable
private fun Home(
    onArticleClick: (id: Int) -> Unit,
) {
    Column {
        Button(onClick = { onArticleClick(1) }) { Text("...") }
        Button(onClick = { onArticleClick(2) }) { Text("...") }
    }
}

ViewModel

You can pass your destination arguments directly from the UI using parameters/the assisted inject feature.

For example, in Koin:

val KoinModule = module {
    viewModelOf(::DemoViewModel)
}

fun DemoScreen(arguments: HomeDestinations.Demo) {
    val viewModel = getViewModel<DemoViewModel> { parametersOf(arguments) }
}

class DemoViewModel(
    arguments: HomeDestinations.Demo,
)

Alternatively, you can read your destination from a SavedStateHandle instance:

class DemoViewModel(
    state: SavedStateHandle,
) : ViewModel() {
    val arguments = state.decodeArguments<HomeDestinations.Demo>()
}

Extensibility

What about cooperation with Accompanist's Material bottomSheet {} integration? Do not worry. Basically, all the functionality is just a few simple functions. Create your own abstraction and use createRoutePattern(), createNavArguments(), decodeArguments() and registerDestinationType() functions.

import com.kiwi.navigationcompose.typed.createRoutePattern
import com.kiwi.navigationcompose.typed.createNavArguments
import com.kiwi.navigationcompose.typed.decodeArguments
import com.kiwi.navigationcompose.typed.Destination
import com.kiwi.navigationcompose.typed.registerDestinationType

private inline fun <reified T : Destination> NavGraphBuilder.bottomSheet(
    noinline content: @Composable T.(NavBackStackEntry) -> Unit,
) {
    val serializer = serializer<T>()
    registerDestinationType(T::class, serializer)
    bottomSheet(
        route = createRoutePattern(serializer),
        arguments = createNavArguments(serializer),
    ) {
        val arguments = decodeArguments(serializer, it)
        arguments.content(it)
    }
}

NavGraph {
    bottomSheet<Destinations.Article> {
        Article(id)
    }
}

Result sharing

Another set of functionality is provided to support the result sharing. First, define the destination as ResultDestination type and specify the result type class. Then open the screen as usual and utilize ComposableResultEffect or DialogResultEffect to observe the destination's result. To send the result, use NavController's extension setResult.

import com.kiwi.navigationcompose.typed.Destination
import com.kiwi.navigationcompose.typed.DialogResultEffect
import com.kiwi.navigationcompose.typed.ResultDestination
import com.kiwi.navigationcompose.typed.setResult

sealed interface Destinations : Destination {

    @Serializable
    data object Dialog : Destinations, ResultDestination<Dialog.Result> {
        @Serializable
        data class Result(
            val something: Int,
        )
    }
}

@Composable
fun Host(navController: NavController) {
    DialogResultEffect(navController) { result: Destinations.Dialog.Result ->
        println(result)
        // process the result
    }

    Button(
        onClick = { navController.navigate(Destinations.Dialog) },
    ) {
        Text("Open")
    }
}

@Composable
fun Dialog(navController: NavController) {
    Button(
        onClick = {
            navController.setResult(Destinations.Dialog.Result(something = 42))
            navController.popBackStack()
        }
    ) {
        Text("Set and close")
    }
}

navigation-compose-typed's People

Contributors

actions-user avatar blipinsk avatar gerak-cz avatar hrach avatar renovate[bot] avatar shahzadansari avatar shanio avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

navigation-compose-typed's Issues

Add proguard rules

I've added the rules from the README.md for kotlinx-serialization into my proguard file, however I'm still running into issues specifically with this library.
Calling createRoutePattern results in it throwing an error that the serializer is not found. My code works just fine in debug builds. Is there some rules that need to be added to fix this?

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Awaiting Schedule

These updates are awaiting their schedule. Click on a checkbox to get an update now.

  • fix(deps): update dependency org.robolectric:robolectric to v4.12

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

github-actions
.github/workflows/build.yml
  • actions/checkout v4
  • actions/setup-java v4
  • yutailang0119/action-android-lint v3.1.0
  • yutailang0119/action-android-lint v3.1.0
.github/workflows/release-drafter.yml
  • release-drafter/release-drafter v6
  • actions/checkout v4
.github/workflows/release.yml
  • actions/checkout v4
  • actions/setup-java v4
  • softprops/action-gh-release v2
gradle
gradle.properties
settings.gradle.kts
build.gradle.kts
  • org.jetbrains.kotlin.android 1.9.22
  • org.jetbrains.kotlin.plugin.serialization 1.9.22
  • org.jetbrains.kotlinx.binary-compatibility-validator 0.14.0
  • org.jmailen.kotlinter 4.1.1
  • com.android.application 8.3.0
  • com.vanniktech.maven.publish.base 0.27.0
core/gradle.properties
core/build.gradle.kts
demo/build.gradle.kts
gradle/libs.versions.toml
  • org.jetbrains.kotlin:kotlin-stdlib-jdk8 1.9.22
  • org.jetbrains.kotlinx:kotlinx-serialization-json 1.6.3
  • com.google.accompanist:accompanist-systemuicontroller 0.32.0
  • androidx.compose:compose-bom 2023.10.01
  • androidx.compose.compiler:compiler 1.5.10
  • androidx.navigation:navigation-compose 2.7.7
  • junit:junit 4.13.2
  • org.robolectric:robolectric 4.11.1
gradle-wrapper
gradle/wrapper/gradle-wrapper.properties
  • gradle 8.7

  • Check this box to trigger a request for Renovate to run again on this repository

Question around deep link parameters involving enums

This is not a bug per-se, but more of an attempt from me to ask you about your thought about how to best work with enums in destinations when also having to work with deep links.
After looking at the implementation and debugging some code, it turns out that enums are serialized simply by their ordinal. In my case I got a destination with a nullable enum class as a parameter. For the deep link, I can add it to the uriPattern as ...?paramName={paramName}, and then when I create an actual deep link for it, I need to do ...?paramName=0 or whatever index of that enum over there.
This means that if for example we do a follow-up release with a new enum entry, if I try to send that deep link to people out in the wild, if they still got an old version of the app, trying to open the deep link will simply crash their app, as kotlinx.serialization will try to decode that by running this line https://github.com/Kotlin/kotlinx.serialization/blob/1116f5f13a957feecda47d5e08b0aa335fc010fa/core/commonMain/src/kotlinx/serialization/internal/Enums.kt#L140 and since the index is not there it crashes the entire application.
My crash for example is kotlinx.serialization.SerializationException: 5 is not among valid com.android.MyClass enum values, values size is 5

What do you think is the best approach to make this process a bit more robust and forwards compatible? I could have the type in the destination itself be a String? for example, and then have to do in my own code a check to find any possible matches, or default to my own entry if not, something like:

val input = destinationInstance.deepLinkPopulatedField
val resolvedEnum = MyEnum.entries.firstOrNull {
  it.name == input
} ?: MyEnum.Default

Am I missing some smarter approach here? Have you encountered this before perhaps, and if yes what did you end up doing?

Add an option to `popUpTo` a specific destination

Do you think this function

@ExperimentalSerializationApi
@MainThread
fun NavOptionsBuilder.popUpTo(
  route: Destination,
  popUpToBuilder: PopUpToBuilder.() -> Unit = {},
) {
  popUpTo(route.toRoute(), popUpToBuilder)
}

Would make sense? I wanted to at some point to navigate but also pop up to a destination in the meantime, with code like this:

composable<Destinations.TerminationDate>() { navBackStackEntry ->
  val viewModel // ...
  TerminationDateDestination(
    ..
    navigateToSuccessScreen = {
      navController.navigate(
        CancelInsuranceDestinations.TerminationSuccess,
        navOptions {
          popUpTo(Destinations.CancelInsurance)
        },
      )
    },
  )
}

and toRoute() is internal so I couldn't do popUpTo(Destinations.CancelInsurance.toRoute()) really.
For now I got this one file where I've set @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") to abuse all these internal functions so here I am asking for your thoughts about exposing some of them 😅

It seems to work for me, am I possibly missing something that wouldn't make this a good idea? What do you think?

Consider supporting the accompanist navigation library which has animation support

I think the title is pretty straightforward. I wonder, do you use the native library without any special animation support for now?
As far as I can tell, the navigation support in the androidx library will not be merged anytime soon, so going with the accompanist one is kinda necessary for now.

I can see having this library expose another artifact which has the same API + the transition enter/pop etc., just targeting the accompanist composable and navigation instead.

Would this be something that you'd accept in this repo? Is it maybe something you were planning to do regardless? If you'd accept this, (with the risk of figuring out this is too hard for some odd reason and failing) would you also accept a contribution from my end for it?

I wonder if it'd become a maintenance burden since the accompanist version of it is more likely to break in backwards incompatible way or something like that.

In such a case, I guess my only bet would be to copy this library locally, and change the functions to target the accompanist ones then I'd at least maintain it all myself without putting burden on you, but also while not being able to help if someone else wants to try this out.

Anyway, I should probably stop rambling, do you have any thoughts on this topic in general?

How to access destination arguments in a ViewModel via the SavedStateHandle?

I am currently migrating an app to use your library. I have compared a few typed navigation compose libraries and found your approach to be the best for my liking.

I have one question, though: How can I access the navigation arguments inside a ViewModel? The ViewModel is injected in my screens via viewModel: MyViewModel = hiltViewModel().

My ViewModel has the default handle: SavedStateHandle argument.

(Bonus question: What's the best way to create NavGraph-Scoped ViewModels using your library?)

Default arguments for startDestination

I had the following use case:
I want to use a NavHost for a specific part of the app (trying out compose navigation in an Activity-based application) but the screen which I am navigating to needs the home destination to be passed in an argument. This isn't quite possible by default using createRoutePattern.

Some more context from this discussion https://slack-chats.kotlinlang.org/t/9627868/i-want-to-adopt-navigation-compose-in-an-app-where-we-got-ac#8b324622-3961-4bcd-975d-87a38b57ae63

What I want to do basically is to follow Ian's advice to set the default value myself, something which is not possible with the current APIs of this library. Maybe rightly so, but I'm gonna go ahead and discuss what I've done because I want to hear your thoughts on it.

To do this, I need to basically override either the entire createNavArguments, or make it accept some extra configuration.
In order not to throw the entire createNavArguments away, and lose the ability to change the data class without having to touch this code again, we can give ourselves the power to instead provide a custom function which acts on the NavArgumentBuilder for a specific field name. Again, a bit type-unsafe as we'll rely on the name of the field, but maybe there's something that can be done there too. Here's what I've done which worked for me:

Create this createNavArguments function:

@ExperimentalSerializationApi
public fun createNavArguments(
  serializer: KSerializer<*>,
  extraNavArgumentConfiguration: Map<String, NavArgumentBuilder.() -> Unit> = emptyMap(), // ⬅️ Added this
): List<NamedNavArgument> =
  List(serializer.descriptor.elementsCount) { i ->
    val name = serializer.descriptor.getElementName(i)
    navArgument(name) {
      // Use StringType for all types to support nullability for all of them.
      type = NavType.StringType
      val isOptional = serializer.descriptor.isNavTypeOptional(i)
      nullable = isOptional
      // If something is optional, the default value is required.
      if (isOptional) {
        defaultValue = null
      }
      // ⬇️ Extra 2 lines
      val extraConfiguration = extraNavArgumentConfiguration[name] ?: return@navArgument
      extraConfiguration()
    }
  }

And we use this one by making the two composable functions take this extra configuration:

@ExperimentalSerializationApi
@MainThread
internal inline fun <reified T : Destination> NavGraphBuilder.composable(
  deepLinks: List<NavDeepLink> = emptyList(),
  extraNavArgumentConfiguration: Map<String, NavArgumentBuilder.() -> Unit> = emptyMap(), // ⬅️ Added this
  noinline content: @Composable T.(NavBackStackEntry) -> Unit,
) {
  composable(
    kClass = T::class,
    serializer = serializer(),
    deepLinks = deepLinks,
    extraNavArgumentConfiguration = extraNavArgumentConfiguration,
    content = content,
  )
}

@ExperimentalSerializationApi
@MainThread
fun <T : Destination> NavGraphBuilder.composable(
  kClass: KClass<T>,
  serializer: KSerializer<T>,
  deepLinks: List<NavDeepLink> = emptyList(),
  extraNavArgumentConfiguration: Map<String, NavArgumentBuilder.() -> Unit> = emptyMap(), // ⬅️ Added this
  content: @Composable T.(NavBackStackEntry) -> Unit,
) {
  registerDestinationType(kClass, serializer)
  composable(
    route = createRoutePattern(serializer),
    arguments = createNavArguments(serializer, extraNavArgumentConfiguration), // ⬅️ Call the new createNavArguments here
    deepLinks = deepLinks,
  ) { navBackStackEntry ->
    decodeArguments(serializer, navBackStackEntry).content(navBackStackEntry)
  }
}

This makes the API of calling this look like this:

composable<CancelInsuranceDestinations.TerminationDate>(
  extraNavArgumentConfiguration = mapOf(
    "insuranceId" to { defaultValue = insuranceId.id },
  ),
) { ... }

Do you feel like this use case is valuable enough to warrant some addition to the existing APIs?

If no I can keep this local function and live with it, I assume I will have to use it more in the future since I'm gonna be doing this slow migration, but that would be fine.

If yes, how would you feel this could be done in a better way?

p.s. the naming of everything was done in haste just to see if this works, some more careful decisions can be made for sure.
p.p.s. You're a genius for making this library 😅 A very interesting way to take what kotlinx.serialization has to offer and use it for this! I am so glad you brought this to my attention on this slack discussion, so I really wanted to try it out. The ability to use what this library has to offer optionally wherever you want, and drop down to manual handling (like in my case) when you want to is so convenient!

Consider using the stable compose BOM instead of the alpha one from Chris Banes

I noticed that in the latest update of the library there was a bump in the compose BOM and that's when I noticed that the alpha compose bom is used

compose-bom = { module = "dev.chrisbanes.compose:compose-bom", version = "2023.03.00" }

Is there some particular reason why that is the case? It'd be nice not to bring in the alpha dependencies by accident by using this library. I haven't encountered any specific problem in my case, but it makes me a bit reluctant to know that I'll always be bringing in all the alphas without realizing. What is your opinion about this?

Consider enabling registering serializers for types coming from third party libraries inside destination classes

So I was exploring the idea of using some 3rd party lib types for some destinations. For kotlinx serialization to know how to serialize/deserialize those types, it needs to be registered in the serializersModule as I understand it (or passed explicitly which isn't what I want in this case, like Json.encodeToString(MySerializer, fooType)).
Right now, this library does register the Destination subclasses inside com/kiwi/navigationcompose/typed/internal/SerializersModule.kt, and (again, as far as I understand, correct me if I am wrong at any point here) relies on the classes used inside to be known how to be serialized by default, by being primitives for example.

So is there a way for me to register more serializers? Does this even make sense in the first place, is there some reason why I should not be doing this in the first place?

My use case in particular is that I am using apollo-kotlin, and that generates some data classes. I would like to be able to take some of those and have them be part of some of my destinations, but I currently can't annotate those with @serializable as they are part of the codegen itself, so I need to make explicit serializers myself and somehow register them to the serializer used by this library. (Or map them to yet another local type which I do annotate with @serializable, but I'd rather avoid that if possible.)

Posibility to add more serializable types to the `SerializersModule` that this library is using, which aren't necessary of the `Destination` type.

So I have this use case where I'd like to have my destinations to take in a list with type ImmutableList from kotlinx.collections.immutable.ImmutableList.

In order for this to work, it needs to know how to serialize this type, but I don't see an entry point from this library to add it to the SerializersModule that it's using.

Am I missing something perhaps? What would you suggest to do instead if this isn't something that could really work with how everything is setup?

DialogResultEffect is called after navigateUp is called

Hello, I am using DialogResultEffect for getting dialog screen. But on the screen where is it implemented it get dialog result after user click on back button aka navController.navigateUp() is called.

DialogResultEffect( currentRoutePattern = createRoutePattern<ProfileDestinations.Profile>(), navController = navController, ) { result: ProfileDestinations.Logout.Result -> Timber.d("result: %s", result.requestLogout) // here I received true if navigateUp is called }

NavHost looks like:
-> nav graph -> HomeScreen -> nav graph -> Profile screen

  • Lib version 0.5.0
  • Compose version 1.4.0-alpha05
  • Compose nav version 2.5.3
  • Android 13 - Google Pixel 6a
  • Android 12 - Samsung A51

Nested NavHost leads to SerializerAlreadyRegisteredException

I have a setup, where I have a few Fullscreen "root" destinations in a root NavHost:

  1. Login
  2. Main

The second destination, Main itself contains another NavHost connected to a BottomBar.

When I navigate in the root NavHost multiple times, I get a SerializerAlreadyRegisteredException.

Serializer for interface XXX.MainDestinations$EntertainmentTabDestinations (Kotlin reflection is not available) already registered in the scope of interface com.kiwi.navigationcompose.typed.Destination (Kotlin reflection is not available)

The already registered interface is in the Main NavHost destinations.

The property knownDestinations in SerializersModule seems to be the culprit here, because it is a List and not a Set.

(crash happens on 0.8.2 and 0.7.0)

Method to get current destination

I'm trying to have a bottom bar in my app that has several destinations. I'm not really sure how to be able to get the current destination as an instance of the serializable destinations class im using

val currentDestination by navController.currentBackStackEntryAsState()

NavigationBar {
    RootDestination.values.forEach { destination ->
        NavigationBarItem(
            selected = currentDestination == destination,
            icon = { Icon(destination.icon, stringResource(destination.label)) },
            label = { Text(stringResource(destination.label)) },
            onClick = { navController.navigate(destination) }
        )
    }
}
sealed interface RootDestination : Destination {
    val icon: ImageVector

    @get:StringRes
    val label: Int

    companion object {
        val values = listOf(Home, Feed, Library)
    }

    @Serializable
    object Home : RootDestination {
        override val icon = Icons.Default.Home
        override val label = R.string.home
    }

    @Serializable
    object Feed : RootDestination {
        override val icon = Icons.Default.Subscriptions
        override val label = R.string.feed
    }

    @Serializable
    object Library : RootDestination {
        override val icon = Icons.Default.VideoLibrary
        override val label = R.string.library
    }
}

Is there some way to convert the back stack entry to the type I'm using in my route?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.