Introducing Nibel: a navigation library for Fragment-based applications that supports seamless use of Jetpack Compose

Introduction to Nibel

Introducing Nibel: a navigation library for Fragment-based applications that supports seamless use of Jetpack Compose in Fragment-dependent Android applications.
Our goal when building Nibel was to provide a true Jetpack Compose experience for teams creating new features, while automatically maintaining compatibility with the codebase. By leveraging the power of the Kotlin Symbol Processor (KSP), Nibel provides a uniform and type-safe way to navigate pages between the following navigation scenarios:

  1. Fragment → Compose
  2. Compose → Compose
  3. Compose → Fragment

Nibel supports single-module and multi-module navigation, and is especially useful for navigating between functional modules that do not directly depend on each other.

In this article, you'll learn how to get started using Nibel in your projects, along with common adoption scenarios for Jetpack Compose and the customization options Nibel offers.

how to use?

Here are the basic steps to start adopting Jetpack Compose in a project using Nibel:

  1. Declare a screen. To start using Nibel, simply @UiEntrymark your Compose functions with annotations. This will generate a {ComposableName}Entryclass for navigating to this screen.
@UiEntry(type = ImplementationType.Fragment)
@Composable
fun FooScreen(
  navigator: NavigationController // optional param
) {
    
     ... }

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

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

We'll look at that in detail later ImplementationType.

  1. Navigate between Compose screens. Using NavigationController, it is possible to navigate between marked Compose screens. Just use navigateToa function, and pass the resulting entryinstance as an argument.
val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenEntry.newInstance(args))
  1. Navigate to Fragment. Likewise, NavigationControllernavigating to old Fragments from the Compose screen can be used.
class BazFragment : Fragment() {
    
     ... }

Wrap the Fragment instance as FragmentEntry, and pass it to navigateTothe function.

val fragment = BazFragment()
navigator.navigateTo(FragmentEntry(fragment))
  1. Navigate from Fragment. Marked Compose screens behave like Fragments to the non-Compose world. Treat the generated entryclasses as Fragments and use them transactionfor navigation.
class QuxFragment : Fragment() {
    
    
  ...
  requireActivity().supportFragmentManager.commit {
    
    
    replace(android.R.id.content, FooScreenEntry.newInstance().fragment)
  }
}

Multi-module navigation

In a multi-module application, there will often be functional modules that do not directly depend on each other. In this case, there is no way to directly get a reference to the entry class generated in another feature module.

Nibel provides a simple, type-safe way of navigating through multiple modules, using the concept of "goals". A target is a simple data type that is used as a navigation intent and resides in a separate module that can be used by other functional modules.

Each goal is associated with a screen, so when navigation is required, an instance of the goal is used to reach the goal screen.

There does not need to be a single navigation module in the application, there can be multiple navigation modules. However, the key requirement is that the target type is available to both the source and target screens.

  1. Declare a goal. The most basic object is DestinationWithNoArgsthe object implemented and declared in a separate navigation module for use by other functional modules.
// :navigation模块
object FooScreenDestination : DestinationWithNoArgs

Inherit if screen with parameters is required DestinationWithArgs.

// :feature模块,依赖于:navigation模块
data class BarScreenDestination(
  override val args: BarScreenArgs // Parcelable args
) : DestinationWithArgs<BarScreenArgs>
  1. Associate a target with a screen. Each target should be @UiExternalEntryassociated with a screen using annotations placed on the Compose function.
@UiExternalEntry(
  type = ImplementationType.Fragment,
  destination = BarScreenDestination::class
)
@Composable
fun BarScreen(
  args: BarScreenArgs, // optional param
  navigator: NavigationController // optional param
) {
    
     ... }

If you need to navigate to an old Fragment from Compose, you should @LegacyExternalEntryapply the Fragment.

@LegacyExternalEntry(destination = BasScreenDestination::class)
class BazScreenFragment : Fragment() {
    
     ... }
  1. Navigate to the target. Use NavigationControllerto navigate to the target.
val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenDestination(args))

@UiScreenEntryAll functionality included @UiEntry, so if you directly referenced the generated entry class, the following code would also work on the same screen.

val args = BarScreenArgs(...)
navigator.navigateTo(BarScreenEntry.newInstance(args))
  1. Navigate from Fragment. Finally, if you need to navigate to a Compose screen from an old Fragment, you should use a target to get 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)
  }
}

Compose function parameters

Compose functions used @UiEntryor @UiExternalEntryannotated can have any number of parameters, as long as they have default values.

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

Also, there are some special types of parameters that don't need default values ​​because Nibel can figure out how to provide the corresponding instances. Arguments of these special types include NavigationController、ImplementationType, and @UiEntryargs of the same type as in the annotation or target type.

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

A compile-time error will be thrown if the argument types do not match.

Alternatively, the above values ​​can also be obtained as composed local variables.

@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

ImplementationTypeThe resulting entry type varies as specified in the target screen's annotations . Each type is suitable for a specific scenario, which may be one of the following:

  1. Fragment - The generated entry is a Fragment with an annotated Compose function as its content. It makes the Compose screen visible to other Fragments and fragment → composeis essential for navigating the scene.
  2. Composable - Generates a small wrapper class overriding the Compose function. Typically used in compose → composeand compose → fragmentnavigation scenarios, which use this implementation type for markup.

In general, there are several common scenarios for adding a new Jetpack Compose screen to an existing codebase.

Scenario 1 - New Features

The simplest case is to create entirely new functionality in a separate module. In this case, all screens for the feature use Jetpack Compose.

