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)
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 DialogFragment
s 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
}
}
Deeplinks
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
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 thisEntainDeepLink
is triggered.
RnRouteDeepLink and RoutableDeepLink
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.
RnRouteDeepLink
s have their parseRoute
prebuilt for them.
RoutableDeepLink
s 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
}
Navigate to a Screen or Modal
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")
}
}