From 2837ee51840efd8aa5c492e76114daa50a33b255 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 02:42:16 +0800 Subject: [PATCH 1/8] feat(ios): detect screen unmount via C++ EventDispatcher - Pass EventDispatcher from ComponentDescriptor through State to native view - Listen for topWillDisappear events to detect when presenter screen unmounts - Capture presenter screen tag and controller at presentation time - Distinguish navigation pop from modal dismiss by checking nav stack - Dismiss sheet when its presenter screen is being popped This replaces RNSLifecycleListenerProtocol per react-native-screens feedback. See: https://github.com/software-mansion/react-native-screens/pull/3527 --- .../TrueSheetViewComponentDescriptor.h | 4 + .../TrueSheetSpec/TrueSheetViewShadowNode.cpp | 13 ++ .../TrueSheetSpec/TrueSheetViewShadowNode.h | 11 + .../TrueSheetSpec/TrueSheetViewState.cpp | 12 ++ .../TrueSheetSpec/TrueSheetViewState.h | 10 + docs/SCREEN_UNMOUNT_DETECTION.md | 201 ++++++++++++++++++ ios/TrueSheetView.mm | 87 ++++++++ ios/TrueSheetViewController.mm | 9 +- 8 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 docs/SCREEN_UNMOUNT_DETECTION.md diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h index 065f95a1..d6a44373 100644 --- a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h @@ -18,6 +18,10 @@ class TrueSheetViewComponentDescriptor final concreteShadowNode.adjustLayoutWithState(); ConcreteComponentDescriptor::adopt(shadowNode); + +#if !defined(ANDROID) + concreteShadowNode.setEventDispatcher(eventDispatcher_); +#endif } }; diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp index eeda2447..c6f27f50 100644 --- a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp @@ -45,4 +45,17 @@ void TrueSheetViewShadowNode::adjustLayoutWithState() { } } +#if !defined(ANDROID) +void TrueSheetViewShadowNode::setEventDispatcher( + std::weak_ptr dispatcher) { + getStateDataMutable().setEventDispatcher(dispatcher); +} + +TrueSheetViewShadowNode::StateData & +TrueSheetViewShadowNode::getStateDataMutable() { + ensureUnsealed(); + return const_cast(getStateData()); +} +#endif + } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h index 01ab9a60..d0efc832 100644 --- a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h @@ -8,6 +8,8 @@ namespace facebook::react { +class EventDispatcher; + JSI_EXPORT extern const char TrueSheetViewComponentName[]; /* @@ -22,6 +24,8 @@ class JSI_EXPORT TrueSheetViewShadowNode final using ConcreteViewShadowNode::ConcreteViewShadowNode; public: + using StateData = ConcreteViewShadowNode::ConcreteStateData; + static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::RootNodeKind); @@ -29,6 +33,13 @@ class JSI_EXPORT TrueSheetViewShadowNode final } void adjustLayoutWithState(); + +#if !defined(ANDROID) + void setEventDispatcher(std::weak_ptr dispatcher); + + private: + StateData &getStateDataMutable(); +#endif }; } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp index 8b621130..a716c352 100644 --- a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp @@ -8,4 +8,16 @@ folly::dynamic TrueSheetViewState::getDynamic() const { } #endif +#if !defined(ANDROID) +void TrueSheetViewState::setEventDispatcher( + std::weak_ptr dispatcher) { + eventDispatcher_ = dispatcher; +} + +std::weak_ptr TrueSheetViewState::getEventDispatcher() + const noexcept { + return eventDispatcher_; +} +#endif + } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h index d96b6923..c87f13a5 100644 --- a/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +++ b/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h @@ -10,6 +10,8 @@ namespace facebook::react { +class EventDispatcher; + /* * State for component. * Contains the container dimensions from native. @@ -37,6 +39,14 @@ class TrueSheetViewState final { return MapBufferBuilder::EMPTY(); } #endif + +#if !defined(ANDROID) + void setEventDispatcher(std::weak_ptr dispatcher); + std::weak_ptr getEventDispatcher() const noexcept; + + private: + std::weak_ptr eventDispatcher_; +#endif }; } // namespace facebook::react diff --git a/docs/SCREEN_UNMOUNT_DETECTION.md b/docs/SCREEN_UNMOUNT_DETECTION.md new file mode 100644 index 00000000..2cb92e4a --- /dev/null +++ b/docs/SCREEN_UNMOUNT_DETECTION.md @@ -0,0 +1,201 @@ +# Screen Unmount Detection Research + +Exploring alternatives to `RNSLifecycleListenerProtocol` for detecting when a presenting screen unmounts while a sheet is presented. + +**Context**: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 + +## Approach 1: `RCTMountingTransactionObserving` + +Conform `TrueSheetView` to `RCTMountingTransactionObserving` and implement mounting transaction callbacks. + +### 1a. `mountingTransactionWillMount` + +Called right **before** first mutation executes. + +```objc +- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction &)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry { + for (const auto &mutation : transaction.getMutations()) { + if (mutation.type == ShadowViewMutation::Delete && mutation.oldChildShadowView.tag == self.tag) { + [self dismissAllAnimated:NO completion:nil]; + return; + } + } +} +``` + +| Scenario | Result | +|----------|--------| +| `navigation.goBack()` | Split second delay before dismiss | +| Native back gesture | Too late - controller already nil? | + +### 1b. `mountingTransactionDidMount` + +Called right **after** last mutation executes. + +**Result**: Not working. Per React Native docs, `DidMount` is NOT called for views being unmounted (already unregistered as observer). + +### Notes + +- These approaches detect when `TrueSheetView` itself is deleted +- Does NOT detect when the **presenting screen** is deleted (parent unmount) +- The timing seems off for native back gestures +- Neither `WillMount` nor `DidMount` provide reliable timing + +--- + +## Approach 2: Event Dispatcher (`RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED`) + +Observe events via `NSNotificationCenter` using `RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED`. + +### Implementation + +```objc +- (void)startObservingEventDispatcher { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleEventDispatcherNotification:) + name:@"RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED" + object:nil]; +} + +- (void)handleEventDispatcherNotification:(NSNotification *)notification { + id event = notification.userInfo[@"event"]; + NSString *eventName = [event eventName]; + // Look for onWillDisappear... +} +``` + +### Findings + +**Result**: Not working. + +Only `onTransitionProgress` and `onHeaderHeightChange` are posted via this notification. The `onWillDisappear` event is emitted directly through the Fabric event emitter: + +```objc +// react-native-screens uses direct emitter, NOT notification +std::dynamic_pointer_cast(_eventEmitter) + ->onWillDisappear(...); +``` + +--- + +## Approach 3: C++ EventDispatcher via State (ComponentDescriptor) + +Pass the C++ `EventDispatcher` through state to the native view and add an `EventListener` to intercept all events. + +### How it works + +1. **ComponentDescriptor** has access to `eventDispatcher_` (member of `ConcreteComponentDescriptor`) +2. In `adopt()`, pass eventDispatcher to ShadowNode similar to how imageLoader is passed: + ```cpp + // TrueSheetViewComponentDescriptor.h + void adopt(ShadowNode &shadowNode) const override { + // ... existing code ... + concreteShadowNode.setEventDispatcher(eventDispatcher_); + ConcreteComponentDescriptor::adopt(shadowNode); + } + ``` +3. Store in State class: + ```cpp + // TrueSheetViewState.h + void setEventDispatcher(std::weak_ptr dispatcher); + std::weak_ptr getEventDispatcher() const noexcept; + ``` +4. In native view's `updateState:oldState:`, retrieve and use it: + ```objc + - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { + _state = std::static_pointer_cast<...>(state); + if (auto dispatcherPtr = _state.get()->getData().getEventDispatcher().lock()) { + // Add event listener to observe onWillDisappear from any RNSScreen + dispatcherPtr->addListener(std::make_shared( + [](const RawEvent& event) { + if (event.type == "onWillDisappear") { + // Dismiss sheet if it's our presenter + } + return false; // Don't intercept, pass through + } + )); + } + } + ``` + +### RawEvent structure + +```cpp +struct RawEvent { + std::string type; // e.g., "onWillDisappear" + SharedEventPayload eventPayload; + SharedEventTarget eventTarget; + std::weak_ptr shadowNodeFamily; // Source component + Category category; +}; +``` + +### Reference: imageLoader pattern in react-native-screens + +```cpp +// RNSScreenStackHeaderConfigComponentDescriptor.h +void adopt(ShadowNode &shadowNode) const override { + // ... + std::weak_ptr imageLoader = + contextContainer_->at>("RCTImageLoader"); + configShadowNode.setImageLoader(imageLoader); +} + +// RNSScreenStackHeaderConfig.mm +- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { + _state = std::static_pointer_cast<...>(state); + if (auto imgLoaderPtr = _state.get()->getData().getImageLoader().lock()) { + _imageLoader = react::unwrapManagedObject(imgLoaderPtr); + } +} +``` + +### Implementation Status + +**IMPLEMENTED** - This is the current solution. + +### How It Works + +1. **At presentation time**, capture: + - `_presenterScreenTag` - Tag of the RNSScreenView containing the sheet + - `_presenterScreenController` - The screen's view controller (via responder chain) + - `_parentModalTag` - Tag of parent RNSModalScreen (if inside a modal) + +2. **Listen for `topWillDisappear` events** via the EventDispatcher: + - Compare event's screen tag with `_presenterScreenTag` + - If they match, the presenter screen is being removed + +3. **Distinguish navigation pop from modal dismiss**: + - If inside a modal (`_parentModalTag != 0`), check if screen is still in nav stack + - If still in nav stack → modal is being dismissed → skip (modal handles sheet) + - If not in nav stack → navigation pop → dismiss sheet + +### Files Changed + +- `common/cpp/.../TrueSheetViewState.h` - Added `eventDispatcher_` member and getter/setter +- `common/cpp/.../TrueSheetViewState.cpp` - Implemented getter/setter +- `common/cpp/.../TrueSheetViewShadowNode.h` - Added `setEventDispatcher()` and `getStateDataMutable()` +- `common/cpp/.../TrueSheetViewShadowNode.cpp` - Implemented methods +- `common/cpp/.../TrueSheetViewComponentDescriptor.h` - Pass `eventDispatcher_` in `adopt()` +- `ios/TrueSheetView.mm` - Event listener setup and screen unmount detection logic + +--- + +## Previous Solution: `RNSLifecycleListenerProtocol` + +The protocol from react-native-screens notifies presented view controllers when the presenter is unmounting: + +```objc +- (void)screenWillDisappear:(UIViewController *)screen isPresenterUnmounting:(BOOL)isPresenterUnmounting; +``` + +### Why it worked + +- Called by `RNSScreen` when it's about to disappear +- Notifies any presented view controller that conforms to the protocol +- Has access to the screen's controller before it becomes nil + +### Why we moved away + +react-native-screens maintainers want to avoid component-specific integrations given planned deprecation of current implementation. See: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index ae1156c8..6fe69a86 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -32,6 +32,8 @@ #import #import #import +#import +#import #import using namespace facebook::react; @@ -52,6 +54,11 @@ @implementation TrueSheetView { BOOL _isSheetUpdatePending; BOOL _pendingLayoutUpdate; BOOL _didInitiallyPresent; + std::shared_ptr _eventDispatcher; + std::shared_ptr _eventListener; + NSInteger _presenterScreenTag; + __weak UIViewController *_presenterScreenController; + NSInteger _parentModalTag; } #pragma mark - Initialization @@ -104,6 +111,12 @@ - (void)didMoveToWindow { } - (void)dealloc { + if (_eventDispatcher && _eventListener) { + _eventDispatcher->removeListener(_eventListener); + } + _eventListener = nullptr; + _eventDispatcher = nullptr; + if (_controller && _controller.presentingViewController) { // Find the root presenting controller to dismiss the entire stack UIViewController *root = _controller.presentingViewController; @@ -227,6 +240,55 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & if (_controller) { [self updateStateWithSize:_controller.view.frame.size]; } + + // Setup event listener for screen lifecycle events (onWillDisappear) + if (!_eventDispatcher) { + if (auto dispatcherPtr = _state.get()->getData().getEventDispatcher().lock()) { + _eventDispatcher = dispatcherPtr; + + __weak TrueSheetView *weakSelf = self; + + _eventListener = std::make_shared([weakSelf](const RawEvent &event) { + TrueSheetView *strongSelf = weakSelf; + if (!strongSelf) { + return false; + } + + if (event.type == "topWillDisappear") { + NSInteger presenterScreenTag = strongSelf->_presenterScreenTag; + if (!strongSelf->_controller.isPresented || + strongSelf->_controller.isBeingDismissed || + presenterScreenTag == 0) { + return false; + } + + if (auto family = event.shadowNodeFamily.lock()) { + Tag screenTag = family->getTag(); + + if (presenterScreenTag == screenTag) { + // If inside a modal, check if this is a nav pop vs modal dismiss + NSInteger parentModalTag = strongSelf->_parentModalTag; + if (parentModalTag != 0) { + UIViewController *screenController = strongSelf->_presenterScreenController; + UINavigationController *navController = screenController.navigationController; + + // If screen is still in nav stack, it's a modal dismiss - skip + // (the modal dismissal will handle the sheet) + if (navController && [navController.viewControllers containsObject:screenController]) { + return false; + } + } + + [strongSelf dismissAllAnimated:YES completion:nil]; + } + } + } + return false; + }); + + _eventDispatcher->addListener(_eventListener); + } + } } /** @@ -381,6 +443,29 @@ - (void)presentAtIndex:(NSInteger)index [_controller setupSheetDetents]; [_controller setupActiveDetentWithIndex:index]; + // Capture presenter screen info for screen unmount detection + _presenterScreenTag = 0; + _presenterScreenController = nil; + _parentModalTag = 0; + UIView *view = self.superview; + while (view) { + NSString *className = NSStringFromClass([view class]); + if (_presenterScreenTag == 0 && [className isEqualToString:@"RNSScreenView"]) { + _presenterScreenTag = view.tag; + // Get the screen's controller via responder chain + for (UIResponder *responder = view; responder; responder = responder.nextResponder) { + if ([responder isKindOfClass:[UIViewController class]]) { + _presenterScreenController = (UIViewController *)responder; + break; + } + } + } else if ([className isEqualToString:@"RNSModalScreen"]) { + _parentModalTag = view.tag; + break; + } + view = view.superview; + } + [presentingViewController presentViewController:_controller animated:animated completion:^{ @@ -557,6 +642,8 @@ - (void)viewControllerDidDetectScreenDisappear { [self dismissAllAnimated:YES completion:nil]; } +// See docs/SCREEN_UNMOUNT_DETECTION.md for research on detecting screen unmount + #pragma mark - Private Helpers - (UIViewController *)findPresentingViewController { diff --git a/ios/TrueSheetViewController.mm b/ios/TrueSheetViewController.mm index 679cff8e..e4dbbfcb 100644 --- a/ios/TrueSheetViewController.mm +++ b/ios/TrueSheetViewController.mm @@ -804,13 +804,8 @@ - (void)sheetPresentationControllerDidChangeSelectedDetentIdentifier: #if RNS_LIFECYCLE_LISTENER_PROTOCOL_AVAILABLE - (void)screenWillDisappear:(UIViewController *)screen isPresenterUnmounting:(BOOL)isPresenterUnmounting { - // Skip if not presented, being dismissed, or if the presenter (modal) itself is being unmounted - if (!_isPresented || self.isBeingDismissed || isPresenterUnmounting) { - return; - } - if ([self.delegate respondsToSelector:@selector(viewControllerDidDetectScreenDisappear)]) { - [self.delegate viewControllerDidDetectScreenDisappear]; - } + // TODO: Disabled - exploring alternative approaches per react-native-screens feedback + // See: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 } #endif From 396084e1a36d59580977b6d515881c1cf46c4130 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 03:05:04 +0800 Subject: [PATCH 2/8] refactor(ios): extract RNScreensEventObserver from TrueSheetView --- example/bare/ios/Podfile.lock | 4 +- ios/TrueSheetView.mm | 103 +++++------------------ ios/core/RNScreensEventObserver.h | 40 +++++++++ ios/core/RNScreensEventObserver.mm | 129 +++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 86 deletions(-) create mode 100644 ios/core/RNScreensEventObserver.h create mode 100644 ios/core/RNScreensEventObserver.mm diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index a6a8f72e..97dfbc24 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -2646,7 +2646,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNTrueSheet (3.7.2): + - RNTrueSheet (3.7.3): - boost - DoubleConversion - fast_float @@ -3095,7 +3095,7 @@ SPEC CHECKSUMS: RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479 - RNTrueSheet: fdf1146eb62282d33c4982c2903593466913afdd + RNTrueSheet: ccd12867de774263d4c4f9dd4ea621900d69780c RNWorklets: d9c050940f140af5d8b611d937eab1cbfce5e9a5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index 6fe69a86..02cf4071 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -14,6 +14,7 @@ #import "TrueSheetFooterView.h" #import "TrueSheetModule.h" #import "TrueSheetViewController.h" +#import "core/RNScreensEventObserver.h" #import "events/TrueSheetDragEvents.h" #import "events/TrueSheetFocusEvents.h" #import "events/TrueSheetLifecycleEvents.h" @@ -32,13 +33,12 @@ #import #import #import -#import -#import #import using namespace facebook::react; -@interface TrueSheetView () +@interface TrueSheetView () @end @implementation TrueSheetView { @@ -54,11 +54,7 @@ @implementation TrueSheetView { BOOL _isSheetUpdatePending; BOOL _pendingLayoutUpdate; BOOL _didInitiallyPresent; - std::shared_ptr _eventDispatcher; - std::shared_ptr _eventListener; - NSInteger _presenterScreenTag; - __weak UIViewController *_presenterScreenController; - NSInteger _parentModalTag; + RNScreensEventObserver *_screensEventObserver; } #pragma mark - Initialization @@ -82,6 +78,9 @@ - (instancetype)initWithFrame:(CGRect)frame { _initialDetentAnimated = YES; _scrollable = NO; _isSheetUpdatePending = NO; + + _screensEventObserver = [[RNScreensEventObserver alloc] init]; + _screensEventObserver.delegate = self; } return self; } @@ -111,11 +110,8 @@ - (void)didMoveToWindow { } - (void)dealloc { - if (_eventDispatcher && _eventListener) { - _eventDispatcher->removeListener(_eventListener); - } - _eventListener = nullptr; - _eventDispatcher = nullptr; + [_screensEventObserver stopObserving]; + _screensEventObserver = nil; if (_controller && _controller.presentingViewController) { // Find the root presenting controller to dismiss the entire stack @@ -241,54 +237,7 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & [self updateStateWithSize:_controller.view.frame.size]; } - // Setup event listener for screen lifecycle events (onWillDisappear) - if (!_eventDispatcher) { - if (auto dispatcherPtr = _state.get()->getData().getEventDispatcher().lock()) { - _eventDispatcher = dispatcherPtr; - - __weak TrueSheetView *weakSelf = self; - - _eventListener = std::make_shared([weakSelf](const RawEvent &event) { - TrueSheetView *strongSelf = weakSelf; - if (!strongSelf) { - return false; - } - - if (event.type == "topWillDisappear") { - NSInteger presenterScreenTag = strongSelf->_presenterScreenTag; - if (!strongSelf->_controller.isPresented || - strongSelf->_controller.isBeingDismissed || - presenterScreenTag == 0) { - return false; - } - - if (auto family = event.shadowNodeFamily.lock()) { - Tag screenTag = family->getTag(); - - if (presenterScreenTag == screenTag) { - // If inside a modal, check if this is a nav pop vs modal dismiss - NSInteger parentModalTag = strongSelf->_parentModalTag; - if (parentModalTag != 0) { - UIViewController *screenController = strongSelf->_presenterScreenController; - UINavigationController *navController = screenController.navigationController; - - // If screen is still in nav stack, it's a modal dismiss - skip - // (the modal dismissal will handle the sheet) - if (navController && [navController.viewControllers containsObject:screenController]) { - return false; - } - } - - [strongSelf dismissAllAnimated:YES completion:nil]; - } - } - } - return false; - }); - - _eventDispatcher->addListener(_eventListener); - } - } + [_screensEventObserver startObservingWithState:_state.get()->getData()]; } /** @@ -443,28 +392,7 @@ - (void)presentAtIndex:(NSInteger)index [_controller setupSheetDetents]; [_controller setupActiveDetentWithIndex:index]; - // Capture presenter screen info for screen unmount detection - _presenterScreenTag = 0; - _presenterScreenController = nil; - _parentModalTag = 0; - UIView *view = self.superview; - while (view) { - NSString *className = NSStringFromClass([view class]); - if (_presenterScreenTag == 0 && [className isEqualToString:@"RNSScreenView"]) { - _presenterScreenTag = view.tag; - // Get the screen's controller via responder chain - for (UIResponder *responder = view; responder; responder = responder.nextResponder) { - if ([responder isKindOfClass:[UIViewController class]]) { - _presenterScreenController = (UIViewController *)responder; - break; - } - } - } else if ([className isEqualToString:@"RNSModalScreen"]) { - _parentModalTag = view.tag; - break; - } - view = view.superview; - } + [_screensEventObserver capturePresenterScreenFromView:self]; [presentingViewController presentViewController:_controller animated:animated @@ -642,7 +570,14 @@ - (void)viewControllerDidDetectScreenDisappear { [self dismissAllAnimated:YES completion:nil]; } -// See docs/SCREEN_UNMOUNT_DETECTION.md for research on detecting screen unmount +#pragma mark - RNScreensEventObserverDelegate + +- (void)presenterScreenWillDisappear { + if (!_controller.isPresented || _controller.isBeingDismissed) { + return; + } + [self dismissAllAnimated:YES completion:nil]; +} #pragma mark - Private Helpers diff --git a/ios/core/RNScreensEventObserver.h b/ios/core/RNScreensEventObserver.h new file mode 100644 index 00000000..132495b0 --- /dev/null +++ b/ios/core/RNScreensEventObserver.h @@ -0,0 +1,40 @@ +// +// Created by Jovanni Lo (@lodev09) +// Copyright (c) 2024-present. All rights reserved. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// + +#ifdef RCT_NEW_ARCH_ENABLED + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol RNScreensEventObserverDelegate + +- (void)presenterScreenWillDisappear; + +@end + +/** + * Observes react-native-screens lifecycle events via C++ EventDispatcher. + * Detects when the presenting screen unmounts while sheet is presented. + */ +@interface RNScreensEventObserver : NSObject + +@property (nonatomic, weak) id delegate; + +- (void)startObservingWithState:(const facebook::react::TrueSheetViewState &)state; +- (void)stopObserving; + +- (void)capturePresenterScreenFromView:(UIView *)view; +- (BOOL)shouldDismissForScreenTag:(NSInteger)screenTag; + +@end + +NS_ASSUME_NONNULL_END + +#endif // RCT_NEW_ARCH_ENABLED diff --git a/ios/core/RNScreensEventObserver.mm b/ios/core/RNScreensEventObserver.mm new file mode 100644 index 00000000..225abf90 --- /dev/null +++ b/ios/core/RNScreensEventObserver.mm @@ -0,0 +1,129 @@ +// +// Created by Jovanni Lo (@lodev09) +// Copyright (c) 2024-present. All rights reserved. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// + +#ifdef RCT_NEW_ARCH_ENABLED + +#import "RNScreensEventObserver.h" + +#import +#import + +using namespace facebook::react; + +@implementation RNScreensEventObserver { + std::shared_ptr _eventDispatcher; + std::shared_ptr _eventListener; + NSInteger _presenterScreenTag; + __weak UIViewController *_presenterScreenController; + NSInteger _parentModalTag; +} + +- (instancetype)init { + if (self = [super init]) { + _presenterScreenTag = 0; + _presenterScreenController = nil; + _parentModalTag = 0; + } + return self; +} + +- (void)dealloc { + [self stopObserving]; +} + +- (void)startObservingWithState:(const TrueSheetViewState &)state { + if (_eventDispatcher) { + return; + } + + if (auto dispatcherPtr = state.getEventDispatcher().lock()) { + _eventDispatcher = dispatcherPtr; + + __weak RNScreensEventObserver *weakSelf = self; + + _eventListener = std::make_shared([weakSelf](const RawEvent &event) { + RNScreensEventObserver *strongSelf = weakSelf; + if (!strongSelf) { + return false; + } + + if (event.type == "topWillDisappear") { + if (auto family = event.shadowNodeFamily.lock()) { + Tag screenTag = family->getTag(); + + if ([strongSelf shouldDismissForScreenTag:screenTag]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [strongSelf.delegate presenterScreenWillDisappear]; + }); + } + } + } + return false; + }); + + _eventDispatcher->addListener(_eventListener); + } +} + +- (void)stopObserving { + if (_eventDispatcher && _eventListener) { + _eventDispatcher->removeListener(_eventListener); + } + _eventListener = nullptr; + _eventDispatcher = nullptr; +} + +- (void)capturePresenterScreenFromView:(UIView *)view { + _presenterScreenTag = 0; + _presenterScreenController = nil; + _parentModalTag = 0; + + UIView *current = view.superview; + while (current) { + NSString *className = NSStringFromClass([current class]); + + if (_presenterScreenTag == 0 && [className isEqualToString:@"RNSScreenView"]) { + _presenterScreenTag = current.tag; + // Get the screen's controller via responder chain + for (UIResponder *responder = current; responder; responder = responder.nextResponder) { + if ([responder isKindOfClass:[UIViewController class]]) { + _presenterScreenController = (UIViewController *)responder; + break; + } + } + } else if ([className isEqualToString:@"RNSModalScreen"]) { + _parentModalTag = current.tag; + break; + } + current = current.superview; + } +} + +- (BOOL)shouldDismissForScreenTag:(NSInteger)screenTag { + if (_presenterScreenTag == 0 || _presenterScreenTag != screenTag) { + return NO; + } + + // If inside a modal, check if this is a nav pop vs modal dismiss + if (_parentModalTag != 0) { + UIViewController *screenController = _presenterScreenController; + UINavigationController *navController = screenController.navigationController; + + // If screen is still in nav stack, it's a modal dismiss - skip + // (the modal dismissal will handle the sheet) + if (navController && [navController.viewControllers containsObject:screenController]) { + return NO; + } + } + + return YES; +} + +@end + +#endif // RCT_NEW_ARCH_ENABLED From bd6b1a43150bc09df7e5e2f39cc16a8b34b1635b Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 03:16:16 +0800 Subject: [PATCH 3/8] refactor(ios): remove RNSLifecycleListenerProtocol dependency --- ...native-screens-npm-4.18.0-fa7de65975.patch | 77 ------- docs/SCREEN_UNMOUNT_DETECTION.md | 201 ------------------ ios/TrueSheetView.mm | 4 - ios/TrueSheetViewController.h | 14 -- ios/TrueSheetViewController.mm | 10 - yarn.lock | 4 +- 6 files changed, 2 insertions(+), 308 deletions(-) delete mode 100644 docs/SCREEN_UNMOUNT_DETECTION.md diff --git a/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch b/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch index e6f99e6f..64ea2389 100644 --- a/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch +++ b/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch @@ -1,60 +1,3 @@ -diff --git a/ios/RNSScreen.mm b/ios/RNSScreen.mm -index 65c18f1ddccc64b3169e050e577eab77fd8c183b..8ffd9d4b48f85e53c6fcf0a76963cfcf946dad97 100644 ---- a/ios/RNSScreen.mm -+++ b/ios/RNSScreen.mm -@@ -38,6 +38,7 @@ - #import "RNSTabBarController.h" - - #import "RNSDefines.h" -+#import "integrations/RNSLifecycleListenerProtocol.h" - #import "UIView+RNSUtility.h" - - #ifdef RCT_NEW_ARCH_ENABLED -@@ -74,6 +75,7 @@ struct ContentWrapperBox { - ContentWrapperBox _contentWrapperBox; - bool _sheetHasInitialDetentSet; - BOOL _shouldUpdateScrollEdgeEffects; -+ RNSScreen *_controllerBeforeInvalidate; - #ifdef RCT_NEW_ARCH_ENABLED - RCTSurfaceTouchHandler *_touchHandler; - react::RNSScreenShadowNode::ConcreteState::Shared _state; -@@ -608,6 +610,26 @@ RNS_IGNORE_SUPER_CALL_END - if (_hideKeyboardOnSwipe) { - [self endEditing:YES]; - } -+ -+ // Notify any presented view controllers that conform to RNSLifecycleListenerProtocol -+ RNSScreen *controller = _controller ?: _controllerBeforeInvalidate; -+ if (controller) { -+ UIViewController *presented = controller.presentedViewController; -+ while (presented) { -+ UIViewController *next = presented.presentedViewController; -+ if ([presented conformsToProtocol:@protocol(RNSLifecycleListenerProtocol)]) { -+ BOOL isPresenterUnmounting = NO; -+ RNSScreen *presenter = (RNSScreen *)presented.presentingViewController; -+ if ([presenter isKindOfClass:[RNSScreen class]]) { -+ isPresenterUnmounting = presenter.screenView.isMarkedForUnmountInCurrentTransaction; -+ } -+ [(id)presented screenWillDisappear:controller -+ isPresenterUnmounting:isPresenterUnmounting]; -+ } -+ presented = next; -+ } -+ } -+ - #ifdef RCT_NEW_ARCH_ENABLED - // If screen is already unmounted then there will be no event emitter - if (_eventEmitter != nullptr) { -@@ -927,6 +949,9 @@ RNS_IGNORE_SUPER_CALL_END - - - (void)invalidate - { -+ if (_controller && !_controllerBeforeInvalidate) { -+ _controllerBeforeInvalidate = _controller; -+ } - _controller = nil; - [_sheetsScrollView removeObserver:self forKeyPath:@"bounds" context:nil]; - } diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm index 51f021831aed26a4eed3c85014020423b7b3108b..268fa69dfee2b20d8b5a66c77c1b4cbd8c831573 100644 --- a/ios/RNSScreenStack.mm @@ -130,23 +73,3 @@ index 006f809d104c1d4fbdf6eccca89d6c6e190cca71..89e297f1b7a9582fee3e19237dfba8d4 @end NS_ASSUME_NONNULL_END -diff --git a/ios/integrations/RNSLifecycleListenerProtocol.h b/ios/integrations/RNSLifecycleListenerProtocol.h -new file mode 100644 -index 0000000000000000000000000000000000000000..025b4231c0b45f9f10034280037617b9b6d6fec4 ---- /dev/null -+++ b/ios/integrations/RNSLifecycleListenerProtocol.h -@@ -0,0 +1,14 @@ -+#import -+ -+NS_ASSUME_NONNULL_BEGIN -+ -+@protocol RNSLifecycleListenerProtocol -+ -+// Called when a screen in the presenting hierarchy is about to disappear. -+// @param screen The screen controller that is disappearing -+// @param isPresenterUnmounting YES if the presenter (modal) itself is being unmounted -+- (void)screenWillDisappear:(UIViewController *)screen isPresenterUnmounting:(BOOL)isPresenterUnmounting; -+ -+@end -+ -+NS_ASSUME_NONNULL_END diff --git a/docs/SCREEN_UNMOUNT_DETECTION.md b/docs/SCREEN_UNMOUNT_DETECTION.md deleted file mode 100644 index 2cb92e4a..00000000 --- a/docs/SCREEN_UNMOUNT_DETECTION.md +++ /dev/null @@ -1,201 +0,0 @@ -# Screen Unmount Detection Research - -Exploring alternatives to `RNSLifecycleListenerProtocol` for detecting when a presenting screen unmounts while a sheet is presented. - -**Context**: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 - -## Approach 1: `RCTMountingTransactionObserving` - -Conform `TrueSheetView` to `RCTMountingTransactionObserving` and implement mounting transaction callbacks. - -### 1a. `mountingTransactionWillMount` - -Called right **before** first mutation executes. - -```objc -- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction &)transaction - withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry { - for (const auto &mutation : transaction.getMutations()) { - if (mutation.type == ShadowViewMutation::Delete && mutation.oldChildShadowView.tag == self.tag) { - [self dismissAllAnimated:NO completion:nil]; - return; - } - } -} -``` - -| Scenario | Result | -|----------|--------| -| `navigation.goBack()` | Split second delay before dismiss | -| Native back gesture | Too late - controller already nil? | - -### 1b. `mountingTransactionDidMount` - -Called right **after** last mutation executes. - -**Result**: Not working. Per React Native docs, `DidMount` is NOT called for views being unmounted (already unregistered as observer). - -### Notes - -- These approaches detect when `TrueSheetView` itself is deleted -- Does NOT detect when the **presenting screen** is deleted (parent unmount) -- The timing seems off for native back gestures -- Neither `WillMount` nor `DidMount` provide reliable timing - ---- - -## Approach 2: Event Dispatcher (`RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED`) - -Observe events via `NSNotificationCenter` using `RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED`. - -### Implementation - -```objc -- (void)startObservingEventDispatcher { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleEventDispatcherNotification:) - name:@"RCTNotifyEventDispatcherObserversOfEvent_DEPRECATED" - object:nil]; -} - -- (void)handleEventDispatcherNotification:(NSNotification *)notification { - id event = notification.userInfo[@"event"]; - NSString *eventName = [event eventName]; - // Look for onWillDisappear... -} -``` - -### Findings - -**Result**: Not working. - -Only `onTransitionProgress` and `onHeaderHeightChange` are posted via this notification. The `onWillDisappear` event is emitted directly through the Fabric event emitter: - -```objc -// react-native-screens uses direct emitter, NOT notification -std::dynamic_pointer_cast(_eventEmitter) - ->onWillDisappear(...); -``` - ---- - -## Approach 3: C++ EventDispatcher via State (ComponentDescriptor) - -Pass the C++ `EventDispatcher` through state to the native view and add an `EventListener` to intercept all events. - -### How it works - -1. **ComponentDescriptor** has access to `eventDispatcher_` (member of `ConcreteComponentDescriptor`) -2. In `adopt()`, pass eventDispatcher to ShadowNode similar to how imageLoader is passed: - ```cpp - // TrueSheetViewComponentDescriptor.h - void adopt(ShadowNode &shadowNode) const override { - // ... existing code ... - concreteShadowNode.setEventDispatcher(eventDispatcher_); - ConcreteComponentDescriptor::adopt(shadowNode); - } - ``` -3. Store in State class: - ```cpp - // TrueSheetViewState.h - void setEventDispatcher(std::weak_ptr dispatcher); - std::weak_ptr getEventDispatcher() const noexcept; - ``` -4. In native view's `updateState:oldState:`, retrieve and use it: - ```objc - - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { - _state = std::static_pointer_cast<...>(state); - if (auto dispatcherPtr = _state.get()->getData().getEventDispatcher().lock()) { - // Add event listener to observe onWillDisappear from any RNSScreen - dispatcherPtr->addListener(std::make_shared( - [](const RawEvent& event) { - if (event.type == "onWillDisappear") { - // Dismiss sheet if it's our presenter - } - return false; // Don't intercept, pass through - } - )); - } - } - ``` - -### RawEvent structure - -```cpp -struct RawEvent { - std::string type; // e.g., "onWillDisappear" - SharedEventPayload eventPayload; - SharedEventTarget eventTarget; - std::weak_ptr shadowNodeFamily; // Source component - Category category; -}; -``` - -### Reference: imageLoader pattern in react-native-screens - -```cpp -// RNSScreenStackHeaderConfigComponentDescriptor.h -void adopt(ShadowNode &shadowNode) const override { - // ... - std::weak_ptr imageLoader = - contextContainer_->at>("RCTImageLoader"); - configShadowNode.setImageLoader(imageLoader); -} - -// RNSScreenStackHeaderConfig.mm -- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { - _state = std::static_pointer_cast<...>(state); - if (auto imgLoaderPtr = _state.get()->getData().getImageLoader().lock()) { - _imageLoader = react::unwrapManagedObject(imgLoaderPtr); - } -} -``` - -### Implementation Status - -**IMPLEMENTED** - This is the current solution. - -### How It Works - -1. **At presentation time**, capture: - - `_presenterScreenTag` - Tag of the RNSScreenView containing the sheet - - `_presenterScreenController` - The screen's view controller (via responder chain) - - `_parentModalTag` - Tag of parent RNSModalScreen (if inside a modal) - -2. **Listen for `topWillDisappear` events** via the EventDispatcher: - - Compare event's screen tag with `_presenterScreenTag` - - If they match, the presenter screen is being removed - -3. **Distinguish navigation pop from modal dismiss**: - - If inside a modal (`_parentModalTag != 0`), check if screen is still in nav stack - - If still in nav stack → modal is being dismissed → skip (modal handles sheet) - - If not in nav stack → navigation pop → dismiss sheet - -### Files Changed - -- `common/cpp/.../TrueSheetViewState.h` - Added `eventDispatcher_` member and getter/setter -- `common/cpp/.../TrueSheetViewState.cpp` - Implemented getter/setter -- `common/cpp/.../TrueSheetViewShadowNode.h` - Added `setEventDispatcher()` and `getStateDataMutable()` -- `common/cpp/.../TrueSheetViewShadowNode.cpp` - Implemented methods -- `common/cpp/.../TrueSheetViewComponentDescriptor.h` - Pass `eventDispatcher_` in `adopt()` -- `ios/TrueSheetView.mm` - Event listener setup and screen unmount detection logic - ---- - -## Previous Solution: `RNSLifecycleListenerProtocol` - -The protocol from react-native-screens notifies presented view controllers when the presenter is unmounting: - -```objc -- (void)screenWillDisappear:(UIViewController *)screen isPresenterUnmounting:(BOOL)isPresenterUnmounting; -``` - -### Why it worked - -- Called by `RNSScreen` when it's about to disappear -- Notifies any presented view controller that conforms to the protocol -- Has access to the screen's controller before it becomes nil - -### Why we moved away - -react-native-screens maintainers want to avoid component-specific integrations given planned deprecation of current implementation. See: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index 02cf4071..2c0df88e 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -566,10 +566,6 @@ - (void)viewControllerDidBlur { [TrueSheetFocusEvents emitDidBlur:_eventEmitter]; } -- (void)viewControllerDidDetectScreenDisappear { - [self dismissAllAnimated:YES completion:nil]; -} - #pragma mark - RNScreensEventObserverDelegate - (void)presenterScreenWillDisappear { diff --git a/ios/TrueSheetViewController.h b/ios/TrueSheetViewController.h index 77894bf0..762fe50d 100644 --- a/ios/TrueSheetViewController.h +++ b/ios/TrueSheetViewController.h @@ -16,12 +16,6 @@ #define RNS_DISMISSIBLE_MODAL_PROTOCOL_AVAILABLE 0 #endif -#if __has_include() -#import -#define RNS_LIFECYCLE_LISTENER_PROTOCOL_AVAILABLE 1 -#else -#define RNS_LIFECYCLE_LISTENER_PROTOCOL_AVAILABLE 0 -#endif NS_ASSUME_NONNULL_BEGIN @@ -46,10 +40,6 @@ NS_ASSUME_NONNULL_BEGIN - (void)viewControllerWillBlur; - (void)viewControllerDidBlur; -#if RNS_LIFECYCLE_LISTENER_PROTOCOL_AVAILABLE -- (void)viewControllerDidDetectScreenDisappear; -#endif - @end @interface TrueSheetViewController : UIViewController diff --git a/ios/TrueSheetViewController.mm b/ios/TrueSheetViewController.mm index e4dbbfcb..5d6baf33 100644 --- a/ios/TrueSheetViewController.mm +++ b/ios/TrueSheetViewController.mm @@ -799,16 +799,6 @@ - (void)sheetPresentationControllerDidChangeSelectedDetentIdentifier: } } -#pragma mark - RNSLifecycleListenerProtocol - -#if RNS_LIFECYCLE_LISTENER_PROTOCOL_AVAILABLE - -- (void)screenWillDisappear:(UIViewController *)screen isPresenterUnmounting:(BOOL)isPresenterUnmounting { - // TODO: Disabled - exploring alternative approaches per react-native-screens feedback - // See: https://github.com/software-mansion/react-native-screens/pull/3527#pullrequestreview-3650866794 -} -#endif - #pragma mark - RNSDismissibleModalProtocol #if RNS_DISMISSIBLE_MODAL_PROTOCOL_AVAILABLE diff --git a/yarn.lock b/yarn.lock index 8b2595a8..3bf21dbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19528,14 +19528,14 @@ __metadata: "react-native-screens@patch:react-native-screens@npm%3A4.18.0#~/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch": version: 4.18.0 - resolution: "react-native-screens@patch:react-native-screens@npm%3A4.18.0#~/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch::version=4.18.0&hash=e3e46b" + resolution: "react-native-screens@patch:react-native-screens@npm%3A4.18.0#~/.yarn/patches/react-native-screens-npm-4.18.0-fa7de65975.patch::version=4.18.0&hash=86df34" dependencies: react-freeze: "npm:^1.0.0" warn-once: "npm:^0.1.0" peerDependencies: react: "*" react-native: "*" - checksum: 10c0/3a26215f34bfa94dd015180758b0a759fc97a542d8978a27aef6ed3f8c8ab26675e1c8da59ce74777d28d1623261b29d9bc3c472aae43a4a4484a89b174be839 + checksum: 10c0/9947fd60699d3c1de2c278279fb4504acf912ffb638bafdedd88063b4483a7bae225548f4b43eaffb27e019e7c88ed649b40edab6375d76010a1deb98554dbbd languageName: node linkType: hard From 4c4a4b7915bf7881b31175169d281d1eadd118a9 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 04:45:58 +0800 Subject: [PATCH 4/8] fix(ios): dismiss sheet when pushing screen in modal nav stack --- ios/TrueSheetView.mm | 5 ++--- ios/core/RNScreensEventObserver.h | 4 +++- ios/core/RNScreensEventObserver.mm | 36 ++++++++++++------------------ 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/ios/TrueSheetView.mm b/ios/TrueSheetView.mm index 2c0df88e..0bab51e3 100644 --- a/ios/TrueSheetView.mm +++ b/ios/TrueSheetView.mm @@ -569,10 +569,9 @@ - (void)viewControllerDidBlur { #pragma mark - RNScreensEventObserverDelegate - (void)presenterScreenWillDisappear { - if (!_controller.isPresented || _controller.isBeingDismissed) { - return; + if (_controller.isPresented && !_controller.isBeingDismissed) { + [self dismissAllAnimated:YES completion:nil]; } - [self dismissAllAnimated:YES completion:nil]; } #pragma mark - Private Helpers diff --git a/ios/core/RNScreensEventObserver.h b/ios/core/RNScreensEventObserver.h index 132495b0..cd9ac104 100644 --- a/ios/core/RNScreensEventObserver.h +++ b/ios/core/RNScreensEventObserver.h @@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN +@class TrueSheetView; + @protocol RNScreensEventObserverDelegate - (void)presenterScreenWillDisappear; @@ -25,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN */ @interface RNScreensEventObserver : NSObject -@property (nonatomic, weak) id delegate; +@property (nonatomic, weak) TrueSheetView *delegate; - (void)startObservingWithState:(const facebook::react::TrueSheetViewState &)state; - (void)stopObserving; diff --git a/ios/core/RNScreensEventObserver.mm b/ios/core/RNScreensEventObserver.mm index 225abf90..39041720 100644 --- a/ios/core/RNScreensEventObserver.mm +++ b/ios/core/RNScreensEventObserver.mm @@ -9,6 +9,7 @@ #ifdef RCT_NEW_ARCH_ENABLED #import "RNScreensEventObserver.h" +#import "TrueSheetView.h" #import #import @@ -57,9 +58,7 @@ - (void)startObservingWithState:(const TrueSheetViewState &)state { Tag screenTag = family->getTag(); if ([strongSelf shouldDismissForScreenTag:screenTag]) { - dispatch_async(dispatch_get_main_queue(), ^{ - [strongSelf.delegate presenterScreenWillDisappear]; - }); + [strongSelf.delegate presenterScreenWillDisappear]; } } } @@ -71,11 +70,11 @@ - (void)startObservingWithState:(const TrueSheetViewState &)state { } - (void)stopObserving { - if (_eventDispatcher && _eventListener) { + if (_eventListener) { _eventDispatcher->removeListener(_eventListener); + _eventListener = nullptr; + _eventDispatcher = nullptr; } - _eventListener = nullptr; - _eventDispatcher = nullptr; } - (void)capturePresenterScreenFromView:(UIView *)view { @@ -83,16 +82,14 @@ - (void)capturePresenterScreenFromView:(UIView *)view { _presenterScreenController = nil; _parentModalTag = 0; - UIView *current = view.superview; - while (current) { + for (UIView *current = view.superview; current; current = current.superview) { NSString *className = NSStringFromClass([current class]); - if (_presenterScreenTag == 0 && [className isEqualToString:@"RNSScreenView"]) { + if (!_presenterScreenTag && [className isEqualToString:@"RNSScreenView"]) { _presenterScreenTag = current.tag; - // Get the screen's controller via responder chain - for (UIResponder *responder = current; responder; responder = responder.nextResponder) { - if ([responder isKindOfClass:[UIViewController class]]) { - _presenterScreenController = (UIViewController *)responder; + for (UIResponder *r = current.nextResponder; r; r = r.nextResponder) { + if ([r isKindOfClass:[UIViewController class]]) { + _presenterScreenController = (UIViewController *)r; break; } } @@ -100,23 +97,18 @@ - (void)capturePresenterScreenFromView:(UIView *)view { _parentModalTag = current.tag; break; } - current = current.superview; } } - (BOOL)shouldDismissForScreenTag:(NSInteger)screenTag { - if (_presenterScreenTag == 0 || _presenterScreenTag != screenTag) { + if (_presenterScreenTag != screenTag) { return NO; } - // If inside a modal, check if this is a nav pop vs modal dismiss + // If inside a modal, skip if screen is top of nav stack (modal dismiss) if (_parentModalTag != 0) { - UIViewController *screenController = _presenterScreenController; - UINavigationController *navController = screenController.navigationController; - - // If screen is still in nav stack, it's a modal dismiss - skip - // (the modal dismissal will handle the sheet) - if (navController && [navController.viewControllers containsObject:screenController]) { + UINavigationController *nav = _presenterScreenController.navigationController; + if (nav.topViewController == _presenterScreenController) { return NO; } } From e71e8357630a530addb6ab4362e9b2b39ab6d454 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 05:14:12 +0800 Subject: [PATCH 5/8] refactor(ios): simplify RNScreensEventObserver modal detection --- ios/core/RNScreensEventObserver.mm | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/ios/core/RNScreensEventObserver.mm b/ios/core/RNScreensEventObserver.mm index 39041720..64da0561 100644 --- a/ios/core/RNScreensEventObserver.mm +++ b/ios/core/RNScreensEventObserver.mm @@ -21,14 +21,12 @@ @implementation RNScreensEventObserver { std::shared_ptr _eventListener; NSInteger _presenterScreenTag; __weak UIViewController *_presenterScreenController; - NSInteger _parentModalTag; } - (instancetype)init { if (self = [super init]) { _presenterScreenTag = 0; _presenterScreenController = nil; - _parentModalTag = 0; } return self; } @@ -80,12 +78,11 @@ - (void)stopObserving { - (void)capturePresenterScreenFromView:(UIView *)view { _presenterScreenTag = 0; _presenterScreenController = nil; - _parentModalTag = 0; for (UIView *current = view.superview; current; current = current.superview) { NSString *className = NSStringFromClass([current class]); - if (!_presenterScreenTag && [className isEqualToString:@"RNSScreenView"]) { + if ([className isEqualToString:@"RNSScreenView"]) { _presenterScreenTag = current.tag; for (UIResponder *r = current.nextResponder; r; r = r.nextResponder) { if ([r isKindOfClass:[UIViewController class]]) { @@ -93,8 +90,6 @@ - (void)capturePresenterScreenFromView:(UIView *)view { break; } } - } else if ([className isEqualToString:@"RNSModalScreen"]) { - _parentModalTag = current.tag; break; } } @@ -105,15 +100,9 @@ - (BOOL)shouldDismissForScreenTag:(NSInteger)screenTag { return NO; } - // If inside a modal, skip if screen is top of nav stack (modal dismiss) - if (_parentModalTag != 0) { - UINavigationController *nav = _presenterScreenController.navigationController; - if (nav.topViewController == _presenterScreenController) { - return NO; - } - } - - return YES; + // Skip if screen is still top of nav stack (e.g. modal dismiss - sheet dismisses naturally with modal) + // Dismiss if a new screen was pushed or popped + return _presenterScreenController.navigationController.topViewController != _presenterScreenController; } @end From f943feca7dc20b1e3c7bb7b4e8cd8655a3a0a966 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Tue, 13 Jan 2026 05:25:53 +0800 Subject: [PATCH 6/8] chore(example): add test screen to root navigator --- example/bare/src/navigators/RootNavigator.tsx | 12 +++++- example/expo/app/_layout.tsx | 6 ++- example/expo/app/index.tsx | 1 + example/expo/app/test.tsx | 7 ++++ example/shared/src/screens/MapScreen.tsx | 38 +++++++++++++------ ios/TrueSheetView.mm | 5 ++- ios/TrueSheetViewController.h | 1 - ios/core/RNScreensEventObserver.h | 2 +- ios/core/RNScreensEventObserver.mm | 2 +- 9 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 example/expo/app/test.tsx diff --git a/example/bare/src/navigators/RootNavigator.tsx b/example/bare/src/navigators/RootNavigator.tsx index 0d35f781..02514e1b 100644 --- a/example/bare/src/navigators/RootNavigator.tsx +++ b/example/bare/src/navigators/RootNavigator.tsx @@ -1,6 +1,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { MapScreen, StandardScreen } from '@example/shared/screens'; +import { MapScreen, StandardScreen, TestScreen } from '@example/shared/screens'; +import { DARK_BLUE } from '@example/shared/utils'; import { Map } from '@example/shared/components'; import { ModalStackNavigator } from './ModalStackNavigator'; import { SheetNavigator } from './SheetNavigator'; @@ -18,6 +19,7 @@ const MapScreenWrapper = () => { MapComponent={Map} onNavigateToModal={() => navigation.navigate('ModalStack')} onNavigateToSheetStack={() => navigation.navigate('SheetStack')} + onNavigateToTest={() => navigation.navigate('Test')} /> ); }; @@ -33,10 +35,15 @@ const StandardScreenWrapper = () => { ); }; +const TestScreenWrapper = () => { + const navigation = useAppNavigation(); + return navigation.goBack()} />; +}; + export const RootNavigator = () => { return ( { component={ModalStackNavigator} options={{ presentation: 'fullScreenModal', headerShown: false }} /> + ); }; diff --git a/example/expo/app/_layout.tsx b/example/expo/app/_layout.tsx index fbe2025c..3f7115ce 100644 --- a/example/expo/app/_layout.tsx +++ b/example/expo/app/_layout.tsx @@ -6,6 +6,7 @@ import * as SplashScreen from 'expo-splash-screen'; import { useEffect } from 'react'; import { useColorScheme } from 'react-native'; import { TrueSheetProvider } from '@lodev09/react-native-true-sheet'; +import { DARK_BLUE } from '@example/shared/utils'; import 'react-native-reanimated'; export { @@ -50,9 +51,12 @@ function RootLayoutNav() { return ( - + + router.push('/modal')} onNavigateToSheetStack={() => router.push('/sheet')} + onNavigateToTest={() => router.push('/test')} /> ); } diff --git a/example/expo/app/test.tsx b/example/expo/app/test.tsx new file mode 100644 index 00000000..9aec4658 --- /dev/null +++ b/example/expo/app/test.tsx @@ -0,0 +1,7 @@ +import { TestScreen } from '@example/shared/screens'; +import { useRouter } from 'expo-router'; + +export default function Test() { + const router = useRouter(); + return router.back()} />; +} diff --git a/example/shared/src/screens/MapScreen.tsx b/example/shared/src/screens/MapScreen.tsx index bcc58ba9..7b3da77c 100644 --- a/example/shared/src/screens/MapScreen.tsx +++ b/example/shared/src/screens/MapScreen.tsx @@ -41,12 +41,14 @@ export interface MapScreenProps { MapComponent: ComponentType<{ style?: StyleProp }>; onNavigateToModal?: () => void; onNavigateToSheetStack?: () => void; + onNavigateToTest?: () => void; } const MapScreenInner = ({ MapComponent, onNavigateToModal, onNavigateToSheetStack, + onNavigateToTest, }: MapScreenProps) => { const { height } = useWindowDimensions(); const { animatedPosition } = useReanimatedTrueSheet(); @@ -149,23 +151,35 @@ const MapScreenInner = ({ The true native bottom sheet experience.