mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-02-13 17:47:32 +08:00
feat: add hook for deep link support
This commit is contained in:
committed by
Satyajit Sahoo
parent
f0b80ce0f6
commit
35987ae369
@@ -1,4 +1,4 @@
|
||||
import { NavigationState } from './types';
|
||||
import { NavigationState, PartialState } from './types';
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
@@ -22,7 +22,7 @@ export type Action =
|
||||
}
|
||||
| {
|
||||
type: 'RESET';
|
||||
payload: Partial<NavigationState>;
|
||||
payload: PartialState<NavigationState>;
|
||||
source?: string;
|
||||
target?: string;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function replace(name: string, params?: object): Action {
|
||||
return { type: 'REPLACE', payload: { name, params } };
|
||||
}
|
||||
|
||||
export function reset(state: Partial<NavigationState>): Action {
|
||||
export function reset(state: PartialState<NavigationState>): Action {
|
||||
return { type: 'RESET', payload: state };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const NavigationBuilderContext = React.createContext<{
|
||||
addActionListener?: (listener: ChildActionListener) => void;
|
||||
addFocusedListener?: (listener: FocusedNavigationListener) => void;
|
||||
onRouteFocus?: (key: string) => void;
|
||||
trackAction: (key: string, action: NavigationAction) => void;
|
||||
trackAction: (action: NavigationAction) => void;
|
||||
}>({
|
||||
trackAction: () => undefined,
|
||||
});
|
||||
|
||||
@@ -10,21 +10,18 @@ import {
|
||||
NavigationState,
|
||||
InitialState,
|
||||
PartialState,
|
||||
ParamListBase,
|
||||
NavigationHelpers,
|
||||
NavigationAction,
|
||||
NavigationContainerRef,
|
||||
} from './types';
|
||||
|
||||
type State = NavigationState | PartialState<NavigationState> | undefined;
|
||||
|
||||
type Props = {
|
||||
initialState?: InitialState;
|
||||
onStateChange?: (
|
||||
state: NavigationState | PartialState<NavigationState> | undefined
|
||||
) => void;
|
||||
onStateChange?: (state: State) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type State = NavigationState | PartialState<NavigationState> | undefined;
|
||||
|
||||
const MISSING_CONTEXT_ERROR =
|
||||
"We couldn't find a navigation context. Have you wrapped your app with 'NavigationContainer'?";
|
||||
|
||||
@@ -88,7 +85,7 @@ const getPartialState = (
|
||||
*/
|
||||
const Container = React.forwardRef(function NavigationContainer(
|
||||
{ initialState, onStateChange, children }: Props,
|
||||
ref: React.Ref<NavigationHelpers<ParamListBase>>
|
||||
ref: React.Ref<NavigationContainerRef>
|
||||
) {
|
||||
const [state, setNavigationState] = React.useState<State>(() =>
|
||||
getPartialState(initialState)
|
||||
@@ -126,6 +123,10 @@ const Container = React.forwardRef(function NavigationContainer(
|
||||
);
|
||||
return acc;
|
||||
}, {}),
|
||||
resetRoot: (state: PartialState<NavigationState> | NavigationState) => {
|
||||
trackAction('@@RESET_ROOT');
|
||||
setNavigationState(state);
|
||||
},
|
||||
dispatch,
|
||||
canGoBack,
|
||||
}));
|
||||
|
||||
@@ -126,6 +126,7 @@ it('resets state to new state with RESET', () => {
|
||||
it('ignores key and routeNames when resetting with RESET', () => {
|
||||
const result = BaseRouter.getStateForAction(
|
||||
STATE,
|
||||
// @ts-ignore
|
||||
BaseActions.reset({ index: 2, key: 'foo', routeNames: ['test'] })
|
||||
);
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
DefaultRouterOptions,
|
||||
NavigationState,
|
||||
Router,
|
||||
NavigationHelpers,
|
||||
ParamListBase,
|
||||
NavigationContainerRef,
|
||||
} from '../types';
|
||||
|
||||
it('throws when getState is accessed without a container', () => {
|
||||
@@ -173,7 +172,7 @@ it('handle dispatching with ref', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ref = React.createRef<NavigationHelpers<ParamListBase>>();
|
||||
const ref = React.createRef<NavigationContainerRef>();
|
||||
|
||||
const onStateChange = jest.fn();
|
||||
|
||||
@@ -226,7 +225,7 @@ it('handle dispatching with ref', () => {
|
||||
render(element).update(element);
|
||||
|
||||
act(() => {
|
||||
if (ref.current !== null) {
|
||||
if (ref.current != null) {
|
||||
ref.current.dispatch({ type: 'REVERSE' });
|
||||
}
|
||||
});
|
||||
@@ -253,3 +252,73 @@ it('handle dispatching with ref', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('handle resetting state with ref', () => {
|
||||
const TestNavigator = (props: any) => {
|
||||
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{state.routes.map(route => descriptors[route.key].render())}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const ref = React.createRef<NavigationContainerRef>();
|
||||
|
||||
const onStateChange = jest.fn();
|
||||
|
||||
const element = (
|
||||
<NavigationContainer ref={ref} onStateChange={onStateChange}>
|
||||
<TestNavigator>
|
||||
<Screen name="foo">{() => null}</Screen>
|
||||
<Screen name="foo2">
|
||||
{() => (
|
||||
<TestNavigator>
|
||||
<Screen name="qux" component={() => null} />
|
||||
<Screen name="lex" component={() => null} />
|
||||
</TestNavigator>
|
||||
)}
|
||||
</Screen>
|
||||
<Screen name="bar">{() => null}</Screen>
|
||||
<Screen name="baz">
|
||||
{() => (
|
||||
<TestNavigator>
|
||||
<Screen name="qux" component={() => null} />
|
||||
<Screen name="lex" component={() => null} />
|
||||
</TestNavigator>
|
||||
)}
|
||||
</Screen>
|
||||
</TestNavigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
render(element).update(element);
|
||||
|
||||
const state = {
|
||||
stale: true,
|
||||
index: 1,
|
||||
routes: [
|
||||
{
|
||||
key: 'baz',
|
||||
name: 'baz',
|
||||
state: {
|
||||
index: 0,
|
||||
key: '4',
|
||||
routeNames: ['qux', 'lex'],
|
||||
routes: [{ key: 'qux', name: 'qux' }, { key: 'lex', name: 'lex' }],
|
||||
},
|
||||
},
|
||||
{ key: 'bar', name: 'bar' },
|
||||
],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
if (ref.current != null) {
|
||||
ref.current.resetRoot(state);
|
||||
}
|
||||
});
|
||||
|
||||
expect(onStateChange).toBeCalledTimes(1);
|
||||
expect(onStateChange).lastCalledWith(state);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InitialState } from './types';
|
||||
import { NavigationState, PartialState } from './types';
|
||||
|
||||
/**
|
||||
* Utility to parse a path string to initial state object accepted by the container.
|
||||
@@ -8,17 +8,17 @@ import { InitialState } from './types';
|
||||
*/
|
||||
export default function getStateFromPath(
|
||||
path: string
|
||||
): InitialState | undefined {
|
||||
): PartialState<NavigationState> | undefined {
|
||||
const parts = path.split('?');
|
||||
const segments = parts[0].split('/').filter(Boolean);
|
||||
const query = parts[1] ? parts[1].split('&') : undefined;
|
||||
|
||||
let result: InitialState | undefined;
|
||||
let current: InitialState | undefined;
|
||||
let result: PartialState<NavigationState> | undefined;
|
||||
let current: PartialState<NavigationState> | undefined;
|
||||
|
||||
while (segments.length) {
|
||||
const state = {
|
||||
stale: true as true,
|
||||
stale: true,
|
||||
routes: [{ name: decodeURIComponent(segments[0]) }],
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export type InitialState = Partial<
|
||||
export type PartialState<State extends NavigationState> = Partial<
|
||||
Omit<State, 'stale' | 'key' | 'routes' | 'routeNames'>
|
||||
> & {
|
||||
stale: boolean;
|
||||
stale?: boolean;
|
||||
routes: Array<
|
||||
Omit<Route<string>, 'key'> & { key?: string; state?: InitialState }
|
||||
>;
|
||||
@@ -304,7 +304,7 @@ type NavigationHelpersCommon<
|
||||
*
|
||||
* @param state Navigation state object.
|
||||
*/
|
||||
reset(state: Partial<State>): void;
|
||||
reset(state: PartialState<State> | State): void;
|
||||
|
||||
/**
|
||||
* Go back to the previous route in history.
|
||||
@@ -486,6 +486,18 @@ export type RouteConfig<
|
||||
children: (props: any) => React.ReactNode;
|
||||
});
|
||||
|
||||
export type NavigationContainerRef =
|
||||
| NavigationHelpers<ParamListBase> & {
|
||||
/**
|
||||
* Reset the navigation state of the root navigator to the provided state.
|
||||
*
|
||||
* @param state Navigation state object.
|
||||
*/
|
||||
resetRoot(state: PartialState<NavigationState> | NavigationState): void;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
export type TypedNavigator<
|
||||
ParamList extends ParamListBase,
|
||||
ScreenOptions extends object,
|
||||
|
||||
@@ -43,9 +43,7 @@ export default function useDevTools({ name, reset, state }: Options) {
|
||||
|
||||
const devTools = devToolsRef.current;
|
||||
const lastStateRef = React.useRef<State>(state);
|
||||
const actions = React.useRef<
|
||||
Array<{ key: string; action: NavigationAction }>
|
||||
>([]);
|
||||
const actions = React.useRef<Array<NavigationAction | string>>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
devTools && devTools.init(lastStateRef.current);
|
||||
@@ -84,12 +82,12 @@ export default function useDevTools({ name, reset, state }: Options) {
|
||||
);
|
||||
|
||||
const trackAction = React.useCallback(
|
||||
(key: string, action: NavigationAction) => {
|
||||
(action: NavigationAction | string) => {
|
||||
if (!devTools) {
|
||||
return;
|
||||
}
|
||||
|
||||
actions.current.push({ key, action });
|
||||
actions.current.push(action);
|
||||
},
|
||||
[devTools]
|
||||
);
|
||||
|
||||
@@ -129,27 +129,41 @@ export default function useNavigationBuilder<
|
||||
performTransaction,
|
||||
} = React.useContext(NavigationStateContext);
|
||||
|
||||
const [initialState] = React.useState(() =>
|
||||
const previousStateRef = React.useRef<
|
||||
NavigationState | PartialState<NavigationState> | undefined
|
||||
>();
|
||||
const initializedStateRef = React.useRef<State>();
|
||||
|
||||
if (
|
||||
initializedStateRef.current === undefined ||
|
||||
currentState !== previousStateRef.current
|
||||
) {
|
||||
// If the current state isn't initialized on first render, we initialize it
|
||||
// We also need to re-initialize it if the state passed from parent was changed (maybe due to reset)
|
||||
// Otherwise assume that the state was provided as initial state
|
||||
// So we need to rehydrate it to make it usable
|
||||
currentState === undefined
|
||||
? router.getInitialState({
|
||||
routeNames,
|
||||
routeParamList,
|
||||
})
|
||||
: router.getRehydratedState(currentState as PartialState<State>, {
|
||||
routeNames,
|
||||
routeParamList,
|
||||
})
|
||||
);
|
||||
initializedStateRef.current =
|
||||
currentState === undefined
|
||||
? router.getInitialState({
|
||||
routeNames,
|
||||
routeParamList,
|
||||
})
|
||||
: router.getRehydratedState(currentState as PartialState<State>, {
|
||||
routeNames,
|
||||
routeParamList,
|
||||
});
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
previousStateRef.current = currentState;
|
||||
}, [currentState]);
|
||||
|
||||
let state =
|
||||
// If the state isn't initialized, or stale, use the state we initialized instead
|
||||
// The state won't update until there's a change needed in the state we have initalized locally
|
||||
// So it'll be `undefined` or stale untill the first navigation event happens
|
||||
currentState === undefined || currentState.stale
|
||||
? initialState
|
||||
? (initializedStateRef.current as State)
|
||||
: (currentState as State);
|
||||
|
||||
if (!isArrayEqual(state.routeNames, routeNames)) {
|
||||
@@ -188,9 +202,9 @@ export default function useNavigationBuilder<
|
||||
const currentState = getCurrentState();
|
||||
|
||||
return currentState === undefined || currentState.stale
|
||||
? initialState
|
||||
? (initializedStateRef.current as State)
|
||||
: (currentState as State);
|
||||
}, [getCurrentState, initialState]);
|
||||
}, [getCurrentState]);
|
||||
|
||||
const emitter = useEventEmitter();
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function useOnAction({
|
||||
result = result === null && action.target === state.key ? state : result;
|
||||
|
||||
if (result !== null) {
|
||||
trackAction(state.key, action);
|
||||
trackAction(action);
|
||||
|
||||
if (state !== result) {
|
||||
setState(result);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { ScrollView, AsyncStorage, YellowBox } from 'react-native';
|
||||
import { Linking } from 'expo';
|
||||
import { Appbar, List } from 'react-native-paper';
|
||||
import { Asset } from 'expo-asset';
|
||||
import {
|
||||
NavigationContainer,
|
||||
InitialState,
|
||||
NavigationHelpers,
|
||||
ParamListBase,
|
||||
getStateFromPath,
|
||||
NavigationContainerRef,
|
||||
} from '@navigation-ex/core';
|
||||
import { useNativeIntegration } from '@navigation-ex/native';
|
||||
import { useBackButton, useLinking } from '@navigation-ex/native';
|
||||
import {
|
||||
createDrawerNavigator,
|
||||
DrawerNavigationProp,
|
||||
@@ -57,9 +58,34 @@ const PERSISTENCE_KEY = 'NAVIGATION_STATE';
|
||||
Asset.loadAsync(StackAssets);
|
||||
|
||||
export default function App() {
|
||||
const containerRef = React.useRef<NavigationHelpers<ParamListBase>>(null);
|
||||
const containerRef = React.useRef<NavigationContainerRef>();
|
||||
|
||||
useNativeIntegration(containerRef);
|
||||
useBackButton(containerRef);
|
||||
|
||||
// To test deep linking on, run the following in the Terminal:
|
||||
// Android: adb shell am start -a android.intent.action.VIEW -d "exp://127.0.0.1:19000/--/simple-stack"
|
||||
// iOS: xcrun simctl openurl booted exp://127.0.0.1:19000/--/simple-stack
|
||||
// The first segment of the link is the the scheme + host (returned by `Linking.makeUrl`)
|
||||
const { getInitialState } = useLinking(containerRef, {
|
||||
prefixes: [Linking.makeUrl('/')],
|
||||
getStateFromPath: path => {
|
||||
const state = getStateFromPath(path);
|
||||
|
||||
return {
|
||||
stale: true,
|
||||
routes: [
|
||||
{
|
||||
name: 'root',
|
||||
state: {
|
||||
...state,
|
||||
stale: true,
|
||||
routes: [{ name: 'home' }, ...(state ? state.routes : [])],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const [initialState, setInitialState] = React.useState<
|
||||
@@ -67,21 +93,25 @@ export default function App() {
|
||||
>();
|
||||
|
||||
React.useEffect(() => {
|
||||
AsyncStorage.getItem(PERSISTENCE_KEY).then(
|
||||
data => {
|
||||
try {
|
||||
const result = JSON.parse(data || '');
|
||||
const restoreState = async () => {
|
||||
try {
|
||||
let state = await getInitialState();
|
||||
|
||||
if (result) {
|
||||
setInitialState(result);
|
||||
}
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
if (state === undefined) {
|
||||
const savedState = await AsyncStorage.getItem(PERSISTENCE_KEY);
|
||||
state = savedState ? JSON.parse(savedState) : undefined;
|
||||
}
|
||||
},
|
||||
() => setIsReady(true)
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (state !== undefined) {
|
||||
setInitialState(state);
|
||||
}
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
restoreState();
|
||||
}, [getInitialState]);
|
||||
|
||||
if (!isReady) {
|
||||
return null;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as useBackButton } from './useBackButton';
|
||||
export { default as useNativeIntegration } from './useNativeIntegration';
|
||||
export { default as useLinking } from './useLinking';
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { NavigationHelpers, ParamListBase } from '@navigation-ex/core';
|
||||
import { NavigationContainerRef } from '@navigation-ex/core';
|
||||
import { BackHandler } from 'react-native';
|
||||
|
||||
export default function useBackButton(
|
||||
ref: React.RefObject<NavigationHelpers<ParamListBase>>
|
||||
ref: React.RefObject<NavigationContainerRef>
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
if (ref.current == null) {
|
||||
const navigation = ref.current;
|
||||
|
||||
if (navigation == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const navigation = ref.current;
|
||||
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack();
|
||||
|
||||
|
||||
82
packages/native/src/useLinking.tsx
Normal file
82
packages/native/src/useLinking.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import {
|
||||
getStateFromPath as getStateFromPathDefault,
|
||||
NavigationContainerRef,
|
||||
NavigationState,
|
||||
PartialState,
|
||||
} from '@navigation-ex/core';
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* The prefixes are stripped from the URL before parsing them.
|
||||
* Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`)
|
||||
*/
|
||||
prefixes: string[];
|
||||
/**
|
||||
* Custom function to parse the URL object to a valid navigation state.
|
||||
*/
|
||||
getStateFromPath?: (
|
||||
path: string
|
||||
) => PartialState<NavigationState> | undefined;
|
||||
};
|
||||
|
||||
export default function useLinking(
|
||||
ref: React.RefObject<NavigationContainerRef>,
|
||||
{ prefixes, getStateFromPath = getStateFromPathDefault }: Options
|
||||
) {
|
||||
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
|
||||
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
|
||||
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
|
||||
const prefixesRef = React.useRef(prefixes);
|
||||
const getStateFromPathRef = React.useRef(getStateFromPath);
|
||||
|
||||
React.useEffect(() => {
|
||||
prefixesRef.current = prefixes;
|
||||
getStateFromPathRef.current = getStateFromPath;
|
||||
}, [getStateFromPath, prefixes]);
|
||||
|
||||
const extractPathFromURL = React.useCallback((url: string) => {
|
||||
for (const prefix of prefixesRef.current) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.replace(prefix, '');
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const getInitialState = React.useCallback(async () => {
|
||||
const url = await Linking.getInitialURL();
|
||||
const path = url ? extractPathFromURL(url) : null;
|
||||
|
||||
if (path) {
|
||||
return getStateFromPathRef.current(path);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [extractPathFromURL]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = ({ url }: { url: string }) => {
|
||||
const path = extractPathFromURL(url);
|
||||
const navigation = ref.current;
|
||||
|
||||
if (navigation && path) {
|
||||
const state = getStateFromPathRef.current(path);
|
||||
|
||||
if (state) {
|
||||
navigation.resetRoot(state);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Linking.addEventListener('url', listener);
|
||||
|
||||
return () => Linking.removeEventListener('url', listener);
|
||||
}, [extractPathFromURL, ref]);
|
||||
|
||||
return {
|
||||
getInitialState,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { NavigationHelpers, ParamListBase } from '@navigation-ex/core';
|
||||
import useBackButton from './useBackButton';
|
||||
|
||||
export default function useNativeIntegration(
|
||||
ref: React.RefObject<NavigationHelpers<ParamListBase>>
|
||||
) {
|
||||
useBackButton(ref);
|
||||
}
|
||||
@@ -85,7 +85,10 @@ export default function TabRouter({
|
||||
return {
|
||||
...route,
|
||||
name,
|
||||
key: route && route.key ? route.key : `${name}-${shortid()}`,
|
||||
key:
|
||||
route && route.name === name && route.key
|
||||
? route.key
|
||||
: `${name}-${shortid()}`,
|
||||
params:
|
||||
routeParamList[name] !== undefined
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user