Router and path-based navigation

path-based navigation was not supported in the original voyager library. Its implementation is derived from routing-compose .

for using path-based navigation you need to add the dependency

implementation "io.github.beyondeye:kbloc-router:$version"

Path-based navigation (i.e. navigation to a Screen with an URL ), is very natural for web apps, but is actually very useful for all platforms, for two main reasons:

  • support for deep links: for app deep links we need a way to associate a Screen to an URL

  • decoupling of an app into independent components: for big applications, where multiple features are developed independently in multiple modules, there is usually a central module that is the only one linked against all other modules, that implement a router that allows to navigate to Screens of any of the modules without the need of knowing anything of the other modules except the URL of the target screen.

We take a slightly different approach from other navigation libraries like the official Android Compose navigation library, and also routing-compose : in our implementation routing is not part of the composition. It is instead a separate operation that it is executed outside the composition that take as input an URL and return a Screen that can be then pushed, as a separate operation, into the navigation stack. This way we gain a better separation between UI rendering and routing logic. It also makes it easier to add animations for screen transitions.

There is only one router for an app, and it is defined where the RootNavigator is (or renderComposableWithNavigator for compose Web). For activating path based-navigation Instead of passing to the RootNavigator a Screen, you pass to it a RouteResolver.

Defining a RouteResolver

Let's see a full example:

/*
    The following screen classes are defined elsewhere
    class MainScreen:Screen
    class TestBasicScreen1:Screen
    class TestBasicScreen2:Screen
    class TestBasicCounterBlocScreen:Screen
    class TestBasicCounterCubitScreen:Screen
    class HelloUser(val userName:String):Screen
*/

//An object where we define both some labels with the route path segments 
//(not required, but recommended) and the RouteResolver itself, that define how each
// URL should be mapped to a Screen instance.
// The mapping 
object AppRoutes {
    val home = "home"
    val hello = "hello"
    val testbasic = "testbasic"
    fun testbasic_(testNumber:Int)="$testbasic/$testNumber"
    val counter = "counter"
    val seg_counter_bloc = "bloc"
    val seg_counter_cubit="cubit"
    val counter_bloc = "$counter/$seg_counter_bloc"
    val counter_cubit = "$counter/$seg_counter_cubit"
    //-----------------
    //the first argument for RouteResolver constructor is the initial route
    val resolver = RouteResolver("/") {
        //route: "/home"
        route(AppRoutes.home) {
            MainScreen()
        }
        route(AppRoutes.hello) {
            //route: "/hello/{string}" where {string} is a string
            string {
                HelloUser(userName=it)
            }
        }
        //route: "/testbasic"
        route(AppRoutes.testbasic) {
            //route: "/testbasic/{int}" where {int} must be an integer 
            int {
                when(it) { //"it" is the parsed integer {int}
                    1 -> TestBasicScreen1()
                    2 -> TestBasicScreen2()
                    else -> MainScreen()
                }
            }
            noMatch {
                MainScreen()
            }
        }
        //route: "/counter"
        route(AppRoutes.counter) {
            //route: "/counter/bloc"
            route(AppRoutes.seg_counter_bloc) {
                TestBasicCounterBlocScreen()
            }
            //route: "/counter/cubit"
            route(AppRoutes.seg_counter_cubit) {
                TestBasicCounterCubitScreen()
            }
            //route selected for all "/counter/<some subroute>" where <some subroute>
            //is not matched with previous explicit rules
            noMatch {
                MainScreen()
            }
        }
        noMatch {
            MainScreen()
        }
    }
}

//for compose web, we pass the RouteResolver in renderComposableInBodyWithNavigator
// that initialize the rootnavigator. 
// For android and desktop platforms we pass the RouteResolver to RootNavigator()
fun main() {
    renderComposableInBodyWithNavigator(AppRoutes.resolver)
}

As you can see we use a custom DSL(that we adopted from routing-compose ) to express matchers for all possible app routes as a hierarchical tree, where each node is a path segment (or noMatch that means to no segment was matched at the current level of the input path) and each leaf is a Screen associated to the associated match tree branch. Url parameters are specified the same way as url path segment matchers. Currently only one parameter per match tree node is supported, with two possible types: int and string

the noMatch clause in the DSL is required only after one or more route clauses.

inside a route clause, after an int or string subroute, it is optional.

How to navigate with a router

Once you have initialized the RootNavigator with a RootResolver you can navigate to an URL by first obtaining a reference to the router and the calling navigate("<URL>")

val router = LocalNavigatorRouter.currentOrThrow
router.navigate("/home")

Under the hood, router.navigate will resolve the Screen associated to the requested URL and push it in the Screen stack of the RootNavigator

Matching multiple path segments at once

The path matching DSL support matching multiple path segments at once. For example

class NotFound:Screen { /* */ } 
class HelloWorld:Screen { /* */ } 

val resolver = RouteResolver("/") {
    route("hello","hi","welcome") {
        route("world","galaxy","universe") {
            HelloWorld()
        }
        noMatch {
            NotFound()
        }
    }
    noMatch { NotFound() }
}

this resolver will return the HelloWord screen for any URL that has any of "hello", "hi", "welcome" as the first path segment and any of "world", "galaxy", "universe" as the second path segment.

Dynamic routes

RouteResolver is executed every time a navigate request is received. So it is possible to incorporate logic that will change the resolved route according to parameters and/or calculations. For example:

val enableHiddenFeature=false
val resolver = RouteResolver("/") {
    route("home") {
        HomeScreen()
    }
    if(enableHiddenFeature) {
        route("hiddenfeature")
        {
            HiddenFeatureScreen()
        }
    }
    noMatch {
        InvalidRouteScreen()
    }
}

Route with Query parameters

When a route is matched it is possible to access the associated query parameters with the variable

parameters:Map<String,List<String>>
  • The map key is the parsed parameter name

  • The map value is the list of parsed value for that parameter name. (In the case that the same query parameter is present more than once in the list)

See for example the following code:

class HomeScreen:Screen { /* */ }
class HelloUser(val name:String,val surname:String):Screen { /* */ }
val resolver = RouteResolver("/") {
    route("/greeting") {
        val params=parameters?.map?: mapOf()
        HelloUser(params["name"]?.first()?:"",params["surname"]?.first()?:"")
    }

    noMatch {
        HomeScreen()
    }
}

In the example above the URL /greeting?name=Albert;surname=Einstein

will be resolved to the screen HelloUser(name="Albert",surname="Einstein")

Redirects

It is possible to specify a redirect for some URL path. The redirect will cause the RouteResolver to be evaluated a second time with new redirected URL path.

class ScreenFoo: Screen { /* */ }
class NotFoundScreen: Screen { /* */ }
val routing = RouteResolver("/") {
    route("foo") {
        noMatch {
            ScreenFoo()
        }
    }
    redirect("bar", "baz", target = "foo")
    noMatch {
        NotFoundScreen()
    }
}

enableCheckForPathSegmentsExtraSlashes flag in RouteResolver

By default RouteResolver when executed will run several checks on the path segments specified as arguments of route, removing, if necessary, slashes at the beginning and end of the path segment definition. If you are sure that you defined the RouteResolver correctly, without extra slashes, you can set this flag to false and avoid some unnecessary calculation.

resolver.enableCheckForPathSegmentsExtraSlashes = false

Hashed vs. Non-Hashed URL Paths

When targeting Compose Web, when the root navigator is initialized, by default is set to use hashed URL paths. Hashed URL paths work without any special server configuration. You can change this behaviour by explicitly specifying the flag:

fun main() {
    renderComposableInBodyWithNavigator(AppRoutes.resolver, useHashRouter=false)
}

Last updated