Illustrations by Pavlo Stavytskyi

Introducing Nibel: A Navigation Library for Adopting Jetpack Compose in Fragment-Based Apps

Pavlo Stavytskyi
Turo Engineering
Published in
10 min readJul 20, 2023

--

Adopting Jetpack Compose in existing codebases is a challenge many teams face nowadays. Recently, we published a story about how we designed a Jetpack Compose architecture for the Turo Android app that heavily relies on fragments. Today, we are excited to make it open-source as Nibel.

Nibel — is a type-safe navigation library that enables seamless adoption of Jetpack Compose in Android apps that rely on fragments. When we built it at Turo, our goal was to ensure a proper Jetpack Compose experience for the team when creating new features while keeping them compatible with the rest of the codebase automatically. By leveraging the power of a Kotlin Symbol Processor (KSP) Nibel provides a unified and type-safe way of navigating between screens in the following navigation scenarios:

  • fragment → compose
  • compose → compose
  • compose → fragment

Nibel supports both single-module and multi-module navigation out-of-the-box. The latter is especially useful when navigating between feature modules that do not depend on each other directly.

You can find Nibel and the documentation on GitHub.

In this story, you will see how you can start using Nibel in your project, the common scenarios of adopting Jetpack Compose, and the customization options Nibel provides.

How to use it?

Here are the basic steps for using Nibel in your project to start adopting Jetpack Compose.

1. Declare a screen. To start using Nibel, simply annotate your composable function with @UiEntry annotation. This will generate a {ComposableName}Entry class that is used to navigate to this screen.

@UiEntry(type = ImplementationType.Fragment)
@Composable
fun FooScreen(
navigator: NavigationController // optional param
) { ... }

For screens with arguments just pass your Parcelable args class in the annotation.

@UiEntry(
type = ImplementationType.Composable,
args = BarScreenArgs::class
)
@Composable
fun BarScreen(
args: BarScreenArgs, // optional param
navigator: NavigationController // optional param
) { ... }

We’re going to take a closer look at ImplementationType later in this post.

2. Navigate between compose screens. Using NavigationController it is possible to navigate between annotated compose screens. Simply use a navigateTo function by passing an instance of a generated entry as an argument.

val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenEntry.newInstance(args))

3. Navigate to a fragment. Similarly, a NavigationController can be used to navigate from compose screens to old fragments.

class BazFragment : Fragment() { ... }

Wrap a fragment instance with FragmentEntry and pass it to navigateTo function.

val fragment = BazFragment()
navigator.navigateTo(FragmentEntry(fragment))

4. Navigate from a fragment. Annotated compose screens appear as fragments to the outside non-compose world. Treat a generated entry class as a fragment and use a transaction for navigation.

class QuxFragment : Fragment() {
...
requireActivity().supportFragmentManager.commit {
replace(android.R.id.content, FooScreenEntry.newInstance().fragment)
}
}

Multi-module navigation

In multi-module apps, it is common to have feature modules that do not depend on each other directly. In this case, it is impossible to obtain a direct reference to a generated entry class in another feature module.

Nibel provides an easy way of multi-module navigation in a type-safe manner using the concept of destinations. Destination — is a simple data type that serves as a navigation intent. It is located in a separate module available to other feature modules.

Each destination is associated with exactly one screen, so when navigation is required, the instance of a destination is used to reach the target screen.

There is no need to have a single navigation module per app. There can be many of them in the app. The key requirement though, is for the destination type to be available for both source and target screens.

1. Declare a destination. The most basic destination is an object that implements DestinationWithNoArgs an is declared in a separate navigation module, available to other feature modules.

// :navigation module
object FooScreenDestination : DestinationWithNoArgs

If you need a screen with args, inherit DestinationWithArgs instead.

// :feature module, depends on :navigation module
data class BarScreenDestination(
override val args: BarScreenArgs // Parcelable args
) : DestinationWithArgs<BarScreenArgs>

2. Associate a destination with a screen. Each destination should be associated with exactly one screen using @UiExternalEntry annotation over a composable function.

@UiExternalEntry(
type = ImplementationType.Fragment,
destination = BarScreenDestination::class
)
@Composable
fun BarScreen(
args: BarScreenArgs, // optional param
navigator: NavigationController // optional param
) { ... }

If multi-module navigation from compose to an old fragment is needed, a @LegacyExternalEntry should be applied to the fragment.

