Navigation - trueangle/Blackbox GitHub Wiki

To support switching between different Views, Blackbox framework defines a @Composable called NavigationFlow.

NavigationFlow is a container presenting series of Views organised into a navigation flow. It provides a declarative description of app Navigation. NavigationFlow is based on fork of Tlaster/PreCompose fully Multiplatform navigation library. The framework uses the navigation library by providing versatile architecture extensions on top of it.

Features:

  1. Simple and declarative Navigation description.
  2. URI powered route naming that is deeplink out of the box.
  3. Support linear and Modal navigation.
  4. Maximised reusability of navigation entries by leveraging Coordinator pattern.
  5. Navigation logic decoupled from the UI.
  6. Navigation transition animations.
  7. Preserves navigation state across app launches in Android and iOS .
  8. Unlimited number of navigation nesting levels.

There are 4 simple steps to define navigation between different Views using NavigationFlow:

  1. Create navigation destinations
  2. Write navigation logic
  3. Declare Navigation DI
  4. Declare NavigationFlow scenes and assign unique URI-like routes

Let’s go through all the steps by looking on the implementation of Home in sample MovieApp.


Create navigation destinations

MovieApp's Home view contains Bottom Navigation with three menu items. Each item correspond to the following black-box Views presented as full size screens:

  1. CinemaList(modifier, config, io)
  2. Seats(modifier, dependencies, io)
  3. TicketSummary(modifier, config, dependencies, io)

Describe navigation logic

For storing navigation logic and keeping it separate from the UI description the framework uses component called Coordinator.

A coordinator encapsulates navigation logic by taking navigation details out of black-box functions, making them independent from navigation context and focused on its own content. This is done by leveraging strict tree-like navigation structure where each node of the tree is independent and completely unaware of navigation context. It enhances testability, reusability, and maintainability of different components in larger apps. In contrast to graph-based navigation solutions such as Android Navigation Component, where each graph node (destination) may have connections to the others forming intricate navigation structure that is barely maintainable.

In general, a coordinator is a mediator between navigation destinations. It takes an event or a result from one destination and based on it, makes a decision on which destination to navigate next or which destination the event should be guided to. To help with this, the framework provides a component called IO<Input, Output>. This component enables two-way (input/output) event-based communication between black-boxes. This ensures more flexible way of communication rather than using @Composable function callbacks or changing function inputs.

If the app has navigation nesting, where one black-box with NavigationFlow contains another black-box with its own Navigation. Their coordinators can be implicitly chained together via black-box interface using IO<Input, Output>. In this case, parent coordinator subscribes to the nested one without knowing its implementation only dealing with the interface. Here is example of Navigation structure of sample MovieApp.

Blackbox navigation structure


The coordinator is defined by extending base Coordinator class. Its API is pretty straightforward:

  1. navigator: Navigator — an interface for controlling NavigationFlow, it has useful methods for controlling navigation back stack.
  2. coroutineScope: CoroutineScope — for tasks that requires async execution. Coordinator may have a logic for complex navigation scenarios. You may also want to save navigation state in database or be able to perform some long running operation.
  3. onDestroy() — the callback that signals the coordinator is about to be destroyed along with black-box scope.

Example of HomeCoordinator, here’s how Featured output is handled.

internal class HomeCoordinator(
    private val homeIO: HomeIO,
    private val featuredIO: FeaturedIO,
    private val trendingIO: TrendingIO,
) : Coordinator() {
//..

init {
     // ...
    handleFeaturedIO()
}

// When featured output event is collected, the coordinator converts it to HomeOutput signalising outer context about events that happened inside Home black-box
private fun handleFeaturedIO() {
        featuredIO.output.onEach {
            when (it) {
                is FeaturedOutput.OnMovieClick -> homeIO.output(
                    HomeOutput.OnMovieClick(
                        it.movie,
                        it.dominantColors
                    )
                )

                is FeaturedOutput.OnBuyTicketClick -> homeIO.output(
                    HomeOutput.OnBuyTicketsClick(
                        it.movie.title ?: it.movie.name ?: ""
                    )
                )
            }
        }.launchIn(coroutineScope)
    }

//..
}

The Navigator implementation is based on a Stack data structure. To switch between destinations there are two useful methods:

  1. navigateTo() — pushes an item on top of a navigation stack. The method has RouteOptions for more precise stack management.
  2. back() — pops an item from the stack. Back to the movieApp example, to switch between different menu items of Bottom Navigation, HomeCoordinator has a callback method which is called every time the bottom navigation item is clicked.
internal class HomeCoordinator(/*..*/) : Coordinator() {

   //..

fun onBottomNavActionClick(clickedTabRoute: HomeRoutes) {
        when (clickedTabRoute) {
	    HomeRoutes.Featured -> navigator.navigateTo(
                route = HomeRoutes.Featured.routePattern, // The result destination 
                routeOptions = RouteOptions(
                    popUpTo = RouteOptions.PopUpTo( 
                        route = HomeRoutes.Featured.routePattern, // The destination of popUpTo
                        inclusive = true // Whether the popUpTo destination should be also popped from the back stack.
                    )
                )
            )
        //..
        }
    }

//..
}

Declare Navigation DI

The movieApp uses custom HomeScope component for declaring DI hierarchy of NavigationFlow. To do so, it extends base component class FlowScope. FlowScope is an extension of ViewScope designed to hold a coordinator instance and automatically manage its lifecycle.

internal class HomeScope(
    homeDependencies: HomeDependencies,
    homeIO: HomeIO
) : FlowScope() {

    val featuredIO by lazy { FeaturedIO() }
    val trendingIO by lazy { TrendingIO() }

    val featuredModuleDependencies by lazy {
        FeaturedDependencies(
            homeDependencies.movieRepository,
            homeDependencies.genreRepository
        )
    }

    val trendingDependencies by lazy { TrendingDependencies(homeDependencies.movieRepository) }
    val watchlistDependencies by lazy { WatchlistDependencies(homeDependencies.movieRepository) }

    val ticketingFactory = homeDependencies.ticketingFactory

    override val coordinator by lazy { HomeCoordinator(homeIO, featuredIO, trendingIO) }
}

There are simple scenarios where navigation implementation doesn’t require to declare Scope and DI. To support that, there is a predefined component called BasicCoordinator. The component does not have any implementation, it only provides navigator instance. To use the component in your black-box, just call:

val coordinator = rememberCoordinator(key = "AuthFlowCoordinator") { BasicCoordinator() }
//..
coordinator.navigator.navigateTo("/some_route")

Declare NavigationFlow

The last step is to put it all together and declare @Composable function for Home black-box

@Composable
internal fun Home(modifier: Modifier, dependencies: HomeDependencies, homeIO: HomeIO) {

    // Scope that holds Coordinator and DI
    val homeScope = rememberScope { HomeScope(dependencies, homeIO) }

    Scaffold(
        modifier = modifier,
        bottomBar = { HomeBottomBar(homeScope.coordinator as HomeCoordinator) }
    ) {
        NavigationFlow(
            modifier = Modifier.fillMaxSize(),
	    // Initial route
            startDestination = HomeRoutes.BottomBar.Featured.RoutePattern,
	    // Coordinator that decouples navigation logic from UI
            coordinator = homeScope.coordinator,
	    // Whether to persist navigation state between app launches
            persistNavState = true
        ) {
	    // can be scene or dialog
		scene(
		        // Scene's route path
			route = HomeRoutes.BottomBar.Featured.RoutePattern,
		        // Navigation transition for this scene, this is optional
			navTransition = NavTransition()
		) {
		// Scene content
                Featured(
                    modifier = Modifier,
                    dependencies = homeScope.featuredModuleDependencies,
                    io = homeScope.featuredIO
                )
            }

           // ...
        }
    }
}
⚠️ **GitHub.com Fallback** ⚠️