Skip to main content

Android Navigation

The Basics

Navigation is performed using Google's Android Jetpack Navigation. Okay, How?

Step 1 - Add a new Routable definition into ScreenRoute(See ScreeRoute) sealable:

object MyCustomScreen : ScreenRoute("my-screen")
object MyCustomModal : ModalRoute("my-modal")

Step 2 - Add a new Destination in the kotlinDSL AppNavGraph(See AppNavGraph) using your Routable definition:

fragment<MyCustomFragment>(ScreenRoute.MyCustomScreen.pattern)

or

dialog<MyCustomDialog>(ModalRoute.MyCustomModal.pattern)

Step 3 - Navigate using your Routable(See Routable) definition:

navController.navigate(ScreenRoute.MyCustomScreen.route)

or

navController.navigate(ModalRoute.MyCustomModal.route)

Before beginning please familiarise yourself with Android Jetpack's Navigation component’s Kotlin-DSL type-safe builders. If you are unfamiliar with this feature of Google’s library or the library as a whole, it is STRONGLY RECOMMENDED that you start there:

https://developer.android.com/guide/navigation/design/kotlin-dsl

Firstly we should note the current application architecture.

The outermost layer of the application is the React Native application and activity, hosted within is MainFragment (See MainFragment) which serves as navigation host layer. Inside MainFragment is a NavHost (See NavHost reference reference and Design your navigation graph that supplies the native codebase with a NavController (See NavController reference and Android Developer - Create a navigation controller) and a container for the our Fragment destinations.

Navigation throughout the Entain Native Android App is performed using NavGraph (See NavGraph reference) definitions and NavController actions.

As we are supporting React Native screen rendering we are hosting all our Jetpack Compose entry points in Fragment (See Fragment)) containers, in addition to a custom ReactFragment(https://git.neds.sh/technology/code/native/android/-/blob/main/app/src/main/java/com/entaingroup/mobile/ui/react/ReactFragment.kt) container that our React Native screens are presented as.

In an ideal world, these Fragment containers will be removed from the application when the React Native layer is removed and Jetpack's Navigation component swapped out for Jetpack Compose Navigation. This possibility is out of reach for now.

Routable Types

Routable

The Routable interface defines a protocol for defining routing patterns used in Jetpack Navigation's Kotlin DSL NavGraph. The primary purpose of this interface is to provide a standard way of specifying route patterns, destination IDs, and placeholder usage within navigation destinations. Here's a technical summary of its methods and properties:

  • pattern: String: This property represents the route pattern used in a NavGraph. It serves as both a lookup mechanism for Destination and a unique identifier for each routing path.
  • route: String: This property used when performing navigation operations. It is a read-only property that returns the same value as the pattern property by default but can be overridden if parameterised placeholders are defined in the pattern and the Routable needs a route with param defaults.
  • destinationId: Int: This property generates and returns a unique hash code based on the specified route or pattern. It is mainly used when Jetpack Navigation is still employing integer IDs for navigation destinations, although it is gradually being replaced with string routes in newer versions.

This interface allows developers to adhere to a consistent convention when defining routing patterns and their corresponding unique identifiers within the context of Jetpack Navigation's Kotlin DSL Nav Graph, thus enhancing code readability and maintainability across navigation destinations.

To keep our definition lists tidy, Routable implementations are categorised into 3 class types: ScreenRoute(https://git.neds.sh/technology/code/native/android/-/blob/main/core/navigation/src/main/java/com/entaingroup/mobile/navigation/routes/ScreenRoute.kt) – Used to define standard screen destinations. ModalRoute(https://git.neds.sh/technology/code/native/android/-/blob/main/core/navigation/src/main/java/com/entaingroup/mobile/navigation/routes/ModalRoute.kt) – Used to define modal/dialog destinations. ParentRoute(https://git.neds.sh/technology/code/native/android/-/blob/main/core/navigation/src/main/java/com/entaingroup/mobile/navigation/routes/ParentRoute.kt) – Used to define root nodes (Nested NavGraphs) within our NavGraph tree.

ScreenRoute

ScreenRoute’s are a sealed list of Routable objects that we use to specify screen destinations. A simple ScreenRoute might look like this:

data object MyCustomScreen : ScreenRoute(pattern = "simple/pattern")

However if the destination requires arguments passed into it, a more complete definition might look like:

data object MyCustomScreen: ScreenRoute(
pattern = "pattern/{customArg1}/{customArg2}",
) {
override val route = "pattern/no-item/false" // override `route` if you have a default parameterised navigation route

fun routeWithParams(arg1: String, arg2: Boolean) = "pattern/$arg1/$arg2"
}

In addition, you can also create ScreenRoute definitions (See RnScreenName) for React Native screens using the RnScreen subclass:

data object MyRnScreen : RnScreen(RnRouting.RnScreenName.EXAMPLE_RN_SCREEN_NAME)

This will automatically define a static pattern String, construct route default, and provide a routeWithParams(uriEncodedParams: String) function for the object.

Navigation using a ScreenRoute definition would look like:

navController.navigate(ScreenRoute.MyCustomScreen.route)

Or

navController.navigate(ScreenRoute.MyCustomScreen.routeWithParam("abc"))

Note: RnScreen’s routeWithParams function expects a json serialised object as a URI encoded String. To help facilitate this a Map<*, *>.toUriArg(): String extension function has been provided.

ModalRoute

ModalRoute’s are a sealed list of Routable objects that we use to specify modal/dialog destinations. A simple ModalRoute might look like this:

data object MyCustomDialog : ModalRoute(pattern = "simple/pattern")

However, if the destination requires arguments passed into it, a more complete definition might look like:

data object MyCustomDialog: ModalRoute(
pattern = "pattern/{customArg1}/{customArg2}",
) {
override val route = "pattern/no-item/false" // override `route` if you have a default parameterised navigation route

fun routeWithParams(arg1: String, arg2: Boolean) = "pattern/$arg1/$arg2"
}

In addition, you can also create ModalRoute definitions for React Native screens using the RnModal subclass:

data object MyRnModal : RnModal(RnRouting.RnScreenName.abc123)

This will automatically define a static pattern String, construct route default, and provide a routeWithParams(uriEncodedParams: String) function for the object.

Navigation using a ModalRoute definition would look like:

navController.navigate(ModalRoute.MyCustomScreen.route)

Or

navController.navigate(ScreenRoute.MyCustomScreen.routeWithParam("abc"))

Note: RnModal’s routeWithParams function expects a json serialised object as a URI encoded String. To help facilitate this a Map<*, *>.toUriArg(): String extension function has been provided.

ParentRoute

ParentRoute’s are a sealed list of Routable objects that we use to specify the root nodes in our NavGraph tree. A good example of these are the Nested NavGraphs that hold the bottom “Tab” groups of Routable definitions.

A ParentRoute might look like this:

data object MyCustomParent : ParentRoute(pattern = "mycustom-root")

RnRoutes

When React Native destinations need to be added to the Back Stack (see Android Developer Docs) they must be mapped to a location on the application's AppNavGraph. These locations require a navigation pattern/route definition which has been specified in RnRouting (See RnRouting) as:

"react/{reactKey}/{reactName}/{reactParams}"

The RnRouting utility provides fun createRoute(screenName: String? = null): String function that builds patterns for Routable with reactKey, reactName, and reactParams placeholders.

It is STRONGLY RECOMMENDED that you use the provided RnScreen and RnModal Routable definitions instead of directly creating these in the NavGraph.

AppNavGraph

This section relates to Entain’s Native-Android App’s use of Android Jetpack's Navigation component’s Kotlin-DSL type-safe builders. If you are unfamiliar with this feature of Google’s library, it is STRONGLY RECOMMENDED that you start there:

https://developer.android.com/guide/navigation/design/kotlin-dsl

Using Routable Definitions

Navigation Destinations should be defined in the NavGraph using ScreenRoute, ModalRoute, or ParentRoute definitions.

For instance:

fragment<MyCustomFragment>(ScreenRoute.MyCustomScreen.pattern)

or

dialog<MyDialogFragment>(ModalRoute.MyCustomDialog.pattern)

If you have a Routable pattern definition that makes use of parametrisation, be sure to define navArgument builders for those too, for example (see Google docs above for more):

fragment<MyCustomFragment>(ScreenRoute.MyCustomScreen.pattern) {
navArgument("myArg1") { type = NavType.StringType }
navArgument("myArg2") { type = NavType.BoolType }
}

Try to ensure that Destinations are correctly defined in the relative route locations. For example, a racing sub screen would be accessible from the Racing bottom Tab, and would therefore be defined under the ParentRoute.Racing Nested NavGraph definition:

navigation(route = ParentRoute.Racing.pattern, startDestination = ScreenRoute.Racing.pattern){
fragment<RacingHomeFragment>(ScreenRoute.Racing.pattern)

//TODO your new definition goes here
}

React Native Destinations

Undefined RnRoutes

Out of the box the NavGraph has been set up with a wildcard pattern Destination that will capture any undefined RnRoutes (see RnRouting). This handler will open a ReactFragment and pass the ReactArgs to it.

Adding Known RnRoutes to the NavGraph

To add a Routable defined RnScreen or RnModal to the NavGraph, two convenience builders have been created:

reactModalRoute(T : ModalRoute.RnModal)

and

reactScreenRoute(T : ScreenRoute.RnScreen)
note

Both these builders currently produce the same output, as a modal is rendered the same as regular screen, in a ReactFragment. The screen options passed to the NativeAppView determine whether the screen displays modally or not. Modal presentation just hides the app bar and bottom tabs as DialogFragments are not supported with RN content.

ReactRedirects

As React Native driven navigation events are passed over the event bridge, they are parsed into a RnRoute (see RnRouting) and sent to the native NavController. As we work through the product and replace these React Native screen destinations with Native implementations we will need to parse these RnRoutes to our new ScreenRoute and ModalRoute definitions.

ReactRedirectsImpl (See ReactRedirectsImpl) is this route mapper. To add a redirect map simply add a redirection definition into the redirectionMap dictionary.

As we transition from React screens to native ones, an entry can be added to the redirectionMap contained within ReactRedirectsImpl. When navigating to a React screen, this redirection will route the user to the new native screen. A redirection might look something like:

RnRouting.RnScreenName.TheRnScreenName to { _ -> ScreenRoute.MyNativeRoute.route }

Or with param resolution:

RnRouting.RnScreenName.TheRnScreenName to { params ->
ScreenRoute.MyNativeRoute.routeWithParams(
params.getString("param1", default = ""),
params.getBoolean("param2", default = false),
...
)
},

Redirections can also be feature toggled:

RnRouting.RnScreenName.TheRnScreenName to { _ ->
if(!featureFlags.getFeatureFlag("FEATURE_FLAG_KEY")) return null
...
return ScreenRoute.MyNativeRoute
}

Or:

RnRouting.RnScreenName.TheRnScreenName to { _ ->
if(featureFlags.getFeatureFlag("FEATURE_FLAG_KEY")){
ScreenRoute.MyNativeRoute.route
} else {
ScreenRoute.MyNativeAltRoute.route
}
}

DeeplinkDefinitions

This class supplies the application with all the possible deeplinks that a URI can be matched against. This List of definitions is typed as EntainDeepLink. DeeplinkDefintions (See DeeplinkDefinitions) has all the deeplink dependencies injected and provides data lookup tables for parameter parsing.

EntainDeepLink (See EntainDeepLink) serves as a base class for other deep link-related entities, such as RnRouteDeepLink and RoutableDeepLink. It has the following abstract properties:

  • pattern (of type String): A string that represents the URI pattern this EntainDeepLink matches.

  • pathSegmentMatcher (of type Map<Int, PathSegmentPredicate>): A map where the Int keys represent wildcard indices and the corresponding values are custom matcher functions defined by the subclass. This is used for processing dynamic parameters in the deep links.

  • constParams (of type Map<String, Any>): A map containing constant parameters that should always be passed when this EntainDeepLink is triggered.

RnRouteDeepLink and RoutableDeepLink classes extend EntainDeepLink and offer additional functionality related to their specific usage contexts:

  • React Native (see RnRouting) screens in the case of RnRouteDeepLink; and
  • General routing for RoutableDeepLink;

Both RnRouteDeepLink and RoutableDeepLlink have a parseRoute method that takes a URI object as input and returns a route string. This is then used by the navigation system to direct the user to the appropriate screen or content within the application.

RnRouteDeepLinks have their parseRoute prebuilt for them. RoutableDeepLinks default behaviour is to use its routable.route definition however this can be overridden by passing in a custom uriMapper (see below).

PathSegmentPredicate

This typealias represents a lambda function that takes in a String (path segment) as an argument and returns a Boolean value. This is used for creating custom matchers for wildcard parameters while parsing URI patterns. If a matcher isn’t supplied for a wildcard index that wildcard path param is skipped during pattern matching.

Usage Example

To illustrate how these components can be utilised, consider the following example of creating a deep link to navigate to a specific "CategoryScreen":

Assuming that CategoryScreen is a Routable defined destination and implements Routable interface:

RoutableDeepLink(
pattern = "/a/path/:categoryId"
routable = ScreenRoute.CategoryScreen,
// map that contains a matcher for ":categoryId" (index = 2) wildcard
pathSegmentMatcher = mapOf(2 to { pathParam -> ValidCategoryMather.contains(pathParam)}),
constParams = mapOf("beAwesome" to true),
// [Routable]'s may have custom navigation route builders, if not set [Routable.route] is used
uriMapper = { uri, params -> routable.myRoute(params["categoryId"] as String) }
)

Assuming that CategoryScreen is a RnRouting destination (defined or not):

RnRouteDeepLink(
pattern = "/a/path/:categoryId"
screenName = RnRouting.RnScreenName.categoryScreen,
//map that contains a matcher for ":categoryId" (index = 2) wildcard
pathSegmentMatcher = mapOf(2 to { pathParam -> ValidCategoryMather.contains(pathParam)}),
constParams = mapOf("beAwesome" to true)
)

PatternParser

This class provides static methods for parsing and processing URI patterns and parameters. It supports wildcard parameters and custom matchers defined in EntainDeepLink subclasses. Lazily initialised during pattern matching.

After a URI has been determined to be a RnRouteDeeplink in the MainActivity deeplink hander, the resulting path is checked against the ReactRedirects definitions.

EntainNavController

EntainNavController is a wrapper around Android's NavController. Its internal NavController is sets once in This component provides a single entry point for navigating between screens or dialogs within an application, making it easier to manage navigation logic and inject it into ViewModels(See https://developer.android.com/topic/libraries/architecture/viewmodel) for unit testing.

Injecting in a ViewModel

class MainViewModel @Inject constructor(private val entainNavController: EntainNavController) : ViewModel() {
}

Define a destination route

Native screen:

data object MyCustomScreen : ScreenRoute("my-screen")

Native modal:

data object MyCustomModal : ModalRoute("my-modal")

React screen:

data object MyCustomReactModal : RnScreen("my-modal")

React modal:

data object MyCustomReactModal : RnModal("my-modal")

Add parameters to a screen:

 data object MyCustomScreen : ScreenRoute(
pattern =
createRouteString(
routeName = ScreenRoute.MY_SCREEN,
args = listOf(NavArgs.MyCustomScreen.ARGS1, NavArgs.MyCustomScreen.ARGS2),
optionalArgs = mapOf(NavArgs.MyScreen.ARG to null),
isPattern = true,
),
) {
fun routeWithParams(mandatoryArgs1: String, mandatoryArgs2 : ComplexArg, optionalArg: String) =
createRouteString(
routeName = ScreenRoute.MY_SCREEN,
args = listOf(mandatoryArgs1, mandatoryArgs2),
optionalArgs = mapOf(NavArgs.MyScreen.ARG to optionalArg),
)
}

Add a destination screen in the navigation graph

Navigation destinations can be defined in AppNavGraph (See AppNavGraph) to handle different types of screens, including native modals, native screens, and React modals or screens. Here’s how to set them up:

React Modal:

reactModalRoute(ScreenRoute.MyCustomScreen.pattern)

React Screen:

reactScreenRoute(ScreenRoute.MyCustomScreen.pattern)

Native Screen:

fragment<Screen>(ScreenRoute.MyCustomScreen.pattern)

Native Modal:

dialogFragment<Screen>(ScreenRoute.MyCustomScreen.pattern)

Adding a screen with parameters:

fragment<Screen>(ScreenRoute.MyCustomScreen.pattern) {
argument(NavArgs.ScreenRoute.ARG1) {
type = NavType.StringType
defaultValue = ""
}
argument(NavArgs.ScreenRoute.ARG1) {
type = NavType.IntType
defaultValue = 1
}
argument(NavArgs.NativeToolbox.TOOLBOX_PRODUCTS) {
type = NavArgType.ComplexArgArgType
defaultValue = ComplexArg(listOf())
}
}

val ComplexArgArgType: NavType<ComplexArg?> =
object : NavType<ComplexArg?>(true) {
override fun put(
bundle: Bundle,
key: String,
value: ComplexArg?,
) {
bundle.putParcelable(key, value)
}

override fun get(
bundle: Bundle,
key: String,
): ComplexArg? {
return bundle.getParcelable(key)
}

override fun parseValue(value: String): ComplexArg {
return Json.decodeFromString(value)
}
}

Try to ensure that Destinations are correctly defined in the relative route locations. For example, a racing sub screen would be accessible from the Racing bottom Tab, and would therefore be defined under the ParentRoute.Racing Nested NavGraph definition: A nested graph has its own route, allowing you to navigate from one nested graph to another. When you navigate to a nested graph, it will restore the state it was left in.

navigation(route = ParentRoute.Racing.pattern, startDestination = ScreenRoute.Racing.pattern){
fragment<RacingHomeFragment>(ScreenRoute.Racing.pattern)
//TODO your new definition goes here
}
class MainViewModel(private val entainNavController: EntainNavController) : ViewModel() {
fun onItemSelected() {
entainNavController.navigate(ScreenRoute.MyCustomScreen.pattern)
}
}

with params :

class MainViewModel(private val entainNavController: EntainNavController) : ViewModel() {
fun onItemSelected() {
entainNavController.navigate(ScreenRoute.MyCustomScreen.routeWithParams(mandatoryArgs1, mandatoryArgs2, optionalArg))
}
}

Unit testing

@RunWith(JUnit4::class)
class MainViewModelTest : BaseViewModelTest() {

// Create a mock instance of EntainNavController
private val mockEntainNavController = mock(EntainNavController::class.java)

// Create an instance of MainViewModel with the mock EntainNavController
private val viewModel = MainViewModel(mockEntainNavController)

@Test
fun `when onItemSelected then navigate to nativeScreenRoute`() {
// When the onItemSelected method is called
viewModel.onItemSelected()

// Then verify that the navigate method is called with the correct route
verify(mockEntainNavController).navigate("nativeScreenRoute")
}
}