Boat
Boat is an implementation of a scoped, simple and composable way to navigate
val moduleFirst: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
}.effect()
val moduleSecond: BoatNavigationEffect = Boat {
compose("/second") { SecondActivity::class }
}.effect()
val moduleThird: BoatNavigationEffect = Boat {
compose("/third") { ThirdActivity::class }
compose("/fourth") { FourthActivity::class }
}.effect()
val appNavigation: BoatNavigationEffect = moduleFirst + moduleSecond + moduleThird
suspend fun main(context: Context) {
navigate(context, appNavigation)
}
suspend fun navigate(context: Context, navigation: BoatNavigationEffect) {
navigation.navigate(context, "/first") // Navigating to FirstActivity
navigation.navigate(context, "/second") // Navigating to SecondActivity
navigation.navigate(context, "/third") // Navigating to ThirdActivity
navigation.navigate(context, "/fourth") // Navigating to FourthActivity
}
Concept
Boat is build on top of a simple concept: It's all about effects composition. Boat provides some effects that are built on top of its compositions, all effects must respect some laws:
- All identities are immutable and composition doesn't break it.
- Every composition create a new effect with correct configuration.
- During the composition none of composed effects are affected.
Effects
As seeing before, effects are totally composables and Boat provides some of them:
Navigation effect
BoatNavigationEffect
is an effect that navigates to a specific route, this effect is built on top of Boat
type with a effect()
function, and we can simple call navigate
function to start the navigation:
val appNavigation: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
compose("/second") { SecondActivity::class }
}.effect()
suspend fun main(context: Context) {
appNavigation.navigate(context, "/second") // Navigating to SecondActivity
}
Since we work with effects composition with all immutable and scoped laws preserved, we are free to play as we want:
val appNavigation: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
compose("/second") { SecondActivity::class }
}.effect()
suspend fun main() {
myExternalModule(appNavigation)
}
...
private val myModuleNavigation: BoatNavigationEffect = Boat {
compose("/my_module_1") { MyModuleFirstActivity::class }
compose("/my_module_2") { MyModuleSecondActivity::class }
}.effect()
suspend fun myExternalModule(navigation: BoatNavigationEffect) {
registerInMyDI(navigation + myModuleNavigation)
}
suspend fun registerInMyDI(navigation: BoatNavigationEffect) {
// work with a composed navigation
}
To compose effects in Boat we use +
operator, in this example we just created an internal navigation effect for a external module and composed with an injected navigation effect, by that my external module can navigate to /my_module_1
, /my_module_2
, /first
and /second
routes with no impact to appNavigation
that never knows about /my_module_1
and /my_module_2
routes.
Route contract effect
BoatRouteContractEffect
validates if N
routes are composed in boat navigation identity during the composition:
val navigation: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
compose("/second") { SecondActivity::class }
}.effect()
val appRouteContracts: BoatRouteContractEffect = RouteContract {
compose("/first")
compose("/second")
compose("/third")
compose("/fourth")
}.effect { "Routes $this should be composed in navigation" }
val appNavigation: BoatNavigationEffect = navigation + appRouteContracts // java.lang.IllegalArgumentException: Routes /third, /fourth should be composed in navigation
In this example we've created a navigation with two routes and a contract with 4 routes, by creating this contract effect what we want is to make sure that composed navigation effect identity compose all these routes that we've declared in contract. In this case we receive a throw of a exception with our custom message saying that composed navigation effect doesn't satisfies our contract. Once we compose the other two missing routes, we're good:
val navigation: BoatNavigationEffect = Boat {
...
compose("/third") { FirstActivity::class }
compose("/fourth") { SecondActivity::class }
}.effect()
...
val appNavigation: BoatNavigationEffect = navigation + appRouteContracts // OK!
This is useful when we have for example multiple modules and in its navigation injection we want to establish a contract saying that my module need N
routes to be composed in the injected navigation effect.
Middleware effect
BoatMiddlewareEffect
provides a way to intercept and write extra custom effects over a navigation. As example let's create a solution that prints "Navigating to route X..."
before and Navigated to route X
after the navigation every time we navigate to a route:
val navigation: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
compose("/second") { SecondActivity::class }
}.effect()
val printMiddleware: BoatMiddlewareEffect = boatMiddleware { route, _, _, _, navigate ->
println("Navigating to route $route...")
navigate()
println("Navigated to route $route")
}
val appNavigation: BoatNavigationEffect = navigation + printMiddleware
In this example we've created a middleware that prints before and after navigation, navigate()
function represents the moment that effect navigates. It's triggered when we call navigate()
function from the BoatNavigationEffect
:
appNavigation.navigate(context, "/first")
> "Navigating to route /first..."
*Navigation occurs*
> "Navigated to route /first"
navigate()
function is just a representation of the navigation continuation that means we can't modify navigation parameters.
We can also compose more middlewares
val navigation: BoatNavigationEffect = Boat {
compose("/first") { FirstActivity::class }
compose("/second") { SecondActivity::class }
}.effect()
val printMiddleware: BoatMiddlewareEffect = boatMiddleware { route, _, _, _, navigate ->
println("Navigating to route $route...")
navigate()
println("Navigated to route $route")
}
val Tracker.middleware: BoatMiddlewareEffect get() = boatMiddleware { route, _, _, _, navigate ->
track(route)
navigate()
}
suspend fun main(context: Context, tracker: Tracker) {
val appNavigation: BoatNavigationEffect = navigation + printMiddleware + tracker.middleware
}
Composed middlewares means that we have closures with effects, then in this case we have this behavior:
val appNavigation: BoatNavigationEffect = navigation + printMiddleware + tracker.middleware
appNavigation.navigate(context, "/first")
> "Navigating to route /first..."
> Tracking "/first"
*Navigation occurs*
> "Navigated to route /first"
Side effects controlling
We're working with effects in everywhere, performing a lot of uncontrolled side effects to our program. Thinking on that all Boat
effects has its Unit
returned functions marked with a suspend
operator. Once we're declaring our impure functions with suspend
operator, Kotlin compiler "declares" in compile time for us a Continuation<A>
extra parameter that proves that we know how to handle success and failure results from our effect. With that we can control our program in a way that Boat
is not allowed to perform effects out of a pure environment (inside another suspend
function or a continuation).
Enhancement
Next studies and improvements:
- Integration with Compose
NavHost
API - Use
FIR
to validateBoatRouteContractEffect
in compile time