The first screen of a function acts as an external entry, allowing access to that function from other modules. It must be marked as ImplementationType.Fragment. This will ensure that it appears as a Fragment in non-Compose code, thus, easily navigating from the old Fragment to this new functionality.

All subsequent screens in the function should use ImplementationType.Composable. This will improve performance as Fragments will not be generated, resulting in fewer class allocations per screen.

In some cases, you may need to go back from new functionality to old functionality that depends on Fragment. You just need to @LegacyExternalEntryannotate the target Fragment with and NavigationControllernavigate by using its associated target.

Scenario 2 - Extending existing functionality

Another situation requires the insertion of a new series of continuous screens within an existing function. In this case, these screens can be Compose screens even though they are in the middle of the Fragment flow.

The key rules remain the same. The first Compose screen should be marked with ImplementationType.Fragment, while all subsequent screens should use ImplementationType.Composable.

Scenario 3 - Standalone Screen

The third scenario is probably most common during the early stages of Jetpack Compose adoption. In this case, a separate Compose screen is placed in the middle of the old Fragment process.

In this case, you just need to ImplementationType.Fragmentannotate your Compose screens with Compose and treat them as Fragments outside of your Compose code.

custom settings

Nibel offers a variety of customization options in order to adapt it to the needs of a particular project.

Before continuing with this section, it is recommended to review our previous story, which covers Nibel's internal components and the thinking behind its implementation in more detail.

apply theme

For each annotated ImplementationType.Fragmentscreen, Nibel generates a entryclass that is a ComposableFragmentfragment that inherits from. When using Jetpack Compose with fragments, all composable UIs are set in onCreateView. This means that for each new fragment, a theme must be applied explicitly.

// 生成的fragment的基类
// (位于nibel-runtime库的一部分)
abstract class ComposableFragment : Fragment() {
    
    

  @Composable
  abstract fun ComposableContent()

  override fun onCreateView(
    ...
  ) = ComposeView(requireContext()).apply {
    
    
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
    
    
      AppTheme {
    
     // 在这里应用主题
        ...
      }
    }
  }
}

Since this is the base class for third-party libraries, it is not possible to apply custom themes for specific applications directly.

To apply a theme, you can implement one RootDelegate, which will be a class with just a simple composition function. This function will ImplementationType.Fragmentbe called at the root of every annotated screen.

object CustomRootContent : RootDelegate {
    
    

  @Composable
  override fun Content(content: @Composable () -> Unit) {
    
    
    AppTheme {
    
     // 应用自定义主题
      content()
    }
  }
}

Don't forget to call contentthe function to continue building the UI.

Then when configuring Nibel, just apply CustomRootContent.

Nibel.configure(rootDelegate = CustomRootContent)

Navigation specification

NavigationControllerUsed to navigate between screens , as described above . However, navigateTofunctions also provide room for customization.

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

  fun navigateTo(
    entry: Entry,
    fragmentSpec: FragmentSpec<*>, // fragment导航规范
    composeSpec: ComposeSpec<*> // compose导航规范
  )
}

As we already know, Nibel allows navigating between fragment and compose screens in various situations. When navigating from a composable function to a new screen, Nibel internally uses different tools for navigating, depending on the target screen.

If the next screen is a fragment or annotated ImplementationType.Fragmentcomposable function, a fragment transaction will be performed automatically.
If the next screen is ImplementationType.Composablean annotated composable function, Nibel will implicitly use the compose navigation library for navigation.
At any time, you can switch between annotations ImplementationTypeand the code will still compile. However, Nibel will use different underlying tools for navigation. FragmentSpecIt is used to navigate between fragments at the bottom layer, and ComposeSpecit is used to directly navigate between composable functions, which is also implicit.

When it comes to underlying fragment transactions, there may be times when you want more control over the details. For example, use add vs replace, select a custom container id for transactions, etc.

Each instance of a navigation specification contains implementation details of how to perform navigation. Therefore, it can be customized in many ways.

For example, in a fragment transaction, you can use FragmentTransactionSpecthe instance and specify the details of the 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 custom navigation logic.

class CustomTransactionSpec : FragmentTransactionSpec(...) {
    
    

  override fun FragmentTransactionContext.navigateTo(entry: FragmentEntry) {
    
    
    this.fragmentManager.commit {
    
    
      // 自定义fragment事务逻辑
    }
  }
}

Then CustomTransactionSpecnavigate by using .

navigator.navigateTo(
  entry = ..., 
  fragmentSpec = CustomTransactionSpec()
)

For the compose specification, the underlying compose navigation library is used. All navigation targets are dynamically added to ComposeNavigationSpec. You can learn more about how the compose navigation library is used under the hood in our previous post.

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

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

Compatibility with Architecture Components

Modern Android applications use various architectural components such as Hilt、ViewModel. Let's see how to integrate Nibel with them.

You can declare the ViewModel as a parameter of a composable function and hiltViewModelget its instance using Now you can use it in a combination of Hilt-injected ViewModel and Nibel screen.

@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 parameters are automatically available in the ViewModel SavedStateHandle.

in conclusion

With Nibel, you can focus on writing new product features for your application using Jetpack Compose while dealing with codebase compatibility, especially dealing with fragments.

Nibel is highly customizable, so it can be applied to various types of projects and navigation scenarios when using Jetpack Compose.

Github

https://github.com/open-turo/nibel/tree/main/sample
https://github.com/open-turo/nibel

Guess you like

Origin blog.csdn.net/u011897062/article/details/132077233