Skip to content

Commit 71f3a1c

Browse files
committed
fix(ios): render dim in presenter view when presenter VC is detached
When TrueSheet is presented from a UIViewController that is not reachable from the key window's root VC via the parent/presentingViewController chain, UIKit's built-in UISheetPresentationController dim layer lands in the wrong UIWindow — the sheet content renders correctly but the dim appears in a fallback container instead of behind the sheet. iOS surfaces this at runtime as: "Presenting view controller ... from detached view controller ... is not supported, and may result in incorrect safe area insets and a corrupt root presentation." Detachment is inherited up the chain: a VC can have a non-nil presentingViewController but still be detached if that presenter is itself detached. A single-level check is not sufficient. Two changes: 1. findPresentingViewController walks superview.nextResponder to locate the nearest ancestor VC. This correctly finds VCs parked in the view tree by custom navigators (which may use addSubview: rather than modal presentation), where the previous window.rootViewController presentedViewController chain walk would miss them. 2. Added isPresenterDetached: which walks parent/presenting chain until it reaches the window's root VC. When the presenter is detached AND dimmed is YES, we add a fallback dim UIView as a subview of the presenter's view so it lives in the correct window below the natively-presented sheet. The dim is removed (animated) in dismissAnimated:, viewControllerWillDismiss, prepareForRecycle, and dealloc so every dismissal path — including swipe-dismiss and Fabric view recycling — cleans up correctly. No behavior change for the attached-presenter path. Closes #661
1 parent 581129a commit 71f3a1c

2 files changed

Lines changed: 128 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### 🐛 Bug fixes
66

7+
- **iOS**: Fixed sheet dim rendering in the wrong `UIWindow` when the presenter view controller is detached from the window's VC hierarchy (e.g. inside a custom navigator or RN `<Modal>`). `findPresentingViewController` now walks the responder chain to find the real owning VC, and a fallback dim view is added to the presenter's view when the presenter is detached. ([#TBD](https://github.com/lodev09/react-native-true-sheet/pull/TBD) by [@kanzelm3](https://github.com/kanzelm3))
78
- **Android**: Fixed focused input in sheet causing auto-focus on main screen input after dismiss. ([#649](https://github.com/lodev09/react-native-true-sheet/pull/649) by [@lodev09](https://github.com/lodev09))
89

910
## 3.10.0

ios/TrueSheetView.mm

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ @implementation TrueSheetView {
6565
BOOL _pendingPropsUpdate;
6666
NSArray *_pendingDetents;
6767
RNScreensEventObserver *_screensEventObserver;
68+
// Fallback dim view used when the presenter VC is detached from the window's
69+
// VC hierarchy — UIKit's built-in sheet dim renders in the wrong UIWindow in
70+
// that case, so we render our own in the presenter's view instead.
71+
UIView *_detachedPresenterDimView;
6872
}
6973

7074
#pragma mark - Initialization
@@ -148,6 +152,9 @@ - (void)dealloc {
148152
[_snapshotView removeFromSuperview];
149153
_snapshotView = nil;
150154

155+
[_detachedPresenterDimView removeFromSuperview];
156+
_detachedPresenterDimView = nil;
157+
151158
[TrueSheetModule unregisterViewWithTag:@(self.tag)];
152159
}
153160

@@ -332,6 +339,9 @@ - (void)prepareForRecycle {
332339

333340
[TrueSheetModule unregisterViewWithTag:@(self.tag)];
334341

342+
[_detachedPresenterDimView removeFromSuperview];
343+
_detachedPresenterDimView = nil;
344+
335345
_lastStateSize = CGSizeZero;
336346
_didInitiallyPresent = NO;
337347
_dismissedByNavigation = NO;
@@ -449,6 +459,12 @@ - (void)presentAtIndex:(NSInteger)index
449459
[_screensEventObserver capturePresenterScreenFromView:self];
450460
[_screensEventObserver startObservingWithState:_state.get()->getData()];
451461

462+
// If the presenter VC is detached from the window's VC hierarchy, UIKit's
463+
// built-in sheet dim renders in the wrong UIWindow. Add our own dim as a
464+
// subview of the presenter's view so it lives in the correct window below
465+
// the natively-presented sheet.
466+
[self addDetachedPresenterDimIfNeededForPresenter:presentingViewController animated:animated];
467+
452468
[presentingViewController presentViewController:_controller
453469
animated:animated
454470
completion:^{
@@ -458,6 +474,90 @@ - (void)presentAtIndex:(NSInteger)index
458474
}];
459475
}
460476

477+
- (BOOL)isPresenterDetached:(UIViewController *)presenter {
478+
// A VC is attached if we can walk up its parent/presenting chain and reach
479+
// the window's root VC. Detachment is *inherited* — a VC can have a non-nil
480+
// `presentingViewController` but still be detached if that presenter is
481+
// itself detached. UIKit applies the same rule and emits "Presenting view
482+
// controller ... from detached view controller ..." in that case.
483+
if (!presenter) {
484+
return NO;
485+
}
486+
UIWindow *window = presenter.viewIfLoaded.window ?: self.window;
487+
UIViewController *rootVC = window.rootViewController;
488+
NSMutableSet *visited = [NSMutableSet set];
489+
UIViewController *current = presenter;
490+
while (current) {
491+
if (current == rootVC) {
492+
return NO; // reached the root — attached
493+
}
494+
if ([visited containsObject:current]) {
495+
return YES; // cycle guard — treat as detached
496+
}
497+
[visited addObject:current];
498+
// Walk up: prefer containment parent, fall back to modal presenter.
499+
UIViewController *next = current.parentViewController;
500+
if (!next) {
501+
next = current.presentingViewController;
502+
}
503+
current = next;
504+
}
505+
return YES; // chain ends before root — detached
506+
}
507+
508+
- (void)addDetachedPresenterDimIfNeededForPresenter:(UIViewController *)presenter animated:(BOOL)animated {
509+
if (!_controller.dimmed) {
510+
return;
511+
}
512+
if (![self isPresenterDetached:presenter]) {
513+
return;
514+
}
515+
if (!presenter.isViewLoaded) {
516+
return;
517+
}
518+
519+
UIView *dim = [[UIView alloc] initWithFrame:presenter.view.bounds];
520+
dim.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
521+
dim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
522+
dim.alpha = 0.0;
523+
dim.userInteractionEnabled = NO;
524+
[presenter.view addSubview:dim];
525+
526+
_detachedPresenterDimView = dim;
527+
528+
CGFloat duration = animated ? 0.35 : 0.0;
529+
[UIView animateWithDuration:duration
530+
delay:0.0
531+
options:UIViewAnimationOptionCurveEaseInOut
532+
animations:^{
533+
dim.alpha = 0.4;
534+
}
535+
completion:nil];
536+
}
537+
538+
- (void)removeDetachedPresenterDimAnimated:(BOOL)animated {
539+
UIView *dim = _detachedPresenterDimView;
540+
if (!dim) {
541+
return;
542+
}
543+
_detachedPresenterDimView = nil;
544+
545+
if (!animated) {
546+
[dim removeFromSuperview];
547+
return;
548+
}
549+
550+
[UIView animateWithDuration:0.35
551+
delay:0.0
552+
options:UIViewAnimationOptionCurveEaseInOut
553+
animations:^{
554+
dim.alpha = 0.0;
555+
}
556+
completion:^(BOOL finished) {
557+
[dim removeFromSuperview];
558+
}];
559+
}
560+
461561
- (void)resizeToIndex:(NSInteger)index completion:(nullable TrueSheetCompletionBlock)completion {
462562
if (!_controller.isPresented) {
463563
RCTLogWarn(@"TrueSheet: Cannot resize. Sheet is not presented.");
@@ -498,6 +598,10 @@ - (void)dismissAnimated:(BOOL)animated completion:(nullable TrueSheetCompletionB
498598
return;
499599
}
500600

601+
// Fade out the detached-presenter dim (if any) in lockstep with the sheet
602+
// animation so it's gone by the time the sheet finishes dismissing.
603+
[self removeDetachedPresenterDimAnimated:animated];
604+
501605
// Dismiss from the presenting view controller to dismiss this sheet and all its children
502606
UIViewController *presenter = _controller.presentingViewController;
503607
[presenter dismissViewControllerAnimated:animated
@@ -618,6 +722,11 @@ - (void)viewControllerDidDrag:(UIGestureRecognizerState)state
618722
}
619723

620724
- (void)viewControllerWillDismiss {
725+
// Also fade out the detached-presenter dim for swipe-dismiss and other paths
726+
// that don't go through dismissAnimated: — willDismiss fires once the
727+
// dismissal transition begins regardless of what triggered it.
728+
[self removeDetachedPresenterDimAnimated:YES];
729+
621730
if (!_dismissedByNavigation) {
622731
[TrueSheetLifecycleEvents emitWillDismiss:_eventEmitter];
623732
}
@@ -734,7 +843,24 @@ - (UIViewController *)findPresentingViewController {
734843
if (!self.window)
735844
return nil;
736845

737-
UIViewController *rootViewController = self.window.rootViewController;
846+
// Walk the view hierarchy to find the nearest ancestor view controller.
847+
// Some navigators (e.g. React Navigation's custom navigators, RN's `<Modal>`
848+
// nested inside one) attach their VCs to the tree via `addSubview:` rather
849+
// than modal presentation, so `window.rootViewController.presentedViewController`
850+
// never reaches them. Walking `superview.nextResponder` finds the real
851+
// owning VC of the view we're in.
852+
UIView *ancestor = self.superview;
853+
UIViewController *nearestVC = nil;
854+
while (ancestor) {
855+
UIResponder *responder = ancestor.nextResponder;
856+
if ([responder isKindOfClass:[UIViewController class]]) {
857+
nearestVC = (UIViewController *)responder;
858+
break;
859+
}
860+
ancestor = ancestor.superview;
861+
}
862+
863+
UIViewController *rootViewController = nearestVC ?: self.window.rootViewController;
738864
if (!rootViewController)
739865
return nil;
740866

0 commit comments

Comments
 (0)