-
-
Notifications
You must be signed in to change notification settings - Fork 5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement usePreventRemove hook (#10682)
As we still polishing the API we're marking the hook for now as UNSTABLE_usePreventRemove. **Motivation** This PR introduces a new hook - usePreventRemove - used for preventing going back in react-navigation. This hook would allow supporting preventing of screen removal in @react-navigation/native-stack which is one of the limitations of current API. The usePreventRemove hook would take two arguments: preventRemove - boolean indicating whether to prevent screen from being removed callback - function which is executed when screen was prevented from being removed Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
- Loading branch information
1 parent
d19987b
commit 7411516
Showing
16 changed files
with
1,479 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
import { UNSTABLE_usePreventRemove } from '@react-navigation/core'; | ||
import { | ||
CommonActions, | ||
ParamListBase, | ||
useTheme, | ||
} from '@react-navigation/native'; | ||
import { | ||
createNativeStackNavigator, | ||
NativeStackScreenProps, | ||
} from '@react-navigation/native-stack'; | ||
import * as React from 'react'; | ||
import { | ||
Alert, | ||
Platform, | ||
ScrollView, | ||
StyleSheet, | ||
TextInput, | ||
View, | ||
} from 'react-native'; | ||
import { Button } from 'react-native-paper'; | ||
|
||
import Article from '../Shared/Article'; | ||
|
||
type PreventRemoveParams = { | ||
Article: { author: string }; | ||
Input: undefined; | ||
}; | ||
|
||
const scrollEnabled = Platform.select({ web: true, default: false }); | ||
|
||
const ArticleScreen = ({ | ||
navigation, | ||
route, | ||
}: NativeStackScreenProps<PreventRemoveParams, 'Article'>) => { | ||
return ( | ||
<ScrollView> | ||
<View style={styles.buttons}> | ||
<Button | ||
mode="contained" | ||
onPress={() => navigation.push('Input')} | ||
style={styles.button} | ||
> | ||
Push Input | ||
</Button> | ||
<Button | ||
mode="outlined" | ||
onPress={() => navigation.popToTop()} | ||
style={styles.button} | ||
> | ||
Pop to top | ||
</Button> | ||
</View> | ||
<Article | ||
author={{ name: route.params?.author ?? 'Unknown' }} | ||
scrollEnabled={scrollEnabled} | ||
/> | ||
</ScrollView> | ||
); | ||
}; | ||
|
||
const InputScreen = ({ | ||
navigation, | ||
}: NativeStackScreenProps<PreventRemoveParams, 'Input'>) => { | ||
const [text, setText] = React.useState(''); | ||
const { colors } = useTheme(); | ||
|
||
const hasUnsavedChanges = Boolean(text); | ||
|
||
UNSTABLE_usePreventRemove(hasUnsavedChanges, ({ data }) => { | ||
if (Platform.OS === 'web') { | ||
const discard = confirm( | ||
'You have unsaved changes. Discard them and leave the screen?' | ||
); | ||
if (discard) { | ||
navigation.dispatch(data.action); | ||
} | ||
} else { | ||
Alert.alert( | ||
'Discard changes?', | ||
'You have unsaved changes. Discard them and leave the screen?', | ||
[ | ||
{ text: "Don't leave", style: 'cancel', onPress: () => {} }, | ||
{ | ||
text: 'Discard', | ||
style: 'destructive', | ||
onPress: () => navigation.dispatch(data.action), | ||
}, | ||
] | ||
); | ||
} | ||
}); | ||
|
||
return ( | ||
<View style={styles.content}> | ||
<TextInput | ||
autoFocus | ||
style={[ | ||
styles.input, | ||
{ backgroundColor: colors.card, color: colors.text }, | ||
]} | ||
value={text} | ||
placeholder="Type something…" | ||
onChangeText={setText} | ||
/> | ||
<Button | ||
mode="outlined" | ||
color="tomato" | ||
onPress={() => | ||
navigation.dispatch({ | ||
...CommonActions.goBack(), | ||
payload: { confirmed: true }, | ||
}) | ||
} | ||
style={styles.button} | ||
> | ||
Discard and go back | ||
</Button> | ||
<Button | ||
mode="outlined" | ||
onPress={() => navigation.push('Article', { author: text })} | ||
style={styles.button} | ||
> | ||
Push Article | ||
</Button> | ||
</View> | ||
); | ||
}; | ||
|
||
const Stack = createNativeStackNavigator<PreventRemoveParams>(); | ||
|
||
type Props = NativeStackScreenProps<ParamListBase>; | ||
|
||
export default function StackScreen({ navigation }: Props) { | ||
React.useLayoutEffect(() => { | ||
navigation.setOptions({ | ||
headerShown: false, | ||
}); | ||
}, [navigation]); | ||
|
||
return ( | ||
<Stack.Navigator> | ||
<Stack.Screen | ||
name="Input" | ||
component={InputScreen} | ||
options={{ | ||
presentation: 'modal', | ||
}} | ||
/> | ||
<Stack.Screen name="Article" component={ArticleScreen} /> | ||
</Stack.Navigator> | ||
); | ||
} | ||
|
||
const styles = StyleSheet.create({ | ||
content: { | ||
flex: 1, | ||
padding: 16, | ||
}, | ||
input: { | ||
margin: 8, | ||
padding: 10, | ||
borderRadius: 3, | ||
borderWidth: StyleSheet.hairlineWidth, | ||
borderColor: 'rgba(0, 0, 0, 0.08)', | ||
}, | ||
buttons: { | ||
flexDirection: 'row', | ||
flexWrap: 'wrap', | ||
padding: 8, | ||
}, | ||
button: { | ||
margin: 8, | ||
}, | ||
}); |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import * as React from 'react'; | ||
|
||
/** | ||
* A type of an object that have a route key as an object key | ||
* and a value whether to prevent that route. | ||
*/ | ||
export type PreventedRoutes = Record<string, { preventRemove: boolean }>; | ||
|
||
const PreventRemoveContext = React.createContext< | ||
| { | ||
preventedRoutes: PreventedRoutes; | ||
setPreventRemove: ( | ||
id: string, | ||
routeKey: string, | ||
preventRemove: boolean | ||
) => void; | ||
} | ||
| undefined | ||
>(undefined); | ||
|
||
export default PreventRemoveContext; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
import { nanoid } from 'nanoid/non-secure'; | ||
import * as React from 'react'; | ||
import useLatestCallback from 'use-latest-callback'; | ||
|
||
import NavigationHelpersContext from './NavigationHelpersContext'; | ||
import NavigationRouteContext from './NavigationRouteContext'; | ||
import PreventRemoveContext, { PreventedRoutes } from './PreventRemoveContext'; | ||
|
||
type Props = { | ||
children: React.ReactNode; | ||
}; | ||
|
||
type PreventedRoutesMap = Map< | ||
string, | ||
{ | ||
routeKey: string; | ||
preventRemove: boolean; | ||
} | ||
>; | ||
|
||
/** | ||
* Util function to transform map of prevented routes to a simpler object. | ||
*/ | ||
const transformPreventedRoutes = ( | ||
preventedRoutesMap: PreventedRoutesMap | ||
): PreventedRoutes => { | ||
const preventedRoutesToTransform = [...preventedRoutesMap.values()]; | ||
|
||
const preventedRoutes = preventedRoutesToTransform.reduce<PreventedRoutes>( | ||
(acc, { routeKey, preventRemove }) => { | ||
acc[routeKey] = { | ||
preventRemove: acc[routeKey]?.preventRemove || preventRemove, | ||
}; | ||
return acc; | ||
}, | ||
{} | ||
); | ||
|
||
return preventedRoutes; | ||
}; | ||
|
||
/** | ||
* Component used for managing which routes have to be prevented from removal in native-stack. | ||
*/ | ||
export default function PreventRemoveProvider({ children }: Props) { | ||
const [parentId] = React.useState(() => nanoid()); | ||
const [preventedRoutesMap, setPreventedRoutesMap] = | ||
React.useState<PreventedRoutesMap>(new Map()); | ||
|
||
const navigation = React.useContext(NavigationHelpersContext); | ||
const route = React.useContext(NavigationRouteContext); | ||
|
||
const preventRemoveContextValue = React.useContext(PreventRemoveContext); | ||
// take `setPreventRemove` from parent context - if exist it means we're in a nested context | ||
const setParentPrevented = preventRemoveContextValue?.setPreventRemove; | ||
|
||
const setPreventRemove = useLatestCallback( | ||
(id: string, routeKey: string, preventRemove: boolean): void => { | ||
if ( | ||
preventRemove && | ||
(navigation == null || | ||
navigation | ||
?.getState() | ||
.routes.every((route) => route.key !== routeKey)) | ||
) { | ||
throw new Error( | ||
`Couldn't find a route with the key ${routeKey}. Is your component inside NavigationContent?` | ||
); | ||
} | ||
|
||
setPreventedRoutesMap((prevPrevented) => { | ||
// values haven't changed - do nothing | ||
if ( | ||
routeKey === prevPrevented.get(id)?.routeKey && | ||
preventRemove === prevPrevented.get(id)?.preventRemove | ||
) { | ||
return prevPrevented; | ||
} | ||
|
||
const nextPrevented = new Map(prevPrevented); | ||
|
||
if (preventRemove) { | ||
nextPrevented.set(id, { | ||
routeKey, | ||
preventRemove, | ||
}); | ||
} else { | ||
nextPrevented.delete(id); | ||
} | ||
|
||
return nextPrevented; | ||
}); | ||
} | ||
); | ||
|
||
const isPrevented = [...preventedRoutesMap.values()].some( | ||
({ preventRemove }) => preventRemove | ||
); | ||
|
||
React.useEffect(() => { | ||
if (route?.key !== undefined && setParentPrevented !== undefined) { | ||
// when route is defined (and setParentPrevented) it means we're in a nested stack | ||
// route.key then will be the route key of parent | ||
setParentPrevented(parentId, route.key, isPrevented); | ||
return () => { | ||
setParentPrevented(parentId, route.key, false); | ||
}; | ||
} | ||
|
||
return; | ||
}, [parentId, isPrevented, route?.key, setParentPrevented]); | ||
|
||
const value = React.useMemo( | ||
() => ({ | ||
setPreventRemove, | ||
preventedRoutes: transformPreventedRoutes(preventedRoutesMap), | ||
}), | ||
[setPreventRemove, preventedRoutesMap] | ||
); | ||
|
||
return ( | ||
<PreventRemoveContext.Provider value={value}> | ||
{children} | ||
</PreventRemoveContext.Provider> | ||
); | ||
} |
Oops, something went wrong.