Skip to content

fix: measureInWindow calculations for Modal with formSheet presentation#56062

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

fix: measureInWindow calculations for Modal with formSheet presentation#56062
kirillzyusko wants to merge 2 commits intofacebook:mainfrom
kirillzyusko:fix/use-window-dimensions-in-form-sheet-modal-on-ios

Conversation

@kirillzyusko
Copy link
Contributor

@kirillzyusko kirillzyusko commented Mar 11, 2026

Summary:

Fixed useWindowDimensions 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 would be 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 Mar 11, 2026
@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Mar 11, 2026
kirillzyusko added a commit to kirillzyusko/react-native-keyboard-controller that referenced this pull request Mar 12, 2026
## 📜 Description

Fixed broken `measureInWindow` measurements in react-native.

## 💡 Motivation and Context

The `measureInWindow` works unreliably in Fabric + formSheet Modal
(facebook/react-native#56062) or on Android with
edge-to-edge enabled
(facebook/react-native#56056)

Upstream fixes are available, but the problem is that it will require at
least RN 0.85+ to work properly. So in this PR we add internal function
that is capable of measuring given view. In future this functionality
can be removed, but for now this is critical to have it bundled within a
package.

Fixes
#1356

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### JS

- added `viewPositionInWindow` to `types`;
- added `viewPositionInWindow` to codegen;
- added `viewPositionInWindow` to bindings/module;
- use `viewPositionInWindow` instead of `measureInWindow`;
- added `viewPositionInWindow` to mocks;

### iOS

- implement `viewPositionInWindow`;
- make `activeWindow` objc available;

### Android

- implement `viewPositionInWindow`;
- add `uiManager` and `eventDispatcher` to `ReactContext` extensions;

## 🤔 How Has This Been Tested?

Tested manually on iPhone 17 Pro (iOS 26.2, simulator) and Pixel 9 Pro
(API 35, emulator).

## 📸 Screenshots (if appropriate):

### Android

#### Fabric

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/afb99e51-043f-459c-90e3-7e1eb1e184ed">|<video
src="https://github.com/user-attachments/assets/8ea0d593-f7bd-47a5-9343-e2fd85f4af45">|

#### Paper

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/702c7d88-755b-45f0-b39f-fe4aaa60ab2e">|<video
src="https://github.com/user-attachments/assets/f5346bbd-cc85-4a40-9cc5-9422931b04af">|

### iOS

#### Fabric

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/49fb9ac6-15b8-4842-939c-4ec06a4e0c42">|<video
src="https://github.com/user-attachments/assets/b559a52a-0a91-48bc-911a-7125ce96e013">|

#### Paper

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/c4d234a7-122a-4023-8d86-fad71ed043dc">|<video
src="https://github.com/user-attachments/assets/d5d48487-16ad-40c2-b6c6-fea85b563e20">|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed

---------

Co-authored-by: thomasvo <thomas.vo@openspace.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: kirillzyusko <zyusko.kirik@gmail.com>
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.

2 participants