@LegacyExternalEntry(destination = BasScreenDestination::class)
class BazScreenFragment : Fragment() { ... }

3. Navigate to a destination. Use NavigationController to navigate to a destination.

val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenDestination(args))

@UiScreenEntry includes all the functionality of @UiEntry and therefore, if you have a direct reference to the generated entry class, the following code is valid for the same screen.

val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenEntry.newInstance(args))

4. Navigate from a fragment. Finally, if a multi-module navigation is needed from an old fragment to a compose screen, a destination should be used to obtain a fragment instance and perform a transaction.

class QuxScreenFragment : Fragment() {
...
requireActivity().supportFragmentManager.commit {
val entry = Nibel.newFragmentEntry(BarScreenDestination)!!
replace(android.R.id.content, entry.fragment)
}
}

Composable function params

Composable functions annotated with @UiEntry or @UiExternalEntry can have any number of parameters as long as they have default values.

@UiEntry(type = ImplementationType.Fragment)
fun FooScreen(viewModel: FooViewModel = viewModel()) { ... }

In addition, there are special types of params that do not require default values, as Nibel can figure out how to provide corresponding instances. Among such params are NavigationController, ImplementationType and args of the same type as in the @UiEntry annotation or in the destination type.

@UiEntry(
type = ImplementationType.Composable,
args = BarArgs::class
)
fun BarScreen(
args: BarArgs,
navigator: NavigationController,
type: ImplementationType
) { ... }

If argument types do not match, a compile type error will be thrown.

Alternatively, the values above could be obtained as composition locals.

@UiEntry(
type = ImplementationType.Composable,
args = BarArgs::class
)
fun BarScreen() {
val args = LocalArgs.current as BarArgs
val navigator = LocalNavigationController.current
val type = LocalImplementationType.current
}

Common usage scenarios

The type of generated entries differs depending on the ImplementationType specified in the annotation of a target screen. Each type serves a specific scenario and can be one of:

  • Fragment - generated entry is a fragment that uses the annotated composable as its content. It makes the compose screen appear as a fragment to other fragments and is crucial in fragment → compose navigation scenarios.
  • Composable - generates a small wrapper class over a composable. It is normally used in compose → compose and compose → fragment navigation scenarios where the latter is marked with this implementation type.

Normally there are several scenarios for adding new Jetpack Compose screens to an existing codebase.

Scenario 1 — new feature

The easiest scenario is when you are creating a completely new feature in a separate module. In this case, all the screens of the feature will use Jetpack Compose.

The first screen of the feature serves as an external entry, as it allows entering the feature from other modules. It is crucial that it is marked with ImplementationType.Fragment. This will ensure it appears as a fragment to the non-compose code and thus, makes it easy to navigate from old fragments to this new feature.

All the subsequent screens in the feature should use ImplementationType.Composable. This will result in improved performance since no fragments are generated, leading to fewer class allocations per screen.

In some cases, you might need to navigate from the new feature back to an old one that relies on fragments. All you have to do is annotate a target fragment with @LegacyExternalEntry and use its associated destination for navigation through NavigationController.

Scenario 2 — expanding existing feature

Another scenario is when you need to insert a bunch of new consecutive screens in an existing feature. In this case, it is possible to have them as compose screens even though they’re placed in the middle of the flow of fragments.

The key rule here is the same. The first compose screen should be annotated with ImplementationType.Fragment and all the subsequent ones should use ImplementationType.Composable.

Scenario 3 — standalone screens

The third scenario is probably the most frequent during the first stages of Jetpack Compose adoption. In this case, standalone compose screens are placed in the middle of the old fragment flows.

All you need to do in this case is to annotate compose screens with ImplementationType.Fragment, and treat them as fragments outside of a compose code.

Customization

Nibel provides various customization options so that it can be adapted to the needs of a specific project.

Before proceeding with this section it is recommended to check our previous story which shows more details on Nibel’s internal components and the thought process behind their implementation.

Applying a theme

For every screen annotated with ImplementationType.Fragment Nibel generates an entry class which is a fragment that inherits a base ComposableFragment. When using Jetpack Compose with fragments, all the composable UI is set in onCreateView. This means that for every new fragment, a theme must be explicitly applied.

// base class for generated fragments
// (part of nibel-runtime library)
abstract class ComposableFragment : Fragment() {

@Composable
abstract fun ComposableContent()

override fun onCreateView(
...
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme { // a theme should be applied here
...
}
}
}
}

Since it’s a base class of a 3-rd party library, a custom theme of a specific app could not just be applied there.

To apply a theme you can implement a RootDelegate which will be just a class with a simple composable function. This function is called at the root of every screen annotated with ImplementationType.Fragment.

object CustomRootContent : RootDelegate {

@Composable
override fun Content(content: @Composable () -> Unit) {
AppTheme { // applying a custom theme
content()
}
}
}

Don’t forget to call content function, to proceed with the UI of your screen.

All that is left to do is apply the CustomRootContent when configuring Nibel.

Nibel.configure(rootDelegate = CustomRootContent)

Navigation specs

As we saw above, NavigationController is used to perform navigation between screens. However, the navigateTo function provides space for customization as well.

abstract class NavigationController(...) {
...

fun navigateTo(
entry: Entry,
fragmentSpec: FragmentSpec<*>, // fragment navigation specification
composeSpec: ComposeSpec<*> // compose navigation specification
)
}

As we already know, Nibel enables navigation between fragments and compose screens in various scenarios. When navigating from a composable to a new screen, Nibel utilizes different tooling for navigation under the hood, depending on the target screen.

  • If the next screen is a fragment or a composable with ImplementationType.Fragment there will be a fragment transaction performed implicitly.
  • If the next screen is composable with ImplementationType.Composable Nibel will use compose navigation library implicitly to perform navigation.

At any moment, you could switch ImplementationType in the annotation of any screen, and the code will be still compilable. However, Nibel will use a different underlying tool to perform navigation. FragmentSpec is used when navigation between fragments happens under the hood, while ComposeSpec is used for direct navigation between composable functions, also implicitly.

When performing a fragment transaction under the hood, sometimes you might want to have more control over its specifics. For example, use add vs replace, select a custom container id for the transaction, etc.

The instance of each navigation spec holds inside implementation details of how the navigation should be performed. Therefore, it could be customized in multiple ways.

For example, for fragment transactions, you can use an instance of FragmentTransactionSpec and specify the details of a transaction.

navigator.navigateTo(
entry = ...,
fragmentSpec = FragmentTransactionSpec(
replace = true,
addToBackStack = true,
containerId = R.id.customContainerId
)
)

If that’s not enough, you can completely override its behavior by writing the custom navigation logic.

class CustomTransactionSpec : FragmentTransactionSpec(...) {

override fun FragmentTransactionContext.navigateTo(entry: FragmentEntry) {
this.fragmentManager.commit {
// custom fragment transaction logic
}
}
}
navigator.navigateTo(
entry = ...,
fragmentSpec = CustomTransactionSpec()
)

As for compose specs, a compose navigation library is used under the hood. All the navigation destinations are being added dynamically in ComposeNavigationSpec. You can learn more about how the compose navigation library is used under the hood in our previous post.

Finally, when configuring Nibel you can set any navigation specification by default for all screens in the app.

Nibel.configure(
fragmentSpec = CustomFragmentSpec(),
composeSPec = CustomComposeSpec()
)

Compatibility with architecture components

Modern Android apps use various architecture components such as Hilt, ViewModel, etc. Let’s see how Nibel could be integrated with them.

You can declare a view model as a param of a composable function and use hiltViewModel to get its instance. Now, you can use a combination of a Nibel screen and a view model injected by Hilt.

@UiEntry(type = Composable, args = FooArgs::class)
@Composable
fun FooScreen(viewModel: FooViewModel = hiltViewModel()) { ... }
@HiltViewModel
class FooViewModel(handle: SavedStateHandle): ViewModel() {
val args = handle.getNibelArgs<FooArgs>()
}

You can notice, that the screen arguments are automatically available in a SavedStateHandle in a view model.

To conclude

With Nibel, you can focus on writing new product features for your app with Jetpack Compose while having compatibility with the rest of the codebase, and fragments in particular, handled for you.

Nibel is highly customizable so it could be applied to various types of projects and navigation scenarios during adopting Jetpack Compose.

Feel free to check the repository for more documentation and a sample project with more details on how to use Nibel in practice.

Give it a try and let us know what you think. We hope it will help you to make the process of Jetpack Compose adoption more smooth.

--

--

Pavlo Stavytskyi
Turo Engineering

Staff Software Engineer at Meta • Google Developer Expert for Android, Kotlin