diff --git a/change/react-native-windows-31db428a-029b-4735-94f6-2c4368b9bda6.json b/change/react-native-windows-31db428a-029b-4735-94f6-2c4368b9bda6.json new file mode 100644 index 00000000000..7b616fc952d --- /dev/null +++ b/change/react-native-windows-31db428a-029b-4735-94f6-2c4368b9bda6.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "implement onDismiss and remove titlebar from Modal", + "packageName": "react-native-windows", + "email": "tatianakapos@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/tester/src/js/examples/Modal/ModalPresentation.windows.js b/packages/@react-native-windows/tester/src/js/examples/Modal/ModalPresentation.windows.js index 7543b357bb5..e24ac9cd4c8 100644 --- a/packages/@react-native-windows/tester/src/js/examples/Modal/ModalPresentation.windows.js +++ b/packages/@react-native-windows/tester/src/js/examples/Modal/ModalPresentation.windows.js @@ -214,7 +214,7 @@ function ModalPresentation() { key="onDismiss" style={styles.option} label="onDismiss ⚫️" - disabled={Platform.OS !== 'ios'} + disabled={Platform.OS !== 'ios' && Platform.OS !== 'windows'} onPress={() => setProps(prev => ({ ...prev, diff --git a/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap b/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap index 20c7600cf34..d5a2a506bbb 100644 --- a/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap +++ b/packages/e2e-test-app-fabric/test/__snapshots__/snapshotPages.test.js.snap @@ -30019,14 +30019,7 @@ exports[`snapshotAllPages Modal 1`] = ` }, null, null, - [ - { - "borderWidth": 0, - }, - { - "backgroundColor": "#7676801e", - }, - ], + null, { "marginRight": 8, "marginTop": 6, diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.cpp index c4aa52a9a16..08b584f2162 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.cpp @@ -34,6 +34,11 @@ WindowsModalHostComponentView::WindowsModalHostComponentView( : Super(compContext, tag, reactContext) {} WindowsModalHostComponentView::~WindowsModalHostComponentView() { + // dispatch onDismiss event + auto emitter = std::static_pointer_cast(m_eventEmitter); + facebook::react::ModalHostViewEventEmitter::OnDismiss onDismissArgs; + emitter->onDismiss(onDismissArgs); + // reset the topWindowID if (m_prevWindowID) { auto host = @@ -92,10 +97,12 @@ void WindowsModalHostComponentView::EnsureModalCreated() { m_parentHwnd = GetHwndForParenting(); + auto windowsStyle = m_showTitleBar ? WS_OVERLAPPEDWINDOW : WS_POPUP; + m_hwnd = CreateWindow( c_modalWindowClassName, L"React-Native Modal", - WS_OVERLAPPEDWINDOW, + windowsStyle, CW_USEDEFAULT, CW_USEDEFAULT, MODAL_MIN_WIDTH, @@ -170,6 +177,11 @@ void WindowsModalHostComponentView::HideOnUIThread() noexcept { SendMessage(m_hwnd, WM_CLOSE, 0, 0); } + // dispatch onDismiss event + auto emitter = std::static_pointer_cast(m_eventEmitter); + facebook::react::ModalHostViewEventEmitter::OnDismiss onDismissArgs; + emitter->onDismiss(onDismissArgs); + // enable input to parent EnableWindow(m_parentHwnd, true); @@ -298,7 +310,10 @@ void WindowsModalHostComponentView::AdjustWindowSize() noexcept { RECT rc; GetClientRect(m_hwnd, &rc); RECT rect = {0, 0, (int)xPos, (int)yPos}; - AdjustWindowRect(&rect, WS_OVERLAPPEDWINDOW, FALSE); // Adjust for title bar and borders + + if (m_showTitleBar) { + AdjustWindowRect(&rect, WS_OVERLAPPEDWINDOW, FALSE); // Adjust for title bar and borders + } // set the layoutMetrics m_layoutMetrics.frame.size = {(float)rect.right - rect.left, (float)rect.bottom - rect.top}; @@ -326,6 +341,9 @@ void WindowsModalHostComponentView::updateProps( *std::static_pointer_cast(oldProps ? oldProps : viewProps()); const auto &newModalProps = *std::static_pointer_cast(props); newModalProps.visible ? m_isVisible = true : m_isVisible = false; + if (!m_isVisible) { + HideOnUIThread(); + } base_type::updateProps(props, oldProps); } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.h index 99320072e79..fbb3f10ec21 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/Modal/WindowsModalHostViewComponentView.h @@ -64,6 +64,7 @@ struct WindowsModalHostComponentView HWND m_hwnd{nullptr}; uint64_t m_prevWindowID; bool m_isVisible{false}; + bool m_showTitleBar{false}; winrt::Microsoft::ReactNative::ReactNativeIsland m_reactNativeIsland; }; diff --git a/vnext/overrides.json b/vnext/overrides.json index 5b6dfe45cbb..470a4048b3d 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -482,6 +482,12 @@ "baseFile": "packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js", "baseHash": "663d3325298404d7c012a6aa53e833eb5fc2ec76" }, + { + "type": "derived", + "file": "src-win/Libraries/Modal/Modal.windows.js", + "baseFile": "packages/react-native/Libraries/Modal/Modal.js", + "baseHash": "ab5b48312e287809327d494e7a9542f2f178d94b" + }, { "type": "derived", "file": "src-win/Libraries/NativeComponent/BaseViewConfig.windows.js", diff --git a/vnext/src-win/Libraries/Modal/Modal.windows.js b/vnext/src-win/Libraries/Modal/Modal.windows.js new file mode 100644 index 00000000000..bee411a14df --- /dev/null +++ b/vnext/src-win/Libraries/Modal/Modal.windows.js @@ -0,0 +1,352 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict-local + */ + +import type {ViewProps} from '../Components/View/ViewPropTypes'; +import type {RootTag} from '../ReactNative/RootTag'; +import type {DirectEventHandler} from '../Types/CodegenTypes'; + +import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import {type EventSubscription} from '../vendor/emitter/EventEmitter'; +import ModalInjection from './ModalInjection'; +import NativeModalManager from './NativeModalManager'; +import RCTModalHostView from './RCTModalHostViewNativeComponent'; +import {VirtualizedListContextResetter} from '@react-native/virtualized-lists'; + +const ScrollView = require('../Components/ScrollView/ScrollView'); +const View = require('../Components/View/View'); +const AppContainer = require('../ReactNative/AppContainer'); +const I18nManager = require('../ReactNative/I18nManager'); +const {RootTagContext} = require('../ReactNative/RootTag'); +const StyleSheet = require('../StyleSheet/StyleSheet'); +const Platform = require('../Utilities/Platform'); +const React = require('react'); + +type ModalEventDefinitions = { + modalDismissed: [{modalID: number}], +}; + +const ModalEventEmitter = + (Platform.OS === 'ios' || Platform.OS === 'windows') && // [Windows] + NativeModalManager != null + ? new NativeEventEmitter( + // T88715063: NativeEventEmitter only used this parameter on iOS. Now it uses it on all platforms, so this code was modified automatically to preserve its behavior + // If you want to use the native module on other platforms, please remove this condition and test its behavior + Platform.OS !== 'ios' && Platform.OS !== 'windows' // [Windows] + ? null + : NativeModalManager, + ) + : null; + +/** + * The Modal component is a simple way to present content above an enclosing view. + * + * See https://reactnative.dev/docs/modal + */ + +// In order to route onDismiss callbacks, we need to uniquely identifier each +// on screen. There can be different ones, either nested or as siblings. +// We cannot pass the onDismiss callback to native as the view will be +// destroyed before the callback is fired. +let uniqueModalIdentifier = 0; + +type OrientationChangeEvent = $ReadOnly<{| + orientation: 'portrait' | 'landscape', +|}>; + +export type Props = $ReadOnly<{| + ...ViewProps, + + /** + * The `animationType` prop controls how the modal animates. + * + * See https://reactnative.dev/docs/modal#animationtype + */ + animationType?: ?('none' | 'slide' | 'fade'), + + /** + * The `presentationStyle` prop controls how the modal appears. + * + * See https://reactnative.dev/docs/modal#presentationstyle + */ + presentationStyle?: ?( + | 'fullScreen' + | 'pageSheet' + | 'formSheet' + | 'overFullScreen' + ), + + /** + * The `transparent` prop determines whether your modal will fill the + * entire view. + * + * See https://reactnative.dev/docs/modal#transparent + */ + transparent?: ?boolean, + + /** + * The `statusBarTranslucent` prop determines whether your modal should go under + * the system statusbar. + * + * See https://reactnative.dev/docs/modal.html#statusbartranslucent-android + */ + statusBarTranslucent?: ?boolean, + + /** + * The `hardwareAccelerated` prop controls whether to force hardware + * acceleration for the underlying window. + * + * This prop works only on Android. + * + * See https://reactnative.dev/docs/modal#hardwareaccelerated + */ + hardwareAccelerated?: ?boolean, + + /** + * The `visible` prop determines whether your modal is visible. + * + * See https://reactnative.dev/docs/modal#visible + */ + visible?: ?boolean, + + /** + * The `onRequestClose` callback is called when the user taps the hardware + * back button on Android or the menu button on Apple TV. + * + * This is required on Apple TV and Android. + * + * See https://reactnative.dev/docs/modal#onrequestclose + */ + onRequestClose?: ?DirectEventHandler, + + /** + * The `onShow` prop allows passing a function that will be called once the + * modal has been shown. + * + * See https://reactnative.dev/docs/modal#onshow + */ + onShow?: ?DirectEventHandler, + + /** + * The `onDismiss` prop allows passing a function that will be called once + * the modal has been dismissed. + * + * See https://reactnative.dev/docs/modal#ondismiss + */ + onDismiss?: ?() => mixed, + + /** + * The `supportedOrientations` prop allows the modal to be rotated to any of the specified orientations. + * + * See https://reactnative.dev/docs/modal#supportedorientations + */ + supportedOrientations?: ?$ReadOnlyArray< + | 'portrait' + | 'portrait-upside-down' + | 'landscape' + | 'landscape-left' + | 'landscape-right', + >, + + /** + * The `onOrientationChange` callback is called when the orientation changes while the modal is being displayed. + * + * See https://reactnative.dev/docs/modal#onorientationchange + */ + onOrientationChange?: ?DirectEventHandler, + + /** + * The `backdropColor` props sets the background color of the modal's container. + * Defaults to `white` if not provided and transparent is `false`. Ignored if `transparent` is `true`. + */ + backdropColor?: ?string, +|}>; + +function confirmProps(props: Props) { + if (__DEV__) { + if ( + props.presentationStyle && + props.presentationStyle !== 'overFullScreen' && + props.transparent === true + ) { + console.warn( + `Modal with '${props.presentationStyle}' presentation style and 'transparent' value is not supported.`, + ); + } + } +} + +// Create a state to track whether the Modal is rendering or not. +// This is the only prop that controls whether the modal is rendered or not. +type State = { + isRendered: boolean, +}; + +class Modal extends React.Component { + static defaultProps: {|hardwareAccelerated: boolean, visible: boolean|} = { + visible: true, + hardwareAccelerated: false, + }; + + static contextType: React.Context = RootTagContext; + + _identifier: number; + _eventSubscription: ?EventSubscription; + + constructor(props: Props) { + super(props); + if (__DEV__) { + confirmProps(props); + } + this._identifier = uniqueModalIdentifier++; + this.state = { + isRendered: props.visible === true, + }; + } + + componentDidMount() { + // 'modalDismissed' is for the old renderer in iOS only + if (ModalEventEmitter) { + this._eventSubscription = ModalEventEmitter.addListener( + 'modalDismissed', + event => { + this.setState({isRendered: false}, () => { + if (event.modalID === this._identifier && this.props.onDismiss) { + this.props.onDismiss(); + } + }); + }, + ); + } + } + + componentWillUnmount() { + if (this._eventSubscription) { + this._eventSubscription.remove(); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.visible === false && this.props.visible === true) { + this.setState({isRendered: true}); + } + + if (__DEV__) { + confirmProps(this.props); + } + } + + // Helper function to encapsulate platform specific logic to show or not the Modal. + _shouldShowModal(): boolean { + if (Platform.OS === 'ios' || Platform.OS === 'windows') { + // [Windows] + return this.props.visible === true || this.state.isRendered === true; + } + + return this.props.visible === true; + } + + render(): React.Node { + if (!this._shouldShowModal()) { + return null; + } + + const containerStyles = { + backgroundColor: + this.props.transparent === true + ? 'transparent' + : this.props.backdropColor ?? 'white', + }; + + let animationType = this.props.animationType || 'none'; + + let presentationStyle = this.props.presentationStyle; + if (!presentationStyle) { + presentationStyle = 'fullScreen'; + if (this.props.transparent === true) { + presentationStyle = 'overFullScreen'; + } + } + + const innerChildren = __DEV__ ? ( + {this.props.children} + ) : ( + this.props.children + ); + + const onDismiss = () => { + // OnDismiss is implemented on iOS/Windows only. // [Windows] + if (Platform.OS === 'ios' || Platform.OS === 'windows') { + // [Windows] + this.setState({isRendered: false}, () => { + if (this.props.onDismiss) { + this.props.onDismiss(); + } + }); + } + }; + + return ( + + + + + {innerChildren} + + + + + ); + } + + // We don't want any responder events bubbling out of the modal. + _shouldSetResponder(): boolean { + return true; + } +} + +const side = I18nManager.getConstants().isRTL ? 'right' : 'left'; +const styles = StyleSheet.create({ + modal: { + position: 'absolute', + }, + container: { + /* $FlowFixMe[invalid-computed-prop] (>=0.111.0 site=react_native_fb) This + * comment suppresses an error found when Flow v0.111 was deployed. To see + * the error, delete this comment and run Flow. */ + [side]: 0, + top: 0, + flex: 1, + }, +}); + +const ExportedModal: React.AbstractComponent< + React.ElementConfig, + // $FlowFixMe[incompatible-type-arg] +> = ModalInjection.unstable_Modal ?? Modal; + +module.exports = ExportedModal;