Skip to content

fix: measureInWindow calculations for Modal with formSheet presentation - #59098

Open
kirillzyusko wants to merge 3 commits into
react:mainfrom
kirillzyusko:fix/use-window-dimensions-in-form-sheet-modal-on-ios
Open

kirillzyusko wants to merge 3 commits into
react:mainfrom
kirillzyusko:fix/use-window-dimensions-in-form-sheet-modal-on-ios

Conversation

@kirillzyusko

@kirillzyusko kirillzyusko commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Coming from #56062 but after fork re-sync

Fixed measureInWindow calculations for Modal with formSheet presentation.

This is only Fabric problem (paper works well because it relies on UIKit implementation).

Problem

The core issue is in how measureInWindow works differently between Paper and Fabric.

Paper calls iOS native [view.window convertRect:view.frame fromView:view.superview] which uses UIKit's actual view hierarchy. When a formSheet modal is presented, the modal's view controller lives in a separate UIWindow or has a known offset from the top of the screen. UIKit's convertRect:fromView: correctly accounts for this because it works with the real rendered view positions.

Fabric's measureInWindow (in DOM.cpp) works entirely in the shadow tree, not the native view hierarchy. It calls getLayoutMetricsFromRoot which walks up the shadow node ancestors accumulating frame origins. The critical logic is at LayoutableShadowNode.cpp:

auto shouldApplyTransformation = (policy.includeTransform && !isRootNode) ||
    (policy.includeViewportOffset && isRootNode);

The includeViewportOffset flag only applies the root node's transform. For a formSheet modal, the Y offset from the top of the screen (the ~62pt gap where the modal doesn't cover the full screen) would need to be represented as a transform on the root shadow node. But the modal's screen position is a UIKit concern — it's the UIViewController presentation that positions the modal content on screen, not a shadow tree transform.

The key is at ModalHostViewShadowNode.h:

traits.set(ShadowNodeTraits::Trait::RootNodeKind);

The ModalHostViewShadowNode has the RootNodeKind trait. This means when computeRelativeLayoutMetrics walks up from the view, it stops at the modal node (see LayoutableShadowNode.cpp):

if (shadowNode.getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) {
  // If this is a node with a `RootNodeKind` trait, we need to stop right there.
  break;
}

Then at the "apply transform" step:

auto shouldApplyTransformation = (policy.includeTransform && !isRootNode) ||
    (policy.includeViewportOffset && isRootNode);

For the Modal's root node, includeViewportOffset && isRootNode is true, so it applies getTransform(). But ModalHostViewShadowNode doesn't override getTransform() — only RootShadowNode does (which translates by viewportOffset). The default getTransform() returns Transform::Identity().

So the modal's screen position (the formSheet top inset) is simply never encoded anywhere in the shadow tree.

Solution

The fix is in RCTModalHostViewComponentView.mm. In boundsDidChange, when the modal's bounds change, the state is updated with just the size:

auto newState = ModalHostViewState{RCTSizeFromCGSize(newBounds.size)};

The fix needs to:

  1. Extend ModalHostViewState to include a viewportOffset (the modal's origin on screen)
  2. In boundsDidChange compute the modal view controller's actual screen position (e.g., self.viewController.view.frame.origin in window coordinates) and pass it as the viewport offset
  3. Override getTransform() on ModalHostViewShadowNode to apply the viewport offset from state.

This way, when measureInWindow walks up to the modal (which is a RootNodeKind), includeViewportOffset triggers getTransform(), which now returns the actual screen offset instead of identity.

Changelog:

[IOS] [FIXED] measureInWindow calculations for Modal with formSheet presentation

Test Plan:

I used this code in RNTester:

Click to expand
/**
 * 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.
 *
 * @flow strict-local
 * @format
 */

/* eslint-disable no-alert */

import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
import type {ModalProps} from 'react-native';

import RNTesterButton from '../../components/RNTesterButton';
import RNTesterText from '../../components/RNTesterText';
import {RNTesterThemeContext} from '../../components/RNTesterTheme';
import RNTOption from '../../components/RNTOption';
import * as React from 'react';
import {useCallback, useContext, useState, useRef} from 'react';
import {Modal, Platform, StyleSheet, Switch, Text, TouchableOpacity, View} from 'react-native';

const animationTypes = ['slide', 'none', 'fade'] as const;
const presentationStyles = [
  'fullScreen',
  'pageSheet',
  'formSheet',
  'overFullScreen',
] as const;
const supportedOrientations = [
  'portrait',
  'portrait-upside-down',
  'landscape',
  'landscape-left',
  'landscape-right',
] as const;

const backdropColors = ['red', 'blue', undefined];

function ModalPresentation() {
  const onDismiss = useCallback(() => {
    alert('onDismiss');
  }, []);

  const onShow = useCallback(() => {
    alert('onShow');
  }, []);

  const onRequestClose = useCallback(() => {
    console.log('onRequestClose');
    setProps(prev => ({...prev, visible: false}));
  }, []);

  const [props, setProps] = useState<ModalProps>({
    allowSwipeDismissal: false,
    animationType: 'none',
    backdropColor: undefined,
    hardwareAccelerated: false,
    navigationBarTranslucent: false,
    onDismiss: undefined,
    onShow: undefined,
    presentationStyle: Platform.select({
      default: undefined,
      ios: 'fullScreen',
    }),
    statusBarTranslucent: false,
    supportedOrientations: Platform.select({
      default: undefined,
      ios: ['portrait'],
    }),
    transparent: false,
    visible: false,
  });
  const presentationStyle = props.presentationStyle;
  const hardwareAccelerated = props.hardwareAccelerated;
  const statusBarTranslucent = props.statusBarTranslucent;
  const navigationBarTranslucent = props.navigationBarTranslucent;
  const allowSwipeDismissal = props.allowSwipeDismissal;
  const backdropColor = props.backdropColor;
  const backgroundColor = useContext(RNTesterThemeContext).BackgroundColor;

  const [currentOrientation, setCurrentOrientation] = useState('unknown');

  type OrientationChangeEvent = Parameters<
    NonNullable<ModalProps['onOrientationChange']>,
  >[0];
  const onOrientationChange = (event: OrientationChangeEvent) =>
    setCurrentOrientation(event.nativeEvent.orientation);

  const controls = (
    <>
      <View style={styles.inlineBlock}>
        <RNTesterText style={styles.title}>
          Status Bar Translucent 🟢
        </RNTesterText>
        <Switch
          value={statusBarTranslucent}
          onValueChange={enabled =>
            setProps(prev => ({
              ...prev,
              navigationBarTranslucent: false,
              statusBarTranslucent: enabled,
            }))
          }
        />
      </View>
      <View style={styles.inlineBlock}>
        <RNTesterText style={styles.title}>
          Navigation Bar Translucent 🟢
        </RNTesterText>
        <Switch
          value={navigationBarTranslucent}
          onValueChange={enabled => {
            setProps(prev => ({
              ...prev,
              navigationBarTranslucent: enabled,
              statusBarTranslucent: enabled,
            }));
          }}
        />
      </View>
      <View style={styles.inlineBlock}>
        <RNTesterText style={styles.title}>
          Hardware Acceleration 🟢
        </RNTesterText>
        <Switch
          value={hardwareAccelerated}
          onValueChange={enabled =>
            setProps(prev => ({
              ...prev,
              hardwareAccelerated: enabled,
            }))
          }
        />
      </View>

      <View style={styles.inlineBlock}>
        <RNTesterText style={styles.title}>
          Allow Swipe Dismissal ⚫️
        </RNTesterText>
        <Switch
          value={allowSwipeDismissal}
          onValueChange={enabled =>
            setProps(prev => ({
              ...prev,
              allowSwipeDismissal: enabled,
            }))
          }
        />
      </View>
      <View style={styles.block}>
        <RNTesterText style={styles.title}>Presentation Style ⚫️</RNTesterText>
        <View style={styles.row}>
          {presentationStyles.map(type => (
            <RNTOption
              key={type}
              disabled={Platform.OS !== 'ios'}
              style={styles.option}
              label={type}
              multiSelect={true}
              onPress={() =>
                setProps(prev => {
                  if (type === 'overFullScreen' && prev.transparent === true) {
                    return {
                      ...prev,
                      presentationStyle: type,
                      transparent: false,
                    };
                  }
                  return {
                    ...prev,
                    presentationStyle:
                      type === prev.presentationStyle ? undefined : type,
                  };
                })
              }
              selected={type === presentationStyle}
            />
          ))}
        </View>
      </View>
      <View style={styles.block}>
        <View style={styles.rowWithSpaceBetween}>
          <RNTesterText style={styles.title}>Transparent</RNTesterText>
          <Switch
            value={props.transparent}
            onValueChange={enabled =>
              setProps(prev => ({...prev, transparent: enabled}))
            }
          />
        </View>
        {Platform.OS === 'ios' && presentationStyle !== 'overFullScreen' ? (
          <RNTesterText style={styles.warning}>
            iOS Modal can only be transparent with 'overFullScreen' Presentation
            Style
          </RNTesterText>
        ) : null}
      </View>
      <View style={styles.block}>
        <RNTesterText style={styles.title}>
          Supported Orientation ⚫️
        </RNTesterText>
        <View style={styles.row}>
          {supportedOrientations.map(orientation => (
            <RNTOption
              key={orientation}
              disabled={Platform.OS !== 'ios'}
              style={styles.option}
              label={orientation}
              multiSelect={true}
              onPress={() =>
                setProps(prev => {
                  if (prev.supportedOrientations?.includes(orientation)) {
                    return {
                      ...prev,
                      supportedOrientations: prev.supportedOrientations?.filter(
                        o => o !== orientation,
                      ),
                    };
                  }
                  return {
                    ...prev,
                    supportedOrientations: [
                      ...(prev.supportedOrientations ?? []),
                      orientation,
                    ],
                  };
                })
              }
              selected={props.supportedOrientations?.includes(orientation)}
            />
          ))}
        </View>
      </View>
      <View style={styles.block}>
        <RNTesterText style={styles.title}>Actions</RNTesterText>
        <View style={styles.row}>
          <RNTOption
            key="onShow"
            style={styles.option}
            label="onShow"
            multiSelect={true}
            onPress={() =>
              setProps(prev => ({
                ...prev,
                onShow: prev.onShow ? undefined : onShow,
              }))
            }
            selected={!!props.onShow}
          />
          <RNTOption
            key="onDismiss"
            style={styles.option}
            label="onDismiss ⚫️"
            disabled={Platform.OS !== 'ios'}
            onPress={() =>
              setProps(prev => ({
                ...prev,
                onDismiss: prev.onDismiss ? undefined : onDismiss,
              }))
            }
            selected={!!props.onDismiss}
          />
        </View>
      </View>
      <View style={styles.block}>
        <RNTesterText style={styles.title}>Backdrop Color ⚫️</RNTesterText>
        <View style={styles.row}>
          {backdropColors.map(type => (
            <RNTOption
              key={type ?? 'default'}
              style={styles.option}
              label={type ?? 'default'}
              multiSelect={true}
              onPress={() =>
                setProps(prev => ({
                  ...prev,
                  backdropColor: type,
                }))
              }
              selected={type === backdropColor}
            />
          ))}
        </View>
      </View>
    </>
  );

  const measureRef = useRef<View>(null);

  return (
    <View>
      <RNTesterButton
        onPress={() => setProps(prev => ({...prev, visible: true}))}>
        Show Modal
      </RNTesterButton>
      <Modal
        {...props}
        onRequestClose={onRequestClose}
        onOrientationChange={onOrientationChange}>
        <View style={styles.modalContainer}>
          <View style={[styles.modalInnerContainer, {backgroundColor}]}>
            <TouchableOpacity onPress={() => measureRef.current.measureInWindow((x, y, width, height) => {
              console.log(x, y, width, height);
            })}>
              <View ref={measureRef} style={{width: 200, height: 50, backgroundColor: "red"}} />
            </TouchableOpacity>
            <Text testID="modal_animationType_text">
              This modal was presented with animationType: '
              {props.animationType}'
            </Text>
            {Platform.OS === 'ios' ? (
              <Text>
                It is currently displayed in {currentOrientation} mode.
              </Text>
            ) : null}
            <RNTesterButton
              onPress={() => setProps(prev => ({...prev, visible: false}))}>
              Close
            </RNTesterButton>
            {controls}
          </View>
        </View>
      </Modal>
      <View style={styles.block}>
        <RNTesterText style={styles.title}>Animation Type</RNTesterText>
        <View style={styles.row}>
          {animationTypes.map(type => (
            <RNTOption
              key={type}
              style={styles.option}
              label={type}
              onPress={() => setProps(prev => ({...prev, animationType: type}))}
              selected={type === props.animationType}
            />
          ))}
        </View>
      </View>
      {controls}
    </View>
  );
}

const styles = StyleSheet.create({
  block: {
    borderBottomWidth: 1,
    borderColor: 'rgba(0,0,0, 0.1)',
    padding: 6,
  },
  inlineBlock: {
    alignItems: 'center',
    borderBottomWidth: 1,
    borderColor: 'rgba(0,0,0, 0.1)',
    flexDirection: 'row',
    justifyContent: 'space-between',
    padding: 6,
  },
  modalContainer: {
    flex: 1,
    justifyContent: 'center',
    padding: 20,
  },
  modalInnerContainer: {
    borderRadius: 10,
    padding: 10,
  },
  option: {
    marginRight: 8,
    marginTop: 6,
  },
  row: {
    flexDirection: 'row',
    flexWrap: 'wrap',
  },
  rowWithSpaceBetween: {
    flexDirection: 'row',
    justifyContent: 'space-between',
  },
  title: {
    fontWeight: 'bold',
    margin: 3,
  },
  warning: {
    color: 'red',
    fontSize: 12,
    margin: 3,
  },
});

export default ({
  description: 'Modals can be presented with or without animation',
  name: 'basic',
  render: (): React.Node => <ModalPresentation />,
  title: 'Modal Presentation',
}: RNTesterModuleExample);

If you press on a red rectangle with a fix you will see a correct y=72 (10pt padding in container + 62pt safe area padding):

Screenshot 2026-03-11 at 18 46 03

Without a fix the y=10 (which is incorrect).

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 15, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 15, 2026
@kirillzyusko
kirillzyusko force-pushed the fix/use-window-dimensions-in-form-sheet-modal-on-ios branch from 042dfcb to eb9d254 Compare October 15, 2026 11:13

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant