Howdy folks, I’ve been recently hinting about a ne...
# ballast
c
Howdy folks, I’ve been recently hinting about a new Routing library, built on top of Ballast, that I’ve been working on, and I’ve now got a stable-ish preview ready for it! Documentation for this library is available here, and I’ve also rewritten the example projects to use this new navigation system and split them into separate projects to make them easier to understand. Ballast Navigation is not officially published yet, but I’d love to get your initial thoughts on its API and documentation before releasing it. I’m currently targeting an official release the first or second week of December, and once it’s published the API will be locked, so now’s the time to request changes or suggest improvements! Feel free to reach out if you need any help getting started or if anything is confusing.
👀 3
c
Oh, using enums to represent routes is very clever! I've been trying for a while to be able to statically refer to routes as well as having a nice DSL for routers and this seems like a great solution!
Does Ballast handle upward navigation? I don't see it mentioned in the documentation.
c
Yeah, it was a pretty big revelation and improvement to the library when I realized how well enums work for this. I really tried to make sealed classes work so that an individual route could be used as a Receiver, but without code gen, it was just not going to work. But I did intentionally define the
Route
and
NavGraph
to be interfaces so that eventually this could be supported, and it would be user’s choice as to which they wanted to use. This library doesn’t distinguish between “up” and “back” like Android does. To be honest, I’ve never much understood the difference between the two, I just know if you do the wrong thing, your navigation won’t always work like you expect it should…
c
I agree with you that it's important to have routes represented as URLs and not data classes to help with deep linking, however I disagree that this cannot be made with type safety. In a personal prototype of navigation, I have a class that looks like this;
Copy code
class MyParam : Parameters() {
  val id by parameter<Int>()
  val date by parameter<String>()
}
which is essentially just building your template, with type information during declaration.
c
Yeah, that’s the kind of thing I imagine would be done with code-gen, taking a URL and generating a class like that, or even manually writing those classes yourself right now. It’s just not on the current roadmap, I want to make sure the core library works well enough on it’s own. It can be pretty easy to mask a library’s poor implementation with code gen
c
To be honest, I’ve never much understood the difference between the two.
In a nutshell, if you were previously at
/users/me
and navigated to your last post `/post/1234`: • backward navigation (back button) brings you to
/users/me
• upward navigation (arrow in the top left corner) brings you to
/posts
Most of the time though, they do the same thing, because most navigation is towards a child screen. The main example is when navigating to the Android settings from the Quick Settings tiles in the notifications: backward brings you back to the home screen, whereas upward brings you to the settings menu (except of course it's not the case anymore in Android 13).
I agree, my example was without code generation. Thanks for the idea, I'll look into Ballast :)
c
Great example, that clears things up so much! It definitely is harder to understand the relationship between the two with normal Android, but putting it in terms of URLs makes perfect sense. And now that you’ve mentioned it, I can think of some improvements that could be made to better support an upward navigation
c
Basically you have to maintain two navigation stacks, the backwards one which you already do (and is the complicated one), and the upward one that's known statically since it's just the parent of any route. The hard part is how to deal with parameters in that case: which parameters are available to the parent route? I'm trying to figure out a way for the children routes to accept subtypes of the parameters of their parents, but I'm not sure it's the correct route.
c
I’d actually say the upward navigation is the more complicated one, since it does need to be resolved statically. Attempting to infer an “upward route” wouldn’t be too hard, to just drop the last path segment, but I know that’s probably not going to be good enough Just to get your thoughts on this: does upward navigation action really need its own dedicated API, or would it be suitable to just recommend setting the back arrow handler to do a
ReplaceTopDestination
with the “parent destination”?
c
I guess you could easily provide a helper function that does the replacing like you said. I'm not an expert on navigation either, all of this is just what I understood from reading the Android/Material documentation. One thing that isn't clear to me is what a backward navigation after an upward navigation does. Said otherwise, is the upward navigation a forward navigation to a parent route (e.g. JS'
pushState
), or a lateral navigation to a parent route (e g. JS'
replaceState
)? I'm not sure.
c
Yeah, I’m not quite sure yet either, but I will give it some research to see what I can find out. This is exactly the kind of info I was looking to get before I publish this library, so thank you very much for bringing this topic up!
x
Good job on the library and the docs Navigation is always very opinionated and having more options is always good. Will definitely try it out 👍🏾 I really despise defining string based routes for navigation (like e.g:-
feature/1/details/section
) and one of the reasons why I don't quite like androidx.compose-navigation. Enums definitely makes this much more palatable but not a big fan of using strings to represent routes - although I do understand specific use-cases why this is necessary (deep-links for instance). Perhaps I'm biased but string-based routes always feels like a web-pattern that is out-of-place in Android. Android is android and we definitely don't have any address bar that the users can see or type in, so to model routes like so is a futile exercise 😅 Another criticism I have is the introduction of a type hierarchy to Compose; where views themselves don't have concrete types but are pure functions. I really adore the fact Compose didn't introduce a type hierarchy (unlike Traditional views, SwiftUI or Flutter) and instead opted in for a more functional approach with composable functions. Yet, most navigation libraries that are geared towards compose (like voyeger, appyx, decompose and now ballast-navigation) use type hierarchy for screens. I do understand that most of these libraries offer more than just navigation - they also offer state management in a lifecycle aware manner. However these tend to come with an opinionated architecture like MVI - which is not bad by itself - but sometimes you just want navigation without opting in to a whole different architecture. Back when my app was only Android - I really hated fragment navigation (for

many other reasons

) and instead opted in for Conductor which had a much simpler API). I stayed far far away from androidx.navigation-components For my multiplatform app - I wanted to be able to do-my-own architecture and not be bound to a particular architecture that is enforced by a routing library. This is why I didn't go all in on the libraries I mentioned above. Ultimately I landed on using decompose - as it does provide a simple router which doesn't enforce any architecture and it doesn't introduce a type hierarchy. Anyways, hope you take these criticisms with a grain of salt 🙂 Hoping to see Ballast Navigation come to life someday and actually get to use it.
c
@xxfast Thank you for your thoughts on this! I very much appreciate the critical review, honestly even more than positive feedback, because it helps point out things I may have missed and really dial in the things that set it apart and make it more apparent the specific audience that I’m targeting. I’m not trying to make the library general enough to make everyone happy, I’m working toward something specific which may not right be for you, and that’s totally fine. I have this philosophy on the entire Ballast library, and even have a page dedicated to helping folks understand the similarities and differences between Ballast and other state management libraries, so they can find one that better suits them if Ballast does not. And since this navigation library is built on top of Ballast, the same philosophy carries through here, too. I’ve got some specific more thoughts on the decisions made for this library that I’ll share in a bit, but first I’m just curious what you mean by a “type hierarchy for screens”. You mentioned in the middle of your comment that Decompose is a library that uses the “type hierarchy for screens”, but at the end said you like Decompose because it doesn’t, so I think i’m just not sure what exactly you’re referring to.
x
Sorry - let me clarify. What I mean by a type hierarchy is when you have extend/subclass a library class to define your own screens. For example on voyeger you have to do this
Copy code
class HomeScreen : Screen {
  @Composable
  override fun Content() {
    val screenModel = rememberScreenModel<HomeScreenModel>()
    // ...
  }
}
Here we have to extend
Screen
to use the library. This brings in a type hierarchy to a view system that otherwise type-free. Another example from Appyx
Copy code
class RootNode(
    buildContext: BuildContext
) : Node(
    buildContext = buildContext
) {
    @Composable
    override fun View(modifier: Modifier) {
        Text("Hello world!")
    }
}
Here we have to extend
Node
to use the library. This brings in a type hierarchy to a view system that otherwise type-free. This is also true for Decompose
Copy code
class SomeParent(componentContext: ComponentContext) : ComponentContext by componentContext {
    val counter: Counter = Counter(childContext(key = "Counter"))
}
But the important distinction here (as @Arkadii Ivanov pointed out) is that this is not tied to the view system (as the views are pluggable). Decompose has made a clear distinction between state management and navigation and it doesn't force you to subclass/extend these library classes just to use navigation aspects of the library. In the end, with decompose - all you need are functions. No type hierarchy necessary
Copy code
@Composable
fun MainContent() {
    val navigation = remember { StackNavigation<Screen>() }

    ChildStack(
        source = navigation,
        initialStack = { listOf(Screen.List) },
        handleBackButton = true,
        animation = stackAnimation(fade() + scale()),
    ) { screen ->
        when (screen) {
            is Screen.List -> ListContent(onItemClick = { navigation.push(Screen.Details(text = it)) })
            is Screen.Details -> DetailsContent(text = screen.text, onBack = navigation::pop)
        }
    }
}
I know it does sound like a contradiction in my above statement - but both statements are true
I’m not trying to make the library general enough to make everyone happy, I’m working toward something specific which may not right be for you, and that’s totally fine.
I definitely agree with this sentiment 🙂 I know I have done this too. It takes a lot of discipline to build up opinions on how to do something. However I think it is worth considering keeping navigation and state-management decoupled as much as possible. Although, I do understand that this is easier said than done
c
That makes sense, thank you for clearing that up. While I see the point you’re trying to make, I think I disagree that the separation between state/View in Decompose makes it free from the type-hierarchy. I really just am not a fan of Decompose, and while I didn’t want to call it out directly in the documentation because so many people do like it and depend on it, I’ve found that it’s really just not for me. I don’t want to demonize the library, but I do want to provide what I believe to be a better alternative. I used Decompose for a while, made a pretty sizable CfD app with it (multiple windows and dozens of panels in an IDE-like application), but I felt like its mission to be unopinionated actually just led to a lot of boilerplate and passing around objects belonging to the library that I didn’t want to, even if it wasn’t strictly required by the library. And it brought in a bunch of extra stuff like lifecycles and state restoration that I really didn’t want, but was still forced to deal with all over the place. In contrast, I would actually argue that, even though the Ballast Navigation enums need to implement the
Route
interface, it actually doesn’t impose its types anywhere else beyond that. The only thing that the
Route
interface is there for, is to tell the Router how to match a URL to a Route, and from there all you need to do “routing” is collect a
StateFlow
and switch on a big exhaustive
when
block of that enum, neither of which requires any special type hierarchy. Decompose does have a “pluggable” View system, which decouples the Views from the library types, but Ballast Navigation doesn’t even need that. Any connection from the Router state to your View doesn’t need to be “pluggable”, because you essentially end up with a raw
StateFlow<List<YourEnumRoute>>
, which you can do anything with very simply without needing special support for a given UI system. In fact, the example apps include Android using this navigation system with Fragments completely without Compose, and nothing changes on the part of the navigation library or how you setup and use the Routes to support that. And to your point about “keeping navigation and state-management decoupled as much as possible”, it actually is a fully decoupled navigation library, but in interest of keeping the documentation and library opinionated, I’ve omitted that from the docs. Ballast itself is the state management engine, but I was very intentional about keeping anything from the core library outside of anything needed for the routing aspects beyond the initial setup. Part of the goal of the library is to be opinionated and avoid documentation that details every possible way to use the library, which makes it hard to get started. Instead, I’ve focused on the primary use-case which gets you set up simply and quickly. That said, I’ll post a snippet below that actually fully implements the routing logic without any hard connection to the base Ballast library, so you actually could plug it your preferred state management system (or just leave it purely in Compose as-is). But actually using it like this, it loses many of the things that I would deem pretty crucial to proper routing, especially transactional updates, protection of the state, and the ability to request route changes outside of the UI (for example, the repository layer getting a 401 and redirecting to the login screen). So it could be used in a way without directly tying it to Ballast at all, but proper state management is pretty critical to a good router and I wanted Ballast and its other modules like undo/redo to be a big feature of using this router, along with making the library work out-of-the-box without needing to go find/build your own state management subsystem to integrate with it.
Copy code
@OptIn(ExperimentalBallastApi::class)
fun MainContent() {
    Column {
        val navGraph = remember { NavGraph.fromEnum(AppScreen.values()) }
        var routerState: Backstack<AppScreen> by remember {
            val initialRoute = AppScreen.PostList
            val initialRouteUrl = initialRoute.directions()
            val initialDestination = Destination.Match(initialRouteUrl, initialRoute)
            mutableStateOf(listOf(initialDestination))
        }

        MainContent(routerState) { input ->
            val navigator = object : BackstackNavigator<AppScreen> {
                override val backstack: Backstack<AppScreen>
                    get() = routerState

                override fun matchDestination(
                    destinationUrl: String,
                    extraAnnotations: Set<RouteAnnotation>
                ): Destination<AppScreen> {
                    return navGraph.findMatch(UnmatchedDestination.parse(destinationUrl, extraAnnotations))
                }

                override fun updateBackstack(block: (Backstack<AppScreen>) -> Backstack<AppScreen>) {
                    routerState = block(routerState)
                }
            }

            with(input) {
                navigator.navigate()
            }
        }
    }
}

@OptIn(ExperimentalBallastApi::class)
@Composable
fun MainContent(
    routerState: Backstack<AppScreen>,
    updateRoute: (RouterContract.Inputs<AppScreen>) -> Unit,
) { 
    // ...
}
x
Sorry for the late reply. Regards to Decompose: I agree - it being un-opinionated does mean that there are some boilerplate that you have pass around. In my case I've added some additional abstractions on top (in form of extensions) to simplify this further and I'm not sure if those work for all use cases. Apart from those inconveniences - I find Decompose really pleasant and it filled my specific use case quite perfectly. In regards to
Route
, I have strong opinions against string-based routes in Android - but I understand there's specific use cases that warrants it. But if I have to have string-based routes - your approach is a very good implementation of this; where I can rely on some type safety. Looking at the further examples you pointed out - looks like
BalllestNavigation
can be fully decoupled from the ballest state management and with a little bit of code you can make it work with your own state management library - which is pretty sweet 👍 I agree that your navigation library doesn't enforce types for your screens the same way voyager or Appyx does. Perhaps it is better to point this out in the docs - but I respect if you want to keep your docs opinionated toward ballest state management