Skip to content

Commit e797c20

Browse files
authored
refactor!: configure full window overlay globally to address exit animation issues (#125)
BREAKING CHANGE: Removes individual `fullWindowOverlay` config from `magicModal.show`. Introduces `magicModal.enableFullWindowOverlay()` and `magicModal.disableFullWindowOverlay()` in order to control overlay behavior globally. This fixes exit animations on iOS.
1 parent 873636e commit e797c20

File tree

7 files changed

+127
-95
lines changed

7 files changed

+127
-95
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,9 @@ If your use-case is a scrollable bottom-sheet, I recommend going with Gorhom's r
227227
**Q:** Modals are appearing on top of native modal screens, such as the image picker. How can I fix this?
228228
229229
**A:**
230-
This behavior can be disabled by passing `fullWindowOverlay: false` to the `magicModal.show` function.
230+
This behavior can be disabled by calling `magicModal.disableFullWindowOverlay()` before showing the modal. This will prevent the modal from appearing on top of native modal screens.
231231
232-
This will prevent the modal from appearing on top of native modal screens.
232+
You can also call `magicModal.enableFullWindowOverlay()` to re-enable it.
233233
234234
## Contributors
235235

examples/kitchen-sink/src/app/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ const showZoomInModal = async () => {
8484
};
8585

8686
const showNoFullWindowOverlayModal = async () => {
87-
magicModal.show(() => <ExampleModal />, {
88-
fullWindowOverlay: false,
89-
});
87+
magicModal.disableFullWindowOverlay();
88+
await magicModal.show(() => <ExampleModal />).promise;
89+
magicModal.enableFullWindowOverlay();
9090
};
9191

9292
export default () => {

packages/modal/src/components/MagicModal.tsx

Lines changed: 55 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import React, { memo, useMemo } from "react";
2-
import {
3-
Platform,
4-
Pressable,
5-
StyleSheet,
6-
useWindowDimensions,
7-
View,
8-
} from "react-native";
2+
import { Pressable, StyleSheet, useWindowDimensions, View } from "react-native";
93
import { Gesture, GestureDetector } from "react-native-gesture-handler";
104
import Animated, {
115
Extrapolation,
@@ -25,8 +19,6 @@ import Animated, {
2519
useSharedValue,
2620
withSpring,
2721
} from "react-native-reanimated";
28-
/** Do not import FullWindowOverlay from react-native-screens directly, as it screws up code splitting */
29-
import FullWindowOverlay from "react-native-screens/src/components/FullWindowOverlay";
3022

3123
import { defaultDirection } from "../constants/defaultConfig";
3224
import {
@@ -69,11 +61,6 @@ export const MagicModal = memo(
6961
const prevTranslationX = useSharedValue(0);
7062
const prevTranslationY = useSharedValue(0);
7163

72-
const Overlay =
73-
config.fullWindowOverlay && Platform.OS === "ios"
74-
? FullWindowOverlay
75-
: React.Fragment;
76-
7764
/**
7865
* Necessary to skip exit animation when swipe is complete.
7966
* This is a problem on web, where the exit animation does not
@@ -228,74 +215,64 @@ export const MagicModal = memo(
228215
const isBackdropVisible = !config.hideBackdrop;
229216

230217
return (
231-
<Overlay>
232-
<View style={[StyleSheet.absoluteFill, styles.pointerEventsBoxNone]}>
233-
<Animated.View
234-
pointerEvents={isBackdropVisible ? "auto" : "none"}
235-
entering={FadeIn.duration(config.animationInTiming)}
236-
exiting={FadeOut.duration(config.animationOutTiming)}
237-
style={styles.backdropContainer}
238-
>
239-
<AnimatedPressable
240-
testID="magic-modal-backdrop"
241-
style={[
242-
styles.backdrop,
243-
animatedBackdropStyles,
244-
{
245-
backgroundColor: isBackdropVisible
246-
? config.backdropColor
247-
: "transparent",
248-
},
249-
]}
250-
onPress={onBackdropPress}
251-
/>
252-
</Animated.View>
253-
<Animated.View
218+
<View style={[StyleSheet.absoluteFill, styles.pointerEventsBoxNone]}>
219+
<Animated.View
220+
pointerEvents={isBackdropVisible ? "auto" : "none"}
221+
entering={FadeIn.duration(config.animationInTiming)}
222+
exiting={FadeOut.duration(config.animationOutTiming)}
223+
style={styles.backdropContainer}
224+
>
225+
<AnimatedPressable
226+
testID="magic-modal-backdrop"
254227
style={[
255-
styles.overlay,
256-
styles.pointerEventsBoxNone,
257-
animatedStyles,
228+
styles.backdrop,
229+
animatedBackdropStyles,
230+
{
231+
backgroundColor: isBackdropVisible
232+
? config.backdropColor
233+
: "transparent",
234+
},
258235
]}
236+
onPress={onBackdropPress}
237+
/>
238+
</Animated.View>
239+
<Animated.View
240+
style={[styles.overlay, styles.pointerEventsBoxNone, animatedStyles]}
241+
>
242+
<Animated.View
243+
style={[styles.overlay, styles.pointerEventsBoxNone, config.style]}
244+
entering={
245+
!isSwipeComplete
246+
? (config.entering ??
247+
defaultAnimationInMap[
248+
config.swipeDirection ?? defaultDirection
249+
].duration(config.animationInTiming))
250+
: undefined
251+
}
252+
exiting={
253+
!isSwipeComplete
254+
? (config.exiting ??
255+
defaultAnimationOutMap[
256+
config.swipeDirection ?? defaultDirection
257+
].duration(config.animationOutTiming))
258+
: undefined
259+
}
259260
>
260-
<Animated.View
261-
style={[
262-
styles.overlay,
263-
styles.pointerEventsBoxNone,
264-
config.style,
265-
]}
266-
entering={
267-
!isSwipeComplete
268-
? (config.entering ??
269-
defaultAnimationInMap[
270-
config.swipeDirection ?? defaultDirection
271-
].duration(config.animationInTiming))
272-
: undefined
273-
}
274-
exiting={
275-
!isSwipeComplete
276-
? (config.exiting ??
277-
defaultAnimationOutMap[
278-
config.swipeDirection ?? defaultDirection
279-
].duration(config.animationOutTiming))
280-
: undefined
281-
}
282-
>
283-
<GestureDetector gesture={pan}>
284-
<View
285-
collapsable={false}
286-
style={[
287-
styles.childrenWrapper,
288-
styles.pointerEventsBoxNone,
289-
config.style,
290-
]}
291-
>
292-
<Children />
293-
</View>
294-
</GestureDetector>
295-
</Animated.View>
261+
<GestureDetector gesture={pan}>
262+
<View
263+
collapsable={false}
264+
style={[
265+
styles.childrenWrapper,
266+
styles.pointerEventsBoxNone,
267+
config.style,
268+
]}
269+
>
270+
<Children />
271+
</View>
272+
</GestureDetector>
296273
</Animated.View>
297-
</View>
298-
</Overlay>
274+
</Animated.View>
275+
</View>
299276
);
300277
},
301278
);

packages/modal/src/components/MagicModalPortal/MagicModalPortal.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import React, {
55
useImperativeHandle,
66
useMemo,
77
} from "react";
8-
import { BackHandler, StyleSheet, View } from "react-native";
8+
import { BackHandler, Platform, StyleSheet, View } from "react-native";
9+
/** Do not import FullWindowOverlay from react-native-screens directly, as it screws up code splitting */
10+
import FullWindowOverlay from "react-native-screens/src/components/FullWindowOverlay";
911

1012
import { defaultConfig } from "../../constants/defaultConfig";
1113
import {
@@ -47,6 +49,16 @@ type ModalStackItem = {
4749
*/
4850
export const MagicModalPortal: React.FC = memo(() => {
4951
const [modals, setModals] = React.useState<ModalStackItem[]>([]);
52+
const [fullWindowOverlayEnabled, setFullWindowOverlayEnabled] =
53+
React.useState(true);
54+
55+
const disableFullWindowOverlay = useCallback(() => {
56+
setFullWindowOverlayEnabled(false);
57+
}, []);
58+
59+
const enableFullWindowOverlay = useCallback(() => {
60+
setFullWindowOverlayEnabled(true);
61+
}, []);
5062

5163
const _hide = useCallback<GlobalHideFunction>(
5264
async (props, { modalID } = {}) => {
@@ -162,6 +174,8 @@ export const MagicModalPortal: React.FC = memo(() => {
162174
show,
163175
hide,
164176
hideAll,
177+
disableFullWindowOverlay,
178+
enableFullWindowOverlay,
165179
}));
166180

167181
const modalList = useMemo(() => {
@@ -174,11 +188,22 @@ export const MagicModalPortal: React.FC = memo(() => {
174188
});
175189
}, [modals]);
176190

191+
const Overlay =
192+
fullWindowOverlayEnabled && Platform.OS === "ios"
193+
? FullWindowOverlay
194+
: React.Fragment;
195+
177196
/* This needs to always be rendered, if we make it conditionally render based on ModalContent too,
178197
the modal will have zIndex issues on react-navigation modals. */
179198
return (
180-
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
181-
{modalList}
182-
</View>
199+
<Overlay>
200+
<View style={[StyleSheet.absoluteFill, styles.wrapper]}>{modalList}</View>
201+
</Overlay>
183202
);
184203
});
204+
205+
const styles = StyleSheet.create({
206+
wrapper: {
207+
pointerEvents: "box-none",
208+
},
209+
});

packages/modal/src/constants/defaultConfig.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,5 @@ export const defaultConfig: ModalProps = {
1313
swipeVelocityThreshold: 500,
1414
onBackButtonPress: undefined,
1515
onBackdropPress: undefined,
16-
fullWindowOverlay: true,
1716
style: {},
1817
} satisfies ModalProps;

packages/modal/src/constants/types.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,6 @@ export type ModalProps = {
3939
*/
4040
backdropColor: string;
4141

42-
/**
43-
* If true, the modal will be displayed as a full window overlay on top of native iOS modal screens.
44-
* @default true
45-
* @platform ios
46-
*/
47-
fullWindowOverlay: boolean;
48-
4942
/**
5043
* Function to be called when the back button is pressed.
5144
* You can override it to prevent the modal from closing on back button press.
@@ -102,6 +95,10 @@ export type GlobalHideFunction = <T>(
10295

10396
export type GlobalHideAllFunction = () => void;
10497

98+
export type EnableFullWindowOverlayFunction = () => void;
99+
100+
export type DisableFullWindowOverlayFunction = () => void;
101+
105102
export type HookHideFunction = <T>(props: HideReturn<T>) => void;
106103

107104
export type NewConfigProps = Partial<ModalProps>;

packages/modal/src/utils/magicModalHandler.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React from "react";
22

33
import {
4+
DisableFullWindowOverlayFunction,
5+
EnableFullWindowOverlayFunction,
46
GlobalHideAllFunction,
57
GlobalHideFunction,
68
GlobalShowFunction,
@@ -28,6 +30,14 @@ const hide: GlobalHideFunction = (props, { modalID } = {}) => {
2830
return getMagicModal().hide(props, { modalID });
2931
};
3032

33+
const enableFullWindowOverlay: EnableFullWindowOverlayFunction = () => {
34+
return getMagicModal().enableFullWindowOverlay();
35+
};
36+
37+
const disableFullWindowOverlay: DisableFullWindowOverlayFunction = () => {
38+
return getMagicModal().disableFullWindowOverlay();
39+
};
40+
3141
const hideAll: GlobalHideAllFunction = () => {
3242
// We recommend using this method in jest, and having throw because the ref was not found isn't useful there.
3343
// Not all tests are necessarily using the provider.
@@ -37,6 +47,8 @@ export interface IModal {
3747
show: typeof show;
3848
hide: typeof hide;
3949
hideAll: typeof hideAll;
50+
enableFullWindowOverlay: typeof enableFullWindowOverlay;
51+
disableFullWindowOverlay: typeof disableFullWindowOverlay;
4052
}
4153

4254
/**
@@ -76,4 +88,26 @@ export const magicModal = {
7688
* However, this function can be useful in edge cases. It's also useful for test suites, such as calling hideAll in Jest's beforeEach function as a cleanup step.
7789
*/
7890
hideAll,
91+
/**
92+
* @description Enables the full window overlay globally. This is useful for modals that need to be displayed on top of native iOS modal screens. The function is no-op on non-iOS platforms.
93+
* @example
94+
* ```js
95+
* magicModal.disableFullWindowOverlay();
96+
* await magicModal.show(() => <ExampleModal />).promise;
97+
* magicModal.enableFullWindowOverlay();
98+
* ```
99+
* @platform ios
100+
*/
101+
enableFullWindowOverlay,
102+
/**
103+
* @description Disables the full window overlay globally. This is useful for modals that do not need to be displayed on top of native iOS modal screens. The function is no-op on non-iOS platforms.
104+
* @example
105+
* ```js
106+
* magicModal.disableFullWindowOverlay();
107+
* await magicModal.show(() => <ExampleModal />).promise;
108+
* magicModal.enableFullWindowOverlay();
109+
* ```
110+
* @platform ios
111+
*/
112+
disableFullWindowOverlay,
79113
};

0 commit comments

Comments
 (0)