Repository navigation
fix(feedback): Present feedback form above native-stack sheets on iOS (experimental) #6882
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
92a2420
903b839
170b868
73fae7a
693fb55
edc646d
ddd2049
1a53159
7c483a9
0b69a90
bee6fec
cba71d9
660ef9e
56b0f7e
8771074
ec6eac5
469e8c8
cfa77d5
557698c
b46df1e
c254486
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,10 +31,12 @@ import { | |
| showFeedbackForm, | ||
| SLIDE_ANIMATION_DURATION, | ||
| } from './FeedbackFormManager'; | ||
| import { getFullWindowOverlay } from './fullWindowOverlay'; | ||
| import { | ||
| getFeedbackButtonOptions, | ||
| getFeedbackOptions, | ||
| getScreenshotButtonOptions, | ||
| isPresentOverNativeModalsEnabled, | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
| isShakeToReportEnabled, | ||
| } from './integration'; | ||
| import { lazyLoadShakeToReportIntegration } from './lazy'; | ||
|
|
@@ -83,6 +85,7 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr | |
| }; | ||
|
|
||
| private _themeListener: NativeEventSubscription | undefined; | ||
| private _dimensionsListener: { remove: () => void } | undefined; | ||
| private _startedShakeListener: boolean = false; | ||
|
|
||
| private _panResponder = PanResponder.create({ | ||
|
|
@@ -132,10 +135,24 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr | |
| this.forceUpdate(); | ||
| }); | ||
|
|
||
| // When the form is rendered in a `FullWindowOverlay` its size comes from | ||
| // `Dimensions.get('window')` at render time, so re-render on rotation/resize | ||
| // to keep it full-screen. (No-op for the `Modal` path, which auto-fills.) | ||
| this._dimensionsListener = Dimensions.addEventListener('change', () => { | ||
| this.forceUpdate(); | ||
| }); | ||
|
antonis marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't really checked how it works without but I think this listener might be redundant if you make the inner But even if it's really really really needed then you can still limit it to iOS-only and register only when |
||
|
|
||
| if (isShakeToReportEnabled()) { | ||
| lazyLoadShakeToReportIntegration(); | ||
| this._startedShakeListener = startShakeListener(showFeedbackForm); | ||
| } | ||
|
|
||
| if (Platform.OS === 'ios' && isPresentOverNativeModalsEnabled() && !getFullWindowOverlay()) { | ||
| debug.warn( | ||
| "[Feedback] '_experiments.presentOverNativeModals' is enabled but 'react-native-screens' is not available; " + | ||
| 'falling back to Modal. The form may not appear above presented native-stack sheets.', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -146,6 +163,10 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr | |
| this._themeListener.remove(); | ||
| } | ||
|
|
||
| if (this._dimensionsListener) { | ||
| this._dimensionsListener.remove(); | ||
| } | ||
|
|
||
| if (this._startedShakeListener) { | ||
| stopShakeListener(); | ||
| } | ||
|
|
@@ -196,6 +217,56 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr | |
| outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], | ||
| }); | ||
|
|
||
| const sheet = ( | ||
| <> | ||
| <View style={topSpacer} /> | ||
| <Animated.View | ||
| style={[modalSheetContainer(theme), { transform: [{ translateY: this.state.panY }] }]} | ||
| {...this._panResponder.panHandlers} | ||
| > | ||
| <ScrollView | ||
| bounces={false} | ||
| keyboardShouldPersistTaps="handled" | ||
| automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'} | ||
| onScroll={this._handleScroll} | ||
| > | ||
| <FeedbackForm | ||
| {...getFeedbackOptions()} | ||
| onFormClose={this._handleClose} | ||
| onFormSubmitted={this._handleClose} | ||
| /> | ||
| </ScrollView> | ||
| </Animated.View> | ||
| </> | ||
| ); | ||
|
|
||
| // On iOS, a React Native `Modal` presents a view controller from the root VC's | ||
| // presentation chain. When a native-stack screen is already presented (e.g. a | ||
| // `formSheet`/`modal`), UIKit refuses the second presentation and the form never | ||
| // shows. `FullWindowOverlay` from react-native-screens renders above everything | ||
| // in the key window โ including presented sheets โ so the form shows. | ||
| // Opt-in via `_experiments.presentOverNativeModals`; falls back to `Modal` when | ||
| // disabled or when react-native-screens isn't installed. | ||
| const FullWindowOverlay = | ||
| Platform.OS === 'ios' && isPresentOverNativeModalsEnabled() ? getFullWindowOverlay() : undefined; | ||
| if (FullWindowOverlay) { | ||
| const { width, height } = Dimensions.get('window'); | ||
| return ( | ||
| <> | ||
| {this.props.children} | ||
| {isButtonVisible && <FeedbackButton {...getFeedbackButtonOptions()} />} | ||
| {isScreenshotButtonVisible && <ScreenshotButton {...getScreenshotButtonOptions()} />} | ||
| {isVisible && ( | ||
| <FullWindowOverlay> | ||
| <Animated.View style={[{ width, height }, { backgroundColor }]} testID="feedback-form-overlay"> | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| {sheet} | ||
| </Animated.View> | ||
| </FullWindowOverlay> | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| )} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| // Wrapping the `Modal` component in a `View` component is necessary to avoid | ||
| // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 | ||
| return ( | ||
|
|
@@ -212,24 +283,7 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr | |
| onRequestClose={this._handleClose} | ||
| testID="feedback-form-modal" | ||
| > | ||
| <View style={topSpacer} /> | ||
| <Animated.View | ||
| style={[modalSheetContainer(theme), { transform: [{ translateY: this.state.panY }] }]} | ||
| {...this._panResponder.panHandlers} | ||
| > | ||
| <ScrollView | ||
| bounces={false} | ||
| keyboardShouldPersistTaps="handled" | ||
| automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'} | ||
| onScroll={this._handleScroll} | ||
| > | ||
| <FeedbackForm | ||
| {...getFeedbackOptions()} | ||
| onFormClose={this._handleClose} | ||
| onFormSubmitted={this._handleClose} | ||
| /> | ||
| </ScrollView> | ||
| </Animated.View> | ||
| {sheet} | ||
| </Modal> | ||
| </Animated.View> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import type * as React from 'react'; | ||
|
|
||
| type FullWindowOverlayComponent = React.ComponentType<{ children?: React.ReactNode }>; | ||
|
|
||
| let _resolved = false; | ||
| let _component: FullWindowOverlayComponent | undefined; | ||
|
|
||
| /** | ||
| * Lazily resolves `FullWindowOverlay` from `react-native-screens`, if installed. | ||
| * | ||
| * On iOS, React Native's `Modal` presents a view controller from the root view | ||
| * controller's chain. When a native-stack screen is already presented (e.g. a | ||
| * `formSheet`/`modal`), UIKit refuses the second presentation and the feedback | ||
| * form silently fails to appear. `FullWindowOverlay` renders above everything in | ||
| * the key window โ including presented sheets โ so the form shows regardless. | ||
| * | ||
| * `react-native-screens` is an optional dependency: the `require` is wrapped in a | ||
| * try/catch, which Metro treats as an optional import, so bundling still works | ||
| * when it isn't installed. In that case callers fall back to `Modal`. | ||
| */ | ||
| export function getFullWindowOverlay(): FullWindowOverlayComponent | undefined { | ||
| if (_resolved) { | ||
| return _component; | ||
| } | ||
| _resolved = true; | ||
|
|
||
| try { | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const screens = require('react-native-screens') as { FullWindowOverlay?: FullWindowOverlayComponent }; | ||
| if (screens?.FullWindowOverlay) { | ||
| _component = screens.FullWindowOverlay; | ||
| } | ||
| } catch { | ||
| // react-native-screens is not installed โ callers fall back to Modal. | ||
| } | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| return _component; | ||
| } | ||
|
|
||
| /** | ||
| * For testing purposes only. | ||
| */ | ||
| export function resetFullWindowOverlayCache(): void { | ||
| _resolved = false; | ||
| _component = undefined; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is never used. The test file below simply mocks the whole module. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it should be under fixes since users need to opt in. I'd rather move it to ### Features
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was on the edge on this since it's not a real feature but more of a new experimental option to test a possible fix publicly.