diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 5afdaf3f415d..095332267a06 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -23,6 +23,7 @@ import type { LayoutRectangle, MouseEvent, PointerEvent, + SafeAreaInsetsChangeEvent, } from '../../Types/CoreEventTypes'; import type { AccessibilityActionEvent, @@ -63,6 +64,32 @@ type DirectEventProps = Readonly<{ */ onLayout?: ?(event: LayoutChangeEvent) => unknown, + /** + * Invoked when the part of this view that is covered by the system UI + * (status bar, navigation bar, home indicator, display cutouts, ...) + * changes, with: + * + * `{nativeEvent: {insets: {top, right, bottom, left}, frame: {x, y, width, height}}}` + * + * `insets` are relative to this view: an inset is only non-zero for the part + * of the view that actually overlaps the system UI. `frame` is the position + * of the view at the time of the event, relative to its enclosing view + * controller on iOS and to the window on Android; it does not trigger the + * event on its own, so it can be stale while the view moves without its + * insets changing. + * + * The event is dispatched synchronously, so the rendering it schedules is + * applied in the same frame the insets changed in. + * + * Setting this prop makes the view observe safe area changes; views without + * it are unaffected. + * + * @experimental + */ + experimental_onSafeAreaInsetsChange?: ?( + event: SafeAreaInsetsChangeEvent, + ) => unknown, + /** * When `accessible` is `true`, the system will invoke this function when the * user performs the magic tap gesture. diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js new file mode 100644 index 000000000000..e361d265330e --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,111 @@ +/** + * 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 + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +describe('experimental_onSafeAreaInsetsChange', () => { + it('delivers the insets and the frame of the view', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + frame: FRAME, + }); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + const [event] = onSafeAreaInsetsChange.mock.lastCall; + expect(event.insets).toEqual(INSETS); + expect(event.frame).toEqual(FRAME); + }); + + it('is not delivered to views that did not opt in', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + root.render(); + }); + + // The prop is what makes the view observe the safe area, so a view without + // it is never the target of the event. + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); + + it('prevents the view from being flattened', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + // A layout-only view would ordinarily be flattened away; observing the + // safe area requires a host view to observe with. + {}}> + + , + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual( + + + , + ); + }); + + it('is reflected in the props of the view when set', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + {}} + />, + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); +}); diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index 6e3ee698720d..c37f44b61888 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -204,6 +204,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, }; const validAttributesForNonEventProps = { @@ -405,6 +408,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = { onLayout: true, + experimental_onSafeAreaInsetsChange: true, // PanResponder handlers onMoveShouldSetResponder: true, diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index d22a68642194..80c413a7c1d0 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -179,6 +179,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({ registrationName: 'onGestureHandlerEvent', }), @@ -380,6 +383,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({ onLayout: true, + experimental_onSafeAreaInsetsChange: true, onMagicTap: true, // Accessibility diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.js b/packages/react-native/Libraries/Types/CoreEventTypes.js index dff10cb27609..15aff5e610c3 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -76,6 +76,29 @@ export type LayoutChangeEvent = NativeSyntheticEvent< }>, >; +export type SafeAreaInsets = Readonly<{ + top: number, + right: number, + bottom: number, + left: number, +}>; + +export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent< + Readonly<{ + /** + * The part of the view that is covered by the system UI, in the view's own + * coordinate space. + */ + insets: SafeAreaInsets, + /** + * The frame of the view at the time of the event. Relative to the + * enclosing view controller on iOS and to the window on Android; only + * updated when the insets change. + */ + frame: LayoutRectangle, + }>, +>; + /** * @deprecated Use `TextLayoutEvent` instead. */ diff --git a/packages/react-native/React/Fabric/AppleEventBeat.cpp b/packages/react-native/React/Fabric/AppleEventBeat.cpp deleted file mode 100644 index 4a3d533a0cd9..000000000000 --- a/packages/react-native/React/Fabric/AppleEventBeat.cpp +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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. - */ - -#include "AppleEventBeat.h" - -#include - -namespace facebook::react { - -AppleEventBeat::AppleEventBeat( - std::shared_ptr ownerBox, - std::unique_ptr uiRunLoopObserver, - RuntimeScheduler& runtimeScheduler) - : EventBeat(std::move(ownerBox), runtimeScheduler), - uiRunLoopObserver_(std::move(uiRunLoopObserver)) { - uiRunLoopObserver_->setDelegate(this); - uiRunLoopObserver_->enable(); -} - -void AppleEventBeat::activityDidChange( - const RunLoopObserver::Delegate* delegate, - RunLoopObserver::Activity /*activity*/) const noexcept { - react_native_assert(delegate == this); - induce(); -} - -} // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.h b/packages/react-native/React/Fabric/AppleEventBeat.h index 256e0f0983ad..528145e23797 100644 --- a/packages/react-native/React/Fabric/AppleEventBeat.h +++ b/packages/react-native/React/Fabric/AppleEventBeat.h @@ -7,6 +7,8 @@ #pragma once +#include + #include #include #include @@ -19,6 +21,11 @@ class RuntimeScheduler; * Event beat associated with JavaScript runtime. * The beat is called on `RuntimeExecutor`'s thread induced by the UI thread * event loop. + * + * A synchronous request made while Core Animation is laying out the current + * frame (the run loop observer that induces the beat has already run at that + * point) is additionally induced from the display phase of the same commit + * cycle, so that its effects are mounted before the frame is presented. */ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { public: @@ -27,13 +34,20 @@ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { std::unique_ptr uiRunLoopObserver, RuntimeScheduler &RuntimeScheduler); + ~AppleEventBeat() override; + + void requestSynchronous() const override; + #pragma mark - RunLoopObserver::Delegate void activityDidChange(const RunLoopObserver::Delegate *delegate, RunLoopObserver::Activity activity) const noexcept override; private: + class DisplayPhaseFlusher; + std::unique_ptr uiRunLoopObserver_; + std::unique_ptr displayPhaseFlusher_; }; } // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.mm b/packages/react-native/React/Fabric/AppleEventBeat.mm new file mode 100644 index 000000000000..b91adb43c2d6 --- /dev/null +++ b/packages/react-native/React/Fabric/AppleEventBeat.mm @@ -0,0 +1,182 @@ +/* + * 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. + */ + +#include "AppleEventBeat.h" + +#import +#import + +#include + +/* + * A zero-sized layer whose only purpose is to run a callback during the + * display phase of a Core Animation commit. Core Animation processes a commit + * as layout → display → (repeat until stable) → commit, so a layer marked as + * needing display during the layout phase has its `display` called after the + * whole layout pass but before the transaction is committed. + */ +@interface RCTEventBeatFlusherLayer : CALayer +@property (nonatomic, copy, nullable) void (^onDisplay)(void); +@end + +@implementation RCTEventBeatFlusherLayer + +- (void)display +{ + if (self.onDisplay != nil) { + self.onDisplay(); + } +} + +// The layer is not a visual element; never participate in animations. +- (id)actionForKey:(NSString *)event +{ + return nil; +} + +@end + +/* + * The windows that can commit a Core Animation transaction: the visible ones + * of every foreground scene. + */ +static NSArray *RCTFlushableWindows(void) +{ + NSMutableArray *windows = [NSMutableArray new]; + for (UIScene *scene in RCTSharedApplication().connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + if (scene.activationState != UISceneActivationStateForegroundActive && + scene.activationState != UISceneActivationStateForegroundInactive) { + continue; + } + for (UIWindow *window in ((UIWindowScene *)scene).windows) { + if (!window.hidden) { + [windows addObject:window]; + } + } + } + if (windows.count == 0) { + // Apps on the legacy UIApplicationDelegate lifecycle own their window + // outside of any scene, so the enumeration above finds nothing. + UIWindow *keyWindow = RCTKeyWindow(); + if (keyWindow != nil) { + [windows addObject:keyWindow]; + } + } + return windows; +} + +namespace facebook::react { + +/* + * Owns the flusher layers and keeps one attached to every window's layer so + * that whichever layer tree is being committed contains one of them. + */ +class AppleEventBeat::DisplayPhaseFlusher { + public: + DisplayPhaseFlusher(std::function callback, std::weak_ptr weakOwner) + { + // Weak keys: a window that goes away takes its own layer with it. + layers_ = [NSMapTable weakToStrongObjectsMapTable]; + auto sharedCallback = std::make_shared>(std::move(callback)); + onDisplay_ = ^{ + // The owner (indirectly) retains the event beat; if it is gone, so is + // the beat the callback points into. + auto owner = weakOwner.lock(); + if (!owner) { + return; + } + (*sharedCallback)(); + }; + } + + ~DisplayPhaseFlusher() + { + // The beat can be destroyed on any thread; layer mutations belong on the + // main thread. The block only retains the layers, and a display happening + // before this executes is made safe by the owner check above. + NSMapTable *layers = layers_; + RCTExecuteOnMainQueue(^{ + for (RCTEventBeatFlusherLayer *layer in layers.objectEnumerator) { + layer.onDisplay = nil; + [layer removeFromSuperlayer]; + } + [layers removeAllObjects]; + }); + } + + /* + * Schedules the callback to run in the display phase of the current (or + * next) Core Animation commit cycle. Main thread only. + * + * Every window gets a layer rather than only the key window: the request can + * come from any of them — a modal and the LogBox are windows of their own — + * and only a layer in a tree that is committed is displayed in this cycle. + * The induce the display triggers is coalescing, so the extra layers cost a + * dirty zero-sized layer each, not extra beats. + */ + void schedule() const + { + for (UIWindow *window in RCTFlushableWindows()) { + RCTEventBeatFlusherLayer *layer = [layers_ objectForKey:window]; + if (layer == nil) { + layer = [RCTEventBeatFlusherLayer new]; + layer.frame = CGRectZero; + layer.onDisplay = onDisplay_; + [layers_ setObject:layer forKey:window]; + } + if (layer.superlayer != window.layer) { + [window.layer addSublayer:layer]; + } + [layer setNeedsDisplay]; + } + } + + private: + NSMapTable *layers_; + void (^onDisplay_)(void); +}; + +AppleEventBeat::AppleEventBeat(std::shared_ptr ownerBox, + std::unique_ptr uiRunLoopObserver, + RuntimeScheduler &runtimeScheduler) + : EventBeat(std::move(ownerBox), runtimeScheduler), + uiRunLoopObserver_(std::move(uiRunLoopObserver)), + displayPhaseFlusher_(std::make_unique([this]() { induce(); }, ownerBox_->owner)) +{ + uiRunLoopObserver_->setDelegate(this); + uiRunLoopObserver_->enable(); +} + +AppleEventBeat::~AppleEventBeat() = default; + +void AppleEventBeat::requestSynchronous() const +{ + EventBeat::requestSynchronous(); + + // The run loop observer that ordinarily induces the beat runs before Core + // Animation commits the frame. A synchronous request made while Core + // Animation is already laying out (e.g. an event emitted from + // `layoutSubviews`) would therefore only be processed on the next frame. + // Scheduling an induce in the display phase of the current commit cycle + // processes it before this frame is presented. Multiple requests within one + // cycle coalesce into a single induce. + if (RCTIsMainQueue()) { + displayPhaseFlusher_->schedule(); + } +} + +void AppleEventBeat::activityDidChange(const RunLoopObserver::Delegate *delegate, + RunLoopObserver::Activity /*activity*/) const noexcept +{ + react_native_assert(delegate == this); + induce(); +} + +} // namespace facebook::react diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 0912c50fec13..427e927ee635 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -25,6 +25,7 @@ #import #import #import +#import #import #import #import @@ -122,6 +123,11 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + // The insets sent with the last `onSafeAreaInsetsChange` event, or nil if + // none was sent yet. A pointer because almost no view observes the safe + // area: the views that do pay for a small box, every other view only for + // the pointer. + NSValue *_lastSentSafeAreaInsets; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & -newViewProps.hitSlop.right}; } + // `onSafeAreaInsetsChange`. Scheduled whenever the prop is set, not only on + // its transitions: recycled views keep their last props, so `oldViewProps` + // of a freshly reused view is not a reliable baseline. + if (newViewProps.onSafeAreaInsetsChange) { + [self setNeedsLayout]; + } else if (oldViewProps.onSafeAreaInsetsChange) { + _lastSentSafeAreaInsets = nil; + } + // `overflow` if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); @@ -720,6 +735,105 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#pragma mark - Safe area insets + +// The view controller the view is hosted in, which is the coordinate space +// `frame` is reported in. Modals and other view controllers are positioned +// independently of the window, so the window is not a usable reference. +static UIViewController *RCTParentViewControllerOfView(UIView *view) +{ + UIResponder *responder = view.nextResponder; + while (responder != nil) { + if ([responder isKindOfClass:[UIViewController class]]) { + return (UIViewController *)responder; + } + responder = responder.nextResponder; + } + return nil; +} + +static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, CGFloat threshold) +{ + return ABS(lhs.left - rhs.left) <= threshold && ABS(lhs.top - rhs.top) <= threshold && + ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold; +} + +// The event is only ever emitted from `layoutSubviews`; everything that might +// have changed the insets merely marks the view as needing layout. This defers +// the emit out of arbitrary call contexts — in particular out of +// `updateProps`, which runs inside the mounting transaction where +// synchronously re-entering React is not safe — while keeping it in the same +// frame: the layout pass runs before the frame is displayed. +- (void)_safeAreaInsetsMayHaveChanged +{ + if (!_eventEmitter) { + return; + } + + // The view has not been mounted or laid out yet, so the insets we would + // compute are not the ones the view ends up with. + if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) { + return; + } + + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves without its overlap with + // the system UI changing stays silent, which is what makes observing views + // safe to place inside scroll views. + UIEdgeInsets insets = self.safeAreaInsets; + if (_lastSentSafeAreaInsets != nil && + RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) { + return; + } + + UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window; + CGRect frame = [self convertRect:self.bounds toView:referenceView]; + + _lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets]; + + static_cast(*_eventEmitter) + .onSafeAreaInsetsChange( + EdgeInsets{ + .left = (Float)insets.left, + .top = (Float)insets.top, + .right = (Float)insets.right, + .bottom = (Float)insets.bottom}, + RCTRectFromCGRect(frame)); +} + +// The prop is checked here rather than inside the helper so that views which +// do not use it only pay for a branch on a prop they already have in hand. +- (BOOL)_observesSafeAreaInsets +{ + return static_cast(*_props).onSafeAreaInsetsChange; +} + +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // Both the insets and the frame depend on where the view sits in the window, + // so moving or resizing it changes them without UIKit notifying us. + if ([self _observesSafeAreaInsets]) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + - (BOOL)isJSResponder { return _isJSResponder; @@ -775,6 +889,7 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + _lastSentSafeAreaInsets = nil; _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index a78d8fbd869e..7834ef7392a0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3264,6 +3264,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo public fun setMoveShouldSetResponder (Landroid/view/View;Z)V public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V public fun setNativeId (Landroid/view/View;Ljava/lang/String;)V + public fun setOnSafeAreaInsetsChange (Landroid/view/View;Z)V public fun setOpacity (Landroid/view/View;F)V public fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V public fun setOutlineOffset (Landroid/view/View;F)V @@ -4604,6 +4605,7 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field NONE Ljava/lang/String; public static final field NUMBER_OF_LINES Ljava/lang/String; public static final field ON Ljava/lang/String; + public static final field ON_SAFE_AREA_INSETS_CHANGE Ljava/lang/String; public static final field OPACITY Ljava/lang/String; public static final field OUTLINE_COLOR Ljava/lang/String; public static final field OUTLINE_OFFSET Ljava/lang/String; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index d2747ebda577..e3c937145c35 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -37,6 +37,7 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.FocusEvent; import com.facebook.react.uimanager.events.PointerEventHelper; +import com.facebook.react.uimanager.internal.SafeAreaInsetsObserver; import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.ArrayList; @@ -74,6 +75,10 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { @Override protected @Nullable T prepareToRecycleView(@NonNull ThemedReactContext reactContext, T view) { + // Stops safe area observation and clears its tag; the next user of the + // view re-enables it through the prop if needed. + SafeAreaInsetsObserver.setEnabled(view, false); + // Reset tags view.setTag(null); view.setTag(R.id.pointer_events, null); @@ -293,6 +298,15 @@ public void setRenderToHardwareTexture(@NonNull T view, boolean useHWTexture) { view.setTag(R.id.use_hardware_layer, useHWTexture); } + /** + * Views only observe safe area insets while a JavaScript handler is attached, so views that do + * not use the prop are not affected. + */ + @ReactProp(name = ViewProps.ON_SAFE_AREA_INSETS_CHANGE, defaultBoolean = false) + public void setOnSafeAreaInsetsChange(@NonNull T view, boolean onSafeAreaInsetsChange) { + SafeAreaInsetsObserver.setEnabled(view, onSafeAreaInsetsChange); + } + @ReactProp(name = ViewProps.TEST_ID) public void setTestId(@NonNull T view, @Nullable String testId) { view.setTag(R.id.react_test_id, testId); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 8aeb06848370..cca374268438 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -158,6 +158,7 @@ public object ViewProps { public const val SHADOW_COLOR: String = "shadowColor" public const val Z_INDEX: String = "zIndex" public const val RENDER_TO_HARDWARE_TEXTURE: String = "renderToHardwareTextureAndroid" + public const val ON_SAFE_AREA_INSETS_CHANGE: String = "experimental_onSafeAreaInsetsChange" public const val ACCESSIBILITY_LABEL: String = "accessibilityLabel" public const val ACCESSIBILITY_COLLECTION: String = "accessibilityCollection" public const val ACCESSIBILITY_COLLECTION_ITEM: String = "accessibilityCollectionItem" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt new file mode 100644 index 000000000000..fdfa4329633f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -0,0 +1,65 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil.pxToDp + +/** + * Emitted when the part of a view that is covered by the system UI, or the position of that view in + * the window, changes. + * + * Dispatched synchronously so that the layout depending on the insets is mounted in the frame the + * insets changed in, rather than the one after it. + */ +internal class SafeAreaInsetsChangeEvent( + surfaceId: Int, + viewTag: Int, + private val insetTop: Int, + private val insetRight: Int, + private val insetBottom: Int, + private val insetLeft: Int, + private val frameX: Int, + private val frameY: Int, + private val frameWidth: Int, + private val frameHeight: Int, +) : Event(surfaceId, viewTag) { + + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putMap( + "insets", + Arguments.createMap().apply { + putDouble("top", insetTop.toDp()) + putDouble("right", insetRight.toDp()) + putDouble("bottom", insetBottom.toDp()) + putDouble("left", insetLeft.toDp()) + }, + ) + putMap( + "frame", + Arguments.createMap().apply { + putDouble("x", frameX.toDp()) + putDouble("y", frameY.toDp()) + putDouble("width", frameWidth.toDp()) + putDouble("height", frameHeight.toDp()) + }, + ) + } + + override fun experimental_isSynchronous(): Boolean = true + + internal companion object { + const val EVENT_NAME: String = "topSafeAreaInsetsChange" + + private fun Int.toDp(): Double = toFloat().pxToDp().toDouble() + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt new file mode 100644 index 000000000000..5512d2e7c5dc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,212 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.internal + +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.R +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent +import kotlin.math.max +import kotlin.math.min + +/** + * Observes the part of a view that is covered by the system UI, and emits + * [SafeAreaInsetsChangeEvent] whenever it, or the position of the view in the window, changes. + * + * One observer is attached per view that sets the `onSafeAreaInsetsChange` prop. Views without the + * prop never get an observer, and so pay nothing for this. + */ +internal class SafeAreaInsetsObserver private constructor(private val view: View) : + ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { + + // Scratch state, reused so that observing a view allocates nothing per frame. + private val visibleRect = Rect() + private val frameRect = Rect() + private val insets = IntArray(4) + private val lastInsets = IntArray(4) + + private var hasLastInsets = false + private var isListening = false + + private fun start() { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) { + onViewAttachedToWindow(view) + } + } + + private fun stop() { + view.removeOnAttachStateChangeListener(this) + stopListening() + hasLastInsets = false + } + + private fun startListening() { + if (!isListening) { + isListening = true + view.viewTreeObserver.addOnPreDrawListener(this) + } + } + + private fun stopListening() { + if (isListening) { + isListening = false + view.viewTreeObserver.removeOnPreDrawListener(this) + } + } + + override fun onViewAttachedToWindow(v: View) { + // The insets and the frame both depend on where the view ends up in the window, which is only + // known once it has been laid out. A pre-draw listener is the cheapest hook that catches every + // change: window insets, layout, and scrolling ancestors alike. + startListening() + maybeEmit() + } + + override fun onViewDetachedFromWindow(v: View) { + stopListening() + } + + override fun onPreDraw(): Boolean { + maybeEmit() + return true + } + + private fun maybeEmit() { + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves (scrolling, layout) + // without its overlap with the system UI changing stays silent. This is + // what makes observing views safe to place inside scroll views — and it + // prevents feedback loops, since the synchronous render caused by an event + // produces a new frame, which runs this pre-draw listener again. + if (!computeSafeAreaInsets(view, visibleRect, insets)) { + return + } + if (hasLastInsets && insets.contentEquals(lastInsets)) { + return + } + val frame = getFrame(view, frameRect) ?: return + val eventDispatcher = + UIManagerHelper.getEventDispatcher(UIManagerHelper.getReactContext(view)) ?: return + // Recorded only once the event is actually dispatched, so a failed lookup + // above does not permanently swallow this inset value. + insets.copyInto(lastInsets) + hasLastInsets = true + eventDispatcher.dispatchEvent( + SafeAreaInsetsChangeEvent( + surfaceId = UIManagerHelper.getSurfaceId(view), + viewTag = view.id, + insetTop = insets[TOP], + insetRight = insets[RIGHT], + insetBottom = insets[BOTTOM], + insetLeft = insets[LEFT], + frameX = frame.left, + frameY = frame.top, + frameWidth = frame.width(), + frameHeight = frame.height(), + ), + ) + } + + companion object { + private const val TOP = 0 + private const val RIGHT = 1 + private const val BOTTOM = 2 + private const val LEFT = 3 + + /** + * Starts or stops observing safe area insets for [view]. Safe to call repeatedly with the same + * value. + */ + @JvmStatic + fun setEnabled(view: View, enabled: Boolean) { + val existing = view.getTag(R.id.safe_area_insets_observer) as? SafeAreaInsetsObserver + if (enabled == (existing != null)) { + return + } + if (enabled) { + val observer = SafeAreaInsetsObserver(view) + view.setTag(R.id.safe_area_insets_observer, observer) + observer.start() + } else { + view.setTag(R.id.safe_area_insets_observer, null) + existing?.stop() + } + } + + /** + * The insets of the window that overlap [view], in the view's own coordinate space. A view that + * does not reach under the system UI has no insets. + * + * Also used with the window's decor view to report window-level safe area insets through the + * `Dimensions` module. + */ + @JvmStatic + fun getSafeAreaInsets(view: View): Insets? { + val insets = IntArray(4) + if (!computeSafeAreaInsets(view, Rect(), insets)) { + return null + } + return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM]) + } + + /** + * Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using + * [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out] + * untouched. + */ + private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean { + // The view has not been laid out yet. + if (view.width == 0 || view.height == 0) { + return false + } + val rootView = view.rootView + val windowInsets = + ViewCompat.getRootWindowInsets(rootView)?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) ?: return false + + if (!view.getGlobalVisibleRect(visibleRect)) { + // The view is fully clipped by an ancestor (e.g. scrolled out of a + // scroll view); the rect is undefined in that case, and a view that is + // not visible has no meaningful insets. + return false + } + out[TOP] = max(windowInsets.top - visibleRect.top, 0) + out[RIGHT] = + max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0) + out[BOTTOM] = + max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0) + out[LEFT] = max(windowInsets.left - visibleRect.left, 0) + return true + } + + /** The frame of [view] in the coordinate space of the window, written into [out]. */ + private fun getFrame(view: View, out: Rect): Rect? { + val rootView = view.rootView as? ViewGroup ?: return null + if (view.parent == null) { + return null + } + view.getDrawingRect(out) + try { + rootView.offsetDescendantRectToMyCoords(view, out) + } catch (e: IllegalArgumentException) { + // Thrown when the view is not a descendant of its own root view, which can happen while it + // is being unmounted. + return null + } + return out + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 0e51a358eb77..a4820e5d8da1 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -82,4 +82,7 @@ + + + diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp index 4e981efd3f80..5f3b261ae664 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp @@ -32,6 +32,42 @@ void BaseViewEventEmitter::onAccessibilityEscape() const { dispatchEvent("accessibilityEscape"); } +#pragma mark - Safe area + +void BaseViewEventEmitter::onSafeAreaInsetsChange( + const EdgeInsets& insets, + const Rect& frame) const { + // Dispatched synchronously and as a discrete event so that React processes it + // before the current frame is presented. Both the thread this is called from + // (the UI thread) and the JavaScript thread are blocked until React has + // finished rendering. + experimental_flushSync([this, insets, frame]() { + dispatchEvent( + "safeAreaInsetsChange", + [insets, frame](jsi::Runtime& runtime) { + auto payload = jsi::Object(runtime); + { + auto insetsPayload = jsi::Object(runtime); + insetsPayload.setProperty(runtime, "top", insets.top); + insetsPayload.setProperty(runtime, "right", insets.right); + insetsPayload.setProperty(runtime, "bottom", insets.bottom); + insetsPayload.setProperty(runtime, "left", insets.left); + payload.setProperty(runtime, "insets", insetsPayload); + } + { + auto framePayload = jsi::Object(runtime); + framePayload.setProperty(runtime, "x", frame.origin.x); + framePayload.setProperty(runtime, "y", frame.origin.y); + framePayload.setProperty(runtime, "width", frame.size.width); + framePayload.setProperty(runtime, "height", frame.size.height); + payload.setProperty(runtime, "frame", framePayload); + } + return payload; + }, + RawEvent::Category::Discrete); + }); +} + #pragma mark - Layout void BaseViewEventEmitter::onLayout(const LayoutMetrics& layoutMetrics) const { diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h index 8d9978a80fc2..01bef6388b7c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h @@ -14,6 +14,7 @@ #include #include +#include #include "TouchEventEmitter.h" @@ -34,6 +35,19 @@ class BaseViewEventEmitter : public TouchEventEmitter { void onLayout(const LayoutMetrics &layoutMetrics) const; +#pragma mark - Safe area + + /* + * Emits `onSafeAreaInsetsChange` with the portion of the view that is covered + * by the system UI (status bar, home indicator, display cutouts, ...) and the + * frame of the view at the time of the event. + * + * The event is dispatched synchronously, blocking the thread it is called + * from until React has re-rendered, so that the layout that depends on the + * insets is mounted in the same frame the insets changed in. + */ + void onSafeAreaInsetsChange(const EdgeInsets &insets, const Rect &frame) const; + #pragma mark - Focus void onFocus() const; void onBlur() const; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 1cb30b0ed6a8..713ab1470fd0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -303,6 +303,12 @@ BaseViewProps::BaseViewProps( "onLayout", sourceProps.onLayout, {})), + onSafeAreaInsetsChange(convertRawProp( + context, + rawProps, + "experimental_onSafeAreaInsetsChange", + sourceProps.onSafeAreaInsetsChange, + {})), events(convertRawProp(context, rawProps, sourceProps.events, {})), collapsable(convertRawProp( context, @@ -373,6 +379,8 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(isolation); RAW_SET_PROP_SWITCH_CASE_BASIC(hitSlop); RAW_SET_PROP_SWITCH_CASE_BASIC(onLayout); + RAW_SET_PROP_SWITCH_CASE( + onSafeAreaInsetsChange, "experimental_onSafeAreaInsetsChange"); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsable); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsableChildren); RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); @@ -609,6 +617,10 @@ SharedDebugStringConvertibleList BaseViewProps::getDebugProps() const { "backgroundImage", backgroundImage, defaultBaseViewProps.backgroundImage), + debugStringConvertibleItem( + "experimental_onSafeAreaInsetsChange", + onSafeAreaInsetsChange, + defaultBaseViewProps.onSafeAreaInsetsChange), }; } #endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index c78c4f38729b..b72c5f944f63 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -103,6 +103,7 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { PointerEventsMode pointerEvents{}; EdgeInsets hitSlop{}; bool onLayout{}; + bool onSafeAreaInsetsChange{}; ViewEvents events{}; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index a166a90546c6..9567c79c8178 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -55,6 +55,8 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || + // Observing the safe area requires a host view to observe with. + viewProps.onSafeAreaInsetsChange || !viewProps.filter.empty() || viewProps.mixBlendMode != BlendMode::Normal || viewProps.isolation == Isolation::Isolate || diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index 32289a2c43f1..0c7d05b7d33a 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -563,6 +563,10 @@ folly::dynamic HostPlatformViewProps::getDiffProps( result["onLayout"] = onLayout; } + if (onSafeAreaInsetsChange != oldProps->onSafeAreaInsetsChange) { + result["experimental_onSafeAreaInsetsChange"] = onSafeAreaInsetsChange; + } + if (zIndex != oldProps->zIndex) { result["zIndex"] = zIndex.has_value() ? zIndex.value() : folly::dynamic(nullptr); diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp index cdb05f4719fd..839e2f19f90b 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp @@ -53,7 +53,14 @@ void EventBeat::induce() const { isEventBeatRequested_ = false; if (isBeatCallbackScheduled_) { - return; + // An asynchronous beat is already scheduled but has not run yet. A + // synchronous request must not be stranded behind it (it would silently + // lose its this-frame guarantee, and the leftover flag would make an + // unrelated later beat blocking), so it proceeds and processes the queue + // now; the already scheduled beat will simply find an empty queue. + if (!isSynchronousRequested_) { + return; + } } isBeatCallbackScheduled_ = true; diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h index 3740b457e785..b8da33cc9ac2 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h @@ -111,6 +111,16 @@ class EventBeat { */ virtual void requestSynchronous() const; + /* + * Induces the next beat to happen as soon as possible. + * Receiver might ignore the call if a beat was not requested. + * + * Ordinarily called by the platform once per frame; also callable by a + * consumer right after `requestSynchronous` to process the queue immediately + * at the call site instead of at the next frame boundary. + */ + void induce() const; + /* * The callback will be executed once a consumer (for example EventQueue) * calls either `EventBeat::request` or `EventBeat::requestSynchronous`. The @@ -128,12 +138,6 @@ class EventBeat { void unstable_setInduceCallback(std::function callback); protected: - /* - * Induces the next beat to happen as soon as possible. - * Receiver might ignore the call if a beat was not requested. - */ - void induce() const; - BeatCallback beatCallback_; std::function induceCallback_; std::shared_ptr ownerBox_; diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp new file mode 100644 index 000000000000..1dda2fd47fd6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp @@ -0,0 +1,168 @@ +/* + * 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. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "StubQueue.h" + +namespace facebook::react { + +class EventBeatTestFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + bool enableBridgelessArchitecture() override { + return true; + } +}; + +class EventBeatTest : public testing::Test { + protected: + void SetUp() override { + ReactNativeFeatureFlags::dangerouslyReset(); + ReactNativeFeatureFlags::override( + std::make_unique()); + + runtime_ = facebook::hermes::makeHermesRuntime( + ::hermes::vm::RuntimeConfig::Builder().build()); + stubQueue_ = std::make_unique(); + + RuntimeExecutor runtimeExecutor = + [this]( + std::function&& callback) { + stubQueue_->runOnQueue([this, callback = std::move(callback)]() { + callback(*runtime_); + }); + }; + + runtimeScheduler_ = std::make_unique(runtimeExecutor); + + ownerBox_ = std::make_shared(); + owner_ = std::make_shared(0); + ownerBox_->owner = owner_; + eventBeat_ = std::make_unique(ownerBox_, *runtimeScheduler_); + } + + void TearDown() override { + ReactNativeFeatureFlags::dangerouslyReset(); + } + + std::unique_ptr runtime_; + std::unique_ptr stubQueue_; + std::unique_ptr runtimeScheduler_; + std::shared_ptr ownerBox_; + std::shared_ptr owner_; + std::unique_ptr eventBeat_; +}; + +TEST_F(EventBeatTest, induceWithoutRequestIsNoop) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + eventBeat_->induce(); + + EXPECT_EQ(beatCount, 0); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, synchronousRequestIsProcessedAtInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + eventBeat_->requestSynchronous(); + EXPECT_EQ(beatCount, 0); + + // Platform implementations induce the beat at a point where the effects of + // synchronous events can still make the current frame (the display phase on + // Apple, before the draw on Android). The beat callback runs synchronously + // before `induce` returns, with both threads blocked. + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request was consumed: another induce does nothing. + eventBeat_->induce(); + EXPECT_EQ(beatCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, requestMadeDuringBeatIsProcessedByASubsequentInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback([&](jsi::Runtime& /*runtime*/) { + beatCount++; + if (beatCount == 1) { + // A synchronous request made from within the beat (e.g. an event whose + // handler causes another synchronous event). Platform implementations + // defer the induce for it (the display phase flusher on Apple, the next + // pre-draw on Android) rather than inducing from within the beat. + eventBeat_->requestSynchronous(); + } + }); + + eventBeat_->requestSynchronous(); + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request made during the beat is not lost: the next induce processes + // it. + std::thread driver2([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver2.join(); + + EXPECT_EQ(beatCount, 2); +} + +TEST_F(EventBeatTest, synchronousRequestIsNotStrandedBehindScheduledBeat) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + // An asynchronous beat is scheduled but has not run yet. + eventBeat_->request(); + eventBeat_->induce(); + EXPECT_EQ(beatCount, 0); + + // A synchronous request arriving now must still be processed by its induce + // instead of being silently deferred behind the scheduled beat. + eventBeat_->requestSynchronous(); + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 2); +} + +} // namespace facebook::react diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js new file mode 100644 index 000000000000..f1e7d8d74882 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,296 @@ +/** + * 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 + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {SafeAreaInsetsChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import {useCallback, useState} from 'react'; +import { + Button, + Modal, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; + +type Insets = SafeAreaInsetsChangeEvent['nativeEvent']['insets']; +type Frame = SafeAreaInsetsChangeEvent['nativeEvent']['frame']; + +function useSafeAreaInsets(): [ + ?Insets, + ?Frame, + (SafeAreaInsetsChangeEvent) => void, +] { + const [state, setState] = useState(null); + const onSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setState({ + insets: event.nativeEvent.insets, + frame: event.nativeEvent.frame, + }); + }, + [], + ); + return [state?.insets, state?.frame, onSafeAreaInsetsChange]; +} + +function InsetsReadoutExample(): React.Node { + const [insets, frame, onSafeAreaInsetsChange] = useSafeAreaInsets(); + + return ( + + + {insets == null + ? 'Waiting for insets…' + : `insets: {top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}}`} + + + {frame == null + ? '' + : `frame: {x: ${frame.x}, y: ${frame.y}, width: ${frame.width}, height: ${frame.height}}`} + + + This view does not reach under the system UI, so its insets are zero. + + + ); +} + +function FullScreenModalContent({onClose}: {onClose: () => void}): React.Node { + const [insets, , onSafeAreaInsetsChange] = useSafeAreaInsets(); + const [applied, setApplied] = useState(false); + + // The view observes the safe area but no event has been received yet. With + // synchronous dispatch this state is committed but never displayed: the + // event fires while this tree is being mounted and the insets are applied + // before the frame is presented. If a frame ever renders in this state, the + // dispatch was not synchronous. + const waitingForInsets = applied && insets == null; + + return ( + + + + {insets != null + ? `top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}` + : waitingForInsets + ? 'Observing the safe area, inset event not received yet — this state should never be visible.' + : 'Insets not applied: the content extends under the system UI.'} + + + Applying the insets and rotating the device both update the padding in + the same frame, without the content jumping. + + {!applied ? ( +