Navigation Bridge
Introduction
The Entain mobile application remains to be a React Native (RN) application at its core, and this will continue to be the case for the duration of the rebuild. The native application is built on top and embedded into the RN view hierarchy with the use of native UI components. It includes a native navigation controller that handles all navigation actions, even those originating in RN code.
This design facilitates a single entry point in the existing RN app where new native code is instantiated and where navigation synchronisation occurs. Further, the native code can get access to RN screens as children passed to the native UI component.
From the point-of-view of the RN code this hierarchy is quite simple, it just renders a screen container component and passes it screens as children, with the complexities of navigation logic and UI elements provided by native code. However, some complexities are introduced as we integrate this solution with the highly extensible React Navigation library. As this is the navigation library originally used by the RN app, mirroring its interfaces minimises changes required across the existing suite of RN screen components.
Native UI Components
Native UI Components are the building blocks of RN applications that produce Android and iOS native views. The components have native implementations, however they provide an interface so that RN code can customize their behaviour. The framework also facilitates the sending of events in both directions over the JS-native bridge.
We utilise 2 native components, one to represent the native application, and the other to represent an individual screen. This combination is based on the React Native Screens relationship between a Screen Container, such as a stack, and a Screen. In our case the screen container is providing a bespoke navigation structure that mimics the old app's structure of stacks within tabs within a stack, along with a shared app bar and bottom navigation tabs.
App Component
NativeAppView
is the native UI component that provides the native app implementation. Once created it initialises the native navigation host and controller and renders the initial state. It is responsible for sending and receiving events to synchronise navigation state with RN, and receiving and displaying RN screen views.
The interface in RN supports a number of props and commands.
App Props
children
- array - The component supports having children passed in but expects all of them to be instances ofEntainScreenView
.- This is how the native code gets references to RN screen views.
screenOptions
- object - A map of RN screen name to a subset of supported screen options.- This is all pre-defined in RN so passing it to the native app up-front allows for layout changes and animations to be triggered before the screen is rendered and received from the RN framework.
- All options override default behaviour so are optional:
presentation
- string -modal
,fullScreenModal
andtransparentModal
are treated as modals that overlay the entire screen, default is a regular screen embedded between the app bar and bottom nav.headerShown
- boolean - Overrides the defaulttrue
for a regular screen andfalse
for a modal.animation
- string -none
,default
,slide_from_bottom
,slide_from_right
are the expected values.
onNavUpdate
- function - A callback triggered via an event every time the native navigation state changes.- The event payload contains the navigation state:
routes
- array - The list of RN routes to be rendered, eachRoute
consisting of:key
- string - The unique identifier for this route in the back stack.name
- string - The screen name.params
- object - optional - The parameters to be passed to the screen.
index
- integer - identifies the currently focused route in the array, with-1
if none have focus. This is the case when a native screen is being shown.canGoBack
- boolean - informs RN if there is history in the back stack.
- The event payload contains the navigation state:
onNavBack
- function - A callback triggered via an event when the native code wants to navigate back.- This gives the RN screens an opportunity to intercept and prevent back navigation.
- If the navigation should proceed then the
navigate
command is sent with theGO_BACK
action.
App Commands
create
- Triggers the creation of the AndroidMainFragment
- not used on iOS.- This pattern is necessary to know when it is safe to perform Fragment transactions, following the React Native documentation.
navigate
- Sends a navigationAction
to the native app, which consists of:type
- string - The identifier for the action to perform, such asNAVIGATE
orGO_BACK
, supported actions are listed below.payload
- object - An optional payload that is different for each action.source
- string - An optional route key that identifies the screen that triggered this action.
When child views are passed to the app component we need to identify them so we can match views against routes sent in onNavUpdate
. This is done using a route key and leads on to the screen component.
Screen Component
EntainScreenView
is the native UI component wrapping a RN screen component. The route key is set as a prop so that it can be identified by the app component.
Screen Props
In the React Native Screens library screen-related options are delivered as props on the screen component. This is not suitable for the solution described here as the native code initiates navigation to RN screens before the screen view has been instantiated by the RN framework. Therefore, there can be a delay between navigating to a screen and receiving the screen props. This is described in further detail below. Screen props should instead be passed to the NativeAppView as screen options.
routeKey
- string - Thekey
of the route that this screen represents.- This key originates from the
Route
object passed from the native app.
- This key originates from the
Bridge Communication
The following graphic illustrates the communication over the bridge via the native UI components. The subtle differences between iOS and Android are due to how the RN framework has been implemented on each platform, however the overall flow is the same.
Sample Code
Here's a simplified example of rendering the native components in RN. The routes to render are stored in local state, set by the onNavUpdate
callback and iterated over to render screens. The mapping between route names and screen components is not included for brevity. The onNavBack
callback just calls the navigate
command to confirm the back action should proceed. Sample screen options are provided.
import {useRef,useState} from "react";
function NativeApp() {
const ref = useRef(null);
const [routes, setRoutes] = useState([]);
useEffect(() => {
UIManager.dispatchViewManagerCommand(
findNodeHandle(ref.current),
UIManager.NativeAppView.Commands.create, []);
}, []);
return (
<NativeAppView
style={{ flex: 1 }}
ref={ref}
screenOptions={{
ModalJoin: { presentation: 'modal' },
GroupModeGroup: { headerShown: false },
Betslip: { presentation: 'modal', animation: 'slide_from_right' }
}}
onNavUpdate={(event) => setRoutes(event.nativeEvent.routes)}
onNavBack={() =>
UIManager.dispatchViewManagerCommand(
findNodeHandle(ref.current),
UIManager.NativeAppView.Commands.navigate, [{ type: 'GO_BACK' }])
}>
{routes.map((route) => {
<EntainScreen key={route.key} routeKey={route.key} style={StyleSheet.absoluteFill}>
// Render component for route.name passing route.params
</EntainScreen>
})}
</NativeAppView>
);
}
Bridging Navigation
React Navigation is the navigation framework of choice for React and RN applications alike, is highly customizable, and was already in use by the Entain RN app. Therefore, we built on the above solution by integrating the native components into React Navigation, taking inspiration from React Native Screens.
This is done through the creation of a single custom navigator and router to replace those in use in the RN app.
The following simplified sample shows the navigation structure pre-rebuild using out-of-the-box navigators nested to get the desired UX: Stack
> Drawer
> Tab
> Stack
.
const AppNavigator = () => {
return (
<RootStack.Navigator>
<RootStack.Screen name="Main" component={() => {
<Drawer.Navigator name="Main" drawerContent={BetslipScreen}>
<Drawer.Screen name="Main" component={() => {
<Tab.Navigator>
<Tab.Screen name="HomeTab" component={() => {
<MainStack.Navigator>
<MainStack.Screen name="Home" component={HomeScreen} />
{SharedScreens}
</MainStack.Navigator>
}} />
</Tab.Navigator>
}} />
</Drawer.Navigator>
}} />
{ModalScreens}
</RootStack.Navigator>
);
};
The following sample shows how the above sample is adapted to use our custom navigator. Note the flat structure, all available screens are defined under a single navigator, passing responsibility onto the native implementation to provide the UX of multiple tabs and back stacks.
const EmpeddedAppNavigator = () => {
return (
<NativeApp.Navigator>>
<NativeApp.Screen name="Blank" component={BlankScreen} />
<NativeApp.Screen name="Home" component={HomeScreen} />
{SharedScreens}
{ModalScreens}
</NativeApp.Navigator>
);
};
There is an addition of a Blank
screen, which is used when no RN screen is currently focused. This is the case when a native screen is being presented. There is also the removal of some screens such as Main
and HomeTab
, the native implementation needs to be aware of these screens so that mapping can be performed in the case that they are the target of a navigation action originating within an existing RN screen.
Custom Router
NativeAppRouter
is the custom router implementation that performs routing logic for the native rebuild. In order to keep the RN side as flexible as possible, and minimise required maintenance, the routing logic is minimal. The desire is to make the RN router as dumb as possible so that the native app has full control over navigation. There are some additions to the navigation state to facilitate this:
pendingAction
- object - In order to proxy navigation actions to the native app they are stored in the navigation state and sent over the bridge on the next render of the navigator. See App Commands for theAction
structure.canGoBack
- boolean - Is used to know whether a back action can be sent to the native app.mountedRoutes
- array - An array to store navigation routes that we wish to remove from theroutes
array but keep mounted. These are concatenated with theroutes
in the custom navigator for rendering.
Custom Navigator
NativeAppNavigator
is the custom navigator implementation that renders the above native UI components, managing their props and sending commands. It has the following responsibilities:
- Renders the
NativeAppView
native component. - Sends the
create
command when the component is mounted. - Observes changes to the
pendingAction
in the navigation state and sends thenavigate
command. - Provides an
onNavUpdate
callback that dispatches theNATIVE_SET
action to the router to overwrite the navigation state. - Provides an
onNavBack
callback that dispatches aGO_BACK
action to the router, giving screens the opportunity to prevent it. - Provides
screenOptions
by iterating over all screen definitions provided as children and compiling the options into a map. - Iterates over the current
routes
andmountedRoutes
in the navigation state and renders each screen component, wrapped by anEntainScreenView
component.
Navigation Actions
A subset of navigation actions are supported based on what was used by the old RN application. This is to minimise refactoring required in RN code while avoiding the substantial effort that would be required to support the full React Navigation API.
Supported Actions
Unless otherwise stated, the following actions are ignored by the custom router and simply saved in the navigation state to be sent to the native app.
NAVIGATE
- A common action to navigate to a screen with parameters. This is implemented natively to behave like a stack navigator and pop back to an existing screen with the same name.name
- string - Name of the route to navigate to.params
- object - Screen params to pass to the destination route.
GO_BACK
: A common action to go back to the previous screen. There are no parameters.- The custom router applies the action by removing the current route from the
routes
array and adding it to themountedRoutes
array. This allows the React NavigationbeforeRemove
event listeners to be triggered before sending the action to the native app, while keeping the routes mounted until the nextonNavUpdate
event.
- The custom router applies the action by removing the current route from the
SET_PARAMS
- A common action to update params for the current screen.params
- object - New params to be merged into existing route params.- The custom router applies the params change to the navigation state and ensures the action
source
is populated before saving the pending action. This ensures the native app knows which route to update.
REPLACE
- A stack action to replace the current screen at the top of the stack.name
- string - Name of the route to navigate to.params
- object - Screen params to pass to the destination route.
PUSH
- A stack action to add a screen on top of the stack, always navigates forward.name
- string - Name of the route to navigate to.params
- object - Screen params to pass to the destination route.
POP
- A stack action to go back a number of screens.count
- integer - the number of screens to pop.- The custom router applies the action by preserving the popped routes in
mountedRoutes
in the same way asGO_BACK
. This implementation assumes that all screen being popped are RN ones and that the order of the routes provided by the native app are ordered correctly, which is a risk.
POP_TO_TOP
- A stack action that returns to the first screen in a stack.- The native app replicates the nested stack structure of the original app, so that if a modal screen is on top of the stack only all the modals are popped. This is often used when tapping the 'X' to close a modal.
Custom Actions
NATIVE_SET
- A custom action that is only to be used by the native navigator when it receives anonNavUpdate
event containing the native navigation state. This is passed directly to the router to overwrite the RN navigation state.routes
- array - SeeonNavUpdate
event payload.index
- integer - SeeonNavUpdate
event payload.canGoBack
- boolean - SeeonNavUpdate
event payload.
RESET_HOME
- A custom action that fills a gap with the unsupportedRESET
method to clear the navigation history for all tab stacks and return to theHome
screen. There are no parameters.
Unsupported Actions
Notable navigation actions that are not supported. In these cases refactoring may be required in the RN codebase so as not to break the old UX.
RESET
- A common action that allows developers to rewrite history. It is not feasible to support this natively as RN does not have the full picture of the navigation state.JUMP_TO
- A tab action that jumps to an existing route in the tab navigator. This wasn't used in the RN codebase.OPEN_DRAWER
- A drawer action that opens the drawer pane.CLOSE_DRAWER
- A drawer action that closes the drawer pane.TOGGLE_DRAWER
- A drawer action that opens or closes the drawer pane based on its current state.
The drawer actions are not supported as the Betslip screen was changed to be a modal. The RN code was refactored to navigate to the Betslip screen or go back instead of opening and closing the drawer.
Screen Delivery
Screen delivery from the RN framework to native code can happen in one of 2 ways, asynchronously or synchronously.
Asynchronous
When first navigating to a RN route it can take time for the RN screen component to be mounted, the corresponding native view to be instantiated and received by the native view manager. During this time the native application has already begun transitioning to the new screen. A loading view is rendered until the screen view is received, then the screen view replaces the loading view.
The LoadingView
is implemented as a native UI component so that it can also be rendered within RN screen components. This allows for a smoother transition from having no screen to having a screen that is still loading data.
Synchronous
Subsequent navigations to that same route, such as by navigating back or switching tabs, result in the native view being rendered immediately. This is because all RN routes in the back stack remain mounted and the native view managers retain a reference to their native views.