Ballast Navigation FAQs
Why make yet another routing library?
The first reason, and why most people create new libraries, is that I was not happy with any of the existing solutions out there. It's my opinion that Android's official navigation patterns (both the old, manual navigation, and the newer Androidx Navigation library) encourage patterns in navigation that tend to lead to bad application architecture. And unfortunately, most of the recent routing libraries I've tried seem to be copying that similar navigation patterns, bringing Android's anti-patterns with them into the KMPP and Compose world. Compose and MVI as an ecosystem work because they're not trying to copy old UIs patterns, so why are we still thinking that the old style of Navigation works?
Most notably, Android's navigation system encourages a pattern of navigating to one screen, and then to another, loading specific data on those screens as you go. Whether this is done with navigation from Activity-to-Activity, Fragment-to-Fragment, or by defining a specific navigation order through a declarative NavGraph explicitly linking destinations to one another, this style of navigation usually leads to data being loaded on a specific screen vs being loaded when requested, regardless of the screen requesting it. This becomes problematic when trying to implement deep-links, when one needs to add explicit handling of the deep-link case to load the data that would have been loaded on an earlier screen with the "happy path" navigation. Instead, I believe the web's pattern of every screen being defined by a URL and the user may jump directly to any given screen encourages a better pattern where you cannot assume any given sequence of screens was visited, and thus you must push the loading of data out of the UI and into the Repository layer, where it belongs.
The second reason that I created this library is that I realized routing is really just an exercise in state management, and Ballast is already very good at that. Routing libraries typically build up a subsystem for managing updates to the state, and then build their routing logic within that, but because they're fundamentally routing libraries and not state management libraries, the actual state management aspects of them are lacking.
But Ballast is already proven to be a stable, robust, and predicable state management library, and it was relatively simple to add navigation on top of what already exists here. And in the process, Ballast Navigation gains all the features of the other Ballast extension libraries for free (like logging, debugging, or undo/redo), both current and future, which would otherwise either be hardcoded in hacky ways into those other libraries, or else completely absent.
Is this library type-safe?
It depends on what you mean by type-safe. If, by that, you mean that routing is done with data classes that are just passed around, then no, this library is not type-safe. It works by parsing a URL to extract data from the path and query parameters, and those values are ultimately passed around as Strings, not as strongly-typed objects.
But if by type-safe you mean that when loading a route, you can easily ensure that the parameters exist and are of a certain type, then yes, this library does support that. Route matching is strict and you manually define which parameters must be present, and it offers a set of delegate functions to make it easy to extract those parameters in a type-safe manner, preventing you to navigating to a route if the value is of an incorrect type. This style of routing is not checked at compile time, unlike passing around a data class, but it actually has some other advantages that the data-class argument-passing lacks:
- By forcing you to represent the data passed between routes as a URL, it encourages the best-practice of only passing the minimal amount of data needed for the new route to load the full objects it needs. Quoting from the documentation of Androidx Navigation, "In general, you should strongly prefer passing only the minimal amount of data between destinations. For example, you should pass a key to retrieve an object rather than passing the object itself...If you need to pass large amounts of data, consider using a ViewModel as described in 'Share data between fragments'."
- You get deep-linking for free, since effectively every navigation request is a deep-link. If you have to pass configuration/argument objects, you would have to manually parse a deep-link URL to that object before attempting to navigate with it, which can cause problems if your URL-parsing logic differs from the rest of your application's navigation logic.
- KSP and Code Generation, or type-safe wrapper functions, can be easily added on top of this library, while it's more difficult to take a library built with strong type-safety/code generation in mind and use it in any other way. This eases the burden of evaluation or incremental adoption. For example, generating type-safe Directions functions and arguments delegates could be done fairly easily, and the core routing APIs were intentionally designed to allow that possibility, though it is not on the current roadmap for this library. This would be a very welcome addition from the community, if someone wanted to create this as a KSP plugin!
Does this library integrate with Compose?
Yes! Everything you need to integrate Ballast Navigation into Compose is provided in the core artifact, without any need
for a special Compose integration library. Ballast Navigation ultimately just manages a backstack of URLs and emits it
to the UI as a StateFlow
, which can be easily collected from Compose. Anything else that you would typically want from
a "Compose integration" is almost certainly too specific to your use-case to be included within the core Ballast
Navigation library, but is easy enough for you to implement yourself.
But when people typically ask this question, what they really are asking is, "does it live entirely within Compose code, and give me automatic transition animations and stuff like that". And the answer to this question is no, Ballast Navigation is intentionally kept outside the UI. A community-designed library to connect Ballast Navigation to Compose for things like Animations would be a very welcome addition, however!
For now, you can achieve basic transition animations with existing Compose UI APIs like AnimatedContent
. Or if someone
wanted to help bring rjrjr/compose-backstack up-to-date with the latest Compose version and make it work with
Desktop, that would be the perfect companion library to Ballast Navigation!
How do I sync destinations with the browser address bar?
When using Ballast Navigation in the browser, you may wish to show the current destination URL in the browser's address bar to help the user understand the structure of your application, as well as allowing them to edit the URL to jump to a specific screen, or save it as a bookmark.
This is included as built-in functionality, for synchronizing the router state with the browser's address bar in both directions: applying router state to the address bar, and passing changes made by the user back into the router. It will also take care of reading the current URL when the page first loads, and navigating directly to that route.
All that's needed to support this functionality is to add an Interceptor to the Router during creation. Both hash-based routing and the History API are supported.
Browser Hash
Hash-based routing is the "older" mechanism for routing in a Single Page Application (SPA), though it should not be considered obselete. In particular, one would have to set up server-side redirects to make the History API work, which may not be feasible, in which case Hash-based routing is the only option left.
Hash-based routing can be added with the BrowserHashNavigationInterceptor
, or with the withBrowserHashRouter
helper
function.
class RouterViewModel(
viewModelCoroutineScope: CoroutineScope
) : BasicRouter<AppScreens>(
config = BallastViewModelConfiguration.Builder()
.withBrowserHashRouter(RoutingTable.fromEnum(AppScreens.values()), AppScreens.Home)
.build(),
eventHandler = eventHandler { },
coroutineScope = viewModelCoroutineScope,
)
Browser History
Hash-based routing is done with the #
portion of the URL, and isn't as user-friendly to read and share as with just
a normal URL path. The Browser History API allows websites to edit the entire URL shown in the address bar
and navigate forward and backward through the screens of your SPA with the browser's native buttons, so users wouldn't
even know that you'ure doing front-end routing.
The caveat is that using the history API requires your hosting server to redirect all URLs to the SPA's main page. There are plenty of tutorials online for configuring your server to do this, so I will not cover these details here.
Routing with the History API can be added with the BrowserHistoryNavigationInterceptor
, or with the
withBrowserHistoryRouter
helper function. Unlike the Hash interceptor, the History interceptor needs to know which
portion of the URL path is just the page itself, and which is used for routing within the application, so you must pass
the base path for this page into the interceptor.
class RouterViewModel(
viewModelCoroutineScope: CoroutineScope
) : BasicRouter<AppScreens>(
config = BallastViewModelConfiguration.Builder()
.withBrowserHistoryRouter(RoutingTable.fromEnum(AppScreens.values()), basePath = "/app", initialRoute = AppScreens.Home)
.build(),
eventHandler = eventHandler { },
coroutineScope = viewModelCoroutineScope,
)
I would recommend using the BrowserHashNavigationInterceptor
when developing locally and switch it out for
BrowserHistoryNavigationInterceptor
when deploying to production, so you don't have to mess with your Webpack dev
server configuration. There are several ways to determine if your running in production, such as checking the value of
window.location.host
, setting a property as a hidden element in the page's HTML, or using something like
Gradle BuildConfig plugin to inject a value from the build pipeline into the Kotlin code. But if you do want to
use the BrowserHistoryNavigationInterceptor
in development, routing-compose has instructions for getting your
environment set up.
How does this library handle transition animations?
It doesn't. Ballast Navigation just manages the backstack, but you can apply transition animations yourself when handling route changes. Ballast Navigation intentionally keeps itself separate from the UI to allow maximum flexibility and avoid bloat in its API.
How do I do nested sub-graphs?
"Nested sub-graphs" in terms of pure navigation really aren't necessary, and is something of an anti-pattern that has become popularized by the Androidx Navigation library. There's not really a good reason to group a bunch of destinations and set up a hierarchy of routers/navControllers, which just adds unnecessary complexity without much benefit.
One useful feature of Android's Nested NavGraphs, however, is the ability to scope a ViewModel to the sub-graph rather than to an individual screen. This allows you to carry information between multiple screens in a "flow" without needing to serialize it all in the Repository layer and manage when it should be reused/cleared. If the ViewModel data is ephemeral and the ViewModel is discarded once the sub-graph is exited, then scoped ViewModels automatically clean up that data after use.
Right now, this feature is not supported in Ballast, and I'm still exploring possible options for handling this kind of
"sub-graph" scoping. You can use RouteAnnotations
to define the bounds of a "sub-graph" and handle the purely
navigational use-case, but it's left up to you to determine how to manage the scope of ViewModels within those graphs.
Scoping ViewModels to the backstack (or anything else, really) is probably more appropriately handled by your DI
library's scope functionality, anyway, rather than Ballast itself.
How do I save/restore the backstack?
Automatic state restoration is intentionally left out of this library, because I did not want to tie it directly to any
serialization mechanism or library. But this is easy enough to achieve on your own, all you need to do is persist the
original destination URLs and then restore them within an Input. This example shows how it might be done (if you are
using RouteAnnotations
, you'll want to (de)serialize those as well).
fun saveBackstack(router: Router<AppScreen>) {
val backstackUrls: List<String> = router.observeStates().value.map { it.originalDestinationUrl }
saveUrlsToSavedState(backstackUrls)
}
fun restoreBackstack(router: Router<AppScreen>) {
val backstackUrls: List<String> = getUrlsFromSavedState()
router.trySend(RouterContract.Inputs.RestoreBackstack(backstackUrls))
}
Automatically saving/restoring the state can be done with the help of the Ballast Saved State module, by creating an adapter like this:
/**
* Automatically save and restore the state of the Router with any route changes. Do not pass an initial route to the
* BallastViewModelConfiguration.Builder.withRouter()` when using this adapter, as it will handle setting the initial
* route instead, and may conflict with the initial route set through that function.
*
* The actual serialization and persistence of the backstack is delegated through [prefs].
*
* If you are also using the Ballast Undo/Redo module for forward/backward navigation, set [preserveDiscreteStates] to
* true so the backstack is restored through individual [RouterContract.Inputs.GoToDestination] Inputs to capture each
* intermediate state. If not, it can be set to false so that a single [RouterContract.Inputs.RestoreBackstack] is used
* instead.
*/
public class RouterSavedStateAdapter<T : Route>(
private val routingTable: RoutingTable<T>,
private val initialRoute: T?,
private val prefs: Prefs,
private val preserveDiscreteStates: Boolean = false,
) : SavedStateAdapter<
RouterContract.Inputs<T>,
RouterContract.Events<T>,
RouterContract.State<T>> {
public interface Prefs {
var backstackUrls: List<String>
}
override suspend fun SaveStateScope<
RouterContract.Inputs<T>,
RouterContract.Events<T>,
RouterContract.State<T>>.save() {
saveAll { backstack ->
prefs.backstackUrls = backstack.map { it.originalDestinationUrl }
}
}
override suspend fun RestoreStateScope<
RouterContract.Inputs<T>,
RouterContract.Events<T>,
RouterContract.State<T>
>.restore(): RouterContract.State<T> {
val savedBackstack = prefs.backstackUrls
if(savedBackstack.isEmpty()) {
initialRoute?.let { initialRoute ->
check(initialRoute.isStatic()) {
"For a Route to be used as a Start Destination, it must be fully static. All path segments and " +
"declared query parameters must either be static or optional."
}
postInput(
RouterContract.Inputs.GoToDestination(initialRoute.directions().build())
)
}
} else if(preserveDiscreteStates) {
savedBackstack.forEach { destinationUrl ->
postInput(
RouterContract.Inputs.GoToDestination(destinationUrl)
)
}
} else {
postInput(
RouterContract.Inputs.RestoreBackstack(savedBackstack)
)
}
return RouterContract.State(routingTable = routingTable)
}
}
Why does this library force Ballast MVI state management?
The technical implementation of this library actually does allow one to use a different mechanism for managing state. All Navigation classes and features are completely separate from any core Ballast APIs, and it's entirely possible to lift the Navigation code and place it into another State Management library.
But if that is true, why is it coupled to the Ballast library?
The main reason is that Routing needs some kind of state management solution in order to work properly. Things could end up very poorly if your app attempts to make multiple navigation attempts quickly and the Router state gets corrupted, and you users will be very unhappy with their experience using that app. The Router state needs to be protected from unwanted changes and ensure things are being processed safely, so the options for building the routing library then become:
- Keep the Navigation library completely separate from any State Management library
- Couple it to a specific State Management library
- Provide adapters to all the popular State Management libraries, so developers can choose which one they want to use
If I went with option 1), then the reality is that I would need to build some minimal state-management system specific to that library in order to allow its usage without pulling in a larger State Management library. It cannot simply exist without state management, so it would need to be shipped with a minimal (and probably poorly-implemented solution) instead to avoid any external dependencies. This would then mean it is lacking in features one might expect (like logging, or browser-like forward/back buttons), or else have those features hardcoded into that minimal system to support those core use-cases that are beyond the base Navigation system. This minimal solution is simply not going to be a robust, extensible platform for state management that one would find in a dedicated State Management library like Ballast. And having built Ballast already, if I were to build a State Management solution just to ship with the navigation library, then I would basically just create Ballast again for it. Ballast is a pretty lightweight library, so it just makes more sense to couple this navigation library to Ballast.
And as for the question of why not provide adapters to other libraries, the answer is that this is a maintenance burden that I do not want to support. I do not use any other State Management libraries, myself, so I am not the best person to maintain an adapter using Ballast Navigation with those other libraries. I also intentionally crafted this library to work well with the other Ballast modules, providing that additional functionality that I do not want to hardcode into the navigation system itself. Using Ballast Navigation with those other solutions loses those features, and would require a lot of extra documentation and testing to ensure everything's working properly with each library. It also makes it more difficult for users to get started, as they could easily be overwhelmed at the thought of choosing a State Management library that they may never interact with outside of Navigation. If I keep this Navigation library coupled to Ballast, it's easy enough for users to get started without needing to know any of the intricacies of State Management or specific libraries, they can just use the snippets in the documentation and focus on the Navigation library itself, trusting that it is tested and known to work as they expect.
If you would like to use Ballast Navigation without the core Ballast State Management library, you should be able to
exclude the ballast-core
dependency from Gradle and wire it up to your own state management solution, as long as you
do not reference anything from the com.copperleaf.ballast.navigation.vm
package. While this is not an
officially-supported way to use this library and I do not intend to keep any documentation for this use-case, I do
intend to keep the Navigation APIs free from any core Ballast APIs, so please let me know if something does not work if
you try this. At a high-level, this snippet
posted to the Ballast Slack channel might help you get started.
How do I do "up" navigation?
Most UI platforms have a distinction between "backward" and "upward" navigation. In a nutshell, "backward" navigation
refers to going back to where you just came from, popping an entry off the backstack. "Upward" navigation means
navigating to a specific Route that is considered the "parent" of the current destination. In terms of URLs, if you were
previously at /users/me
and navigated to your last post /post/1234
backward navigation (Android's hardware back
button/gesture) brings you to /users/me
, while upward navigation (the arrow in the toolbar) brings you to /posts
.
Put in another way, a "backward" navigation is dynamic and determined by the history of screens you've already visited.
Upward navigation is static, navigating to a predefined destination. In most apps, the flow of navigation through the
application should match the route hierarchy, so a "back" and "up" action should do the same thing, but deep-links could
cause them to behave differently.
Ballast Navigation does not explicitly handle the use-case of "upward" navigation. Because the upward navigation is
statically determined, one would have to explicitly describe the hierarchical structure of your routes if you wanted to
have a single RouterContract.Inputs.NavigateUp()
action, which not only becomes cumbersome, but may not be entirely
possible within the Kotlin type system (for example, with recursive routes or cycles in the graph). It also becomes a
huge maintenance burden with the introduction of graph algorithms into the Navigation library, and something that is
easy to mess up or get wrong for the end user.
But why do we need an RouterContract.Inputs.NavigateUp()
action at all? The main idea is to navigate from one screen
to its parent screen, and with a statically-defined graph, that parent route would also be statically determined. So
rather than including a NavigateUp
action and massively complicating this library, it's recommended to instead just
set the action on the toolbar back button to RouterContract.Inputs.ReplaceTopDestination()
with the intended parent
route. This actually makes it easier to understand your application's navigational flows, while keeping the core Routing
mechanism simple and easy to work with.