Type-safe arguments in Jetpack Navigation Compose

This blog post is a repost from code.kiwi.com. Newly, there is a meetup talk about Navigation Compose Typed library.

Jetpack Navigation Compose is the Jetpack Navigation library enhanced for usage in Compose. But the actual “enhancement” is a bit limited and different from the View-based navigation. Most importantly, there are no SafeArgs — a type-safe compile time solution for passing arguments between destinations. Navigation for Compose uses a simple URL pattern — arguments have to be converted to a string as a query parameter/path segment.

There are plenty of possible solutions — extensions to Jetpack Navigation Compose (Compose Destinations) or completely new navigation stacks with a custom implementation of the whole concept of navigation (Voyager, Compose Navigation Reimagined, Appyx). Yet every solution lacks something for our Kiwi.com apps usage.

Requirements

There are also requirements that are supported in every navigation library, so let me rather enumerate key areas that are implemented differently or not implemented at all.

Safe argument passing — This is the main reason why we are not satisfied with the “raw” Jetpack Navigation Compose library, which we are currently using. We want to freely pass more complex types (Parcelable/Serializable), non-primitive types like lists of ints, or even nullable primitive types. All this is either impossible or extremely verbose and complex to set up.

Deeplink support and navigation up vs. navigation back — This is probably the greatest topic at all. Let’s take an example from our app and have this navigation flow:
Main screen -> Bookings tab -> Booking detail -> Booking passenger details
We are about to create a deep link to that passenger details screen. The screen requires a bookingId to show. In the case of deep link, one could imagine an URL like example.com/bookings/123/passengers. The 123 is the bookingId. Opening this URL will open the app on the Booking passenger details screen. Then the user can:

  • Navigate back using the swipe gesture/back button in the system navigation bar. This action returns the user to the app they were before — e.g. an email client where the user clicked the link.
  • Navigate up using the back arrow in the toolbar/TopAppBar. This action goes to the previous screen in the navigation hierarchy — in this case to the actual Booking detail. What’s more important, the navigationUp should somehow “share” the bookingId from the current services screen and use it for opening the Booking detail screen.

Build speed — The final solution shouldn’t be using KAPT or KSP. Half of our modules are about UI and making them all KSP-dependent means a non-trivial slow-down. An acceptable approach can be using a Kotlin compiler plugin.

Dialogs, BottomSheets, Animations — One would expect that every library has solutions for dialogs, bottom-sheets, or custom animations/transitions. Actually, the Jetpack Navigation Compose library does not have bottom-sheets or custom animations/transitions, but there are official accompanying libraries — Accompanist’s Navigation Material BottomSheet and Navigation Animations. Simply, we don’t want to solve all those issues once again and expect the navigation library to provide solutions for us.

Jetpack Navigation
Compose
Compose DestinationsCompose Navigation ReimaginedBumble-Tech AppyxVoyager
Type-safe
NavigateUp vs. Back
Build speed
Dialogs/BottomSheets🟡🟡🟡🟡🟡
Animations
A simplified matrix of our requirements.

NavigationUp vs Back is well supported in Jetpack Navigation Compose (and therefore in Compose Destinations). Other custom navigation libraries are letting you define the deeplink’s back stack manually. The problematic part is that the developer has to manually track which back stack entry is “artificial” and therefore going back to this entry should rather navigate to the previous app (in contrast to navigating up).

As mentioned earlier, KSP used in Compose Destination is a non-trivial slow-down that we do not want to sacrifice.

Dialogs and bottom sheets are somehow supported everywhere, though not always they are part of the navigation back stack and they can be modeled separately. Jetpack Navigation Compose seems to be the way we want to go with and it is somehow “promised” that bottom sheets will be supported natively.

Currently, we do not miss much. Only the type-safe navigation. If only there was a mechanism in Kotlin that would allow us compile-time arguments processing and then runtime reflection-less conversion into an URL (and runtime reflection-less recreation from Bundle, respectively). Parcelables are quite limited. But wait, isn’t Kotlinx.Serilization exactly about this? Oh yes. That’s our solution.

NavigationComposeTyped

NavigationComposeTyped is the name of our new open-sourced library, that solves the type-safe arguments passing problem using KotlinX.Serialization.

It is a simple set of extensions to the existing Jetpack Navigation Compose library. Those extension functions allow you to define destinations with arguments, easy navigation to those destinations, and the extraction of the final argument. It utilizes the official KotlinX.Serialization Kotlin compiler plugin, therefore it is fast, and the plugin promises great Kotlin compatibility.

Let’s start hacking with a simple definition of the app’s destinations including their arguments:

import com.kiwi.navigationcompose.typed.Destination
import kotlinx.serialization.Serializable

sealed interface Destinations : Destination {
	@Serializable
	object BookingList: Destinations

	@Serializable
	data class BookingDetail(
		val bookingId: Long,
	) : Destinations
}

Next, continue with the NavGraph construction

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

val navController = rememberNavController()
NavHost(
	navController = navController,
	startDestination = createRoutePattern<Destinations.BookingList>(),
) {
	composable<Destinations.BookingList> { 
		Home(navController::navigate)
	}
	composable<Destinations.BookingDetail> {
		// this is Destinations.BookingDetail
		BookingDetail(bookingId)
	}
}

Finally, let’s allow Home to navigate

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

@Composable 
internal fun Home(navigate: (Destination) -> Unit) {
	Home(
		onBookingClick = { id -> navigate(Destinations.BookingDetail(id)) },
	)
}

@Composable 
private fun Home(onBookingClick: (id: Long) -> Unit) {
	// ...
}

Extensibility

The library itself is basically a set of a few simple public functions:

  • to create a URL pattern for a Destination,
  • to create navArguments definition for a Destination,
  • to decode a Destination instance (arguments) from a received Bundle,
  • to register a destination is a subtype of Destination (solving open polymorphism for KotlinX.Serialization).

Those functions help you implement type-safe navigation for other Jetpack Navigation Compose extensions, e.g. for bottom sheets from Accompanist’s library. Let’s take a look.

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)
	}
}

@Composable
fun App() {
	val bottomSheetNavigator = rememberBottomSheetNavigator()
	val navController = rememberNavController(bottomSheetNavigator)
	ModalBottomSheetLayout(bottomSheetNavigator) {
		NavGraph(
			navController = navControoler, 
			startDestination = createRoutePattern<Destinations.Home>(),
		) {
			bottomSheet<Destinations.Article> {
				Article(id)
			}
		}
	}
}

Conclusion

We had about 6+ NavHost using Navigation Compose already. Coming up with a solution that adds the missing piece helps us keep the continuity and knowledge. It allows us to freely integrate other Navigation Compose extensions.

Feedback wanted

We have just started. Check out the project and feel free to open an issue and discuss your experience and suggestions. The whole repository is open-sourced at github.com/kiwicom/navigation-compose-typed.