diff --git a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm index ecaa8bb71e..52bd33b83f 100644 --- a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm +++ b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryComponentView.mm @@ -49,9 +49,37 @@ - (void)initState - (void)didMoveToWindow { if (self.window != nil) { + // The helper & shadow proxy are torn down below whenever the accessory + // leaves the window (e.g. a full-screen push covering the tab bar), so + // they must be recreated on re-entry — previously + // `registerForAccessoryFrameChanges` was messaged at nil here and all + // trait/frame callbacks and content-view switching stayed permanently + // dead after the first full-screen push, freezing the accessory on + // whichever content copy was last visible. The content views' own + // `didMoveToWindow` runs after this one (window changes propagate + // top-down), so they re-register into the fresh helper. + if (@available(iOS 26, *)) { + if (_helper == nil) { + _helper = [[RNSTabsBottomAccessoryHelper alloc] initWithBottomAccessoryView:self]; + } + if (_shadowStateProxy == nil) { + _shadowStateProxy = [[RNSTabsBottomAccessoryShadowStateProxy alloc] initWithBottomAccessoryView:self]; + } + } [_helper registerForAccessoryFrameChanges]; } else { - [self invalidate]; + // Lighter teardown than `invalidate` — keep the Fabric `_state` alive + // across a detach/reattach cycle. Only React can re-deliver the state + // (via `updateState` on a commit), so resetting it on a mere window + // detach would leave the recreated shadow proxy unable to publish + // accessory frame updates until an unrelated commit happens to arrive. + // True unmount still fully invalidates via RNSTabsHostComponentView. + if (@available(iOS 26, *)) { + [_helper invalidate]; + _helper = nil; + [_shadowStateProxy invalidate]; + _shadowStateProxy = nil; + } } } diff --git a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm index 9095a8bd43..3dc7be6963 100644 --- a/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm +++ b/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm @@ -39,11 +39,6 @@ - (void)initState #pragma mark - Content view switching workaround -- (BOOL)isContentViewSwitchingWorkaroundActive -{ - return _regularContentView != nil && _inlineContentView != nil; -} - - (void)setContentView:(RNSTabsBottomAccessoryContentComponentView *)contentView forEnvironment:(RNSTabsBottomAccessoryEnvironment)environment { @@ -65,20 +60,29 @@ - (void)setContentView:(RNSTabsBottomAccessoryContentComponentView *)contentView - (void)handleContentViewVisibilityForEnvironmentIfNeeded { - if (!self.isContentViewSwitchingWorkaroundActive) { - return; - } - - switch (self->_bottomAccessoryView.traitCollection.tabAccessoryEnvironment) { - case UITabAccessoryEnvironmentInline: - _regularContentView.layer.opacity = 0.0; - _inlineContentView.layer.opacity = 1.0; - break; - default: - _regularContentView.layer.opacity = 1.0; - _inlineContentView.layer.opacity = 0.0; - break; - } + // `hidden` is used as the sole carrier of invisibility, with alpha + // normalized to 1.0 on both content views: + // - `RCTViewComponentView.invalidateLayer` unconditionally resets + // `layer.opacity` to the React props value on arbitrary commits, which + // used to re-show the off-environment copy. It never touches `hidden`, + // and with alpha kept at 1.0 the reset becomes a no-op by construction. + // - The off-environment copy is mounted absoluteFill ON TOP of the active + // one (later sibling); when it regained opacity it also captured all + // hit-testing, making the accessory unresponsive to taps. Hidden views + // are excluded from hit-testing. + // Applied to whichever content views are registered (messages to nil are + // no-ops) — the previous both-registered early-return left a lone + // registered view fully visible until its sibling registered. + BOOL isInline = + self->_bottomAccessoryView.traitCollection.tabAccessoryEnvironment == UITabAccessoryEnvironmentInline; + + UIView *viewToShow = isInline ? _inlineContentView : _regularContentView; + UIView *viewToHide = isInline ? _regularContentView : _inlineContentView; + + viewToShow.hidden = NO; + viewToShow.alpha = 1.0; + viewToHide.hidden = YES; + viewToHide.alpha = 1.0; } #pragma mark - Observing environment changes @@ -149,6 +153,15 @@ - (void)notifyWrapperViewFrameHasChanged // We use self.nativeWrapperView because it has both the size and the origin // that we want to send to the ShadowNode. [_bottomAccessoryView.shadowStateProxy updateShadowStateWithFrame:self.nativeWrapperView.frame]; + + // The wrapper frame changes on every regular <-> inline move, so re-evaluate + // content-view visibility here as well. The one-shot + // UITraitTabAccessoryEnvironment registration callback is occasionally + // missed during the minimize transition, which left the wrong copy visible + // inside the inline accessory (or vice versa); this KVO fires reliably on + // each transition (and once on registration via + // NSKeyValueObservingOptionInitial) and self-corrects that. + [self handleContentViewVisibilityForEnvironmentIfNeeded]; } #pragma mark - Invalidation