Skip to content

fix(iOS, TabsBottomAccessory): use hidden instead of opacity to switch bottom accessory content views#4196

Open
sgaczol wants to merge 6 commits into
mainfrom
@sgaczol/tabsbottomaccessory-hidden
Open

fix(iOS, TabsBottomAccessory): use hidden instead of opacity to switch bottom accessory content views#4196
sgaczol wants to merge 6 commits into
mainfrom
@sgaczol/tabsbottomaccessory-hidden

Conversation

@sgaczol

@sgaczol sgaczol commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR changes the way the RNSTabsBottomAccessory content-view switching workaround hides/shows its two content views: instead of mutating layer.opacity, it now toggles view.hidden.

Such approach fixes a bug where, during the bottom accessory's appearance animation, both content views (regular & inline) showed through each other instead of just the one matching the current environment. We don't fully understand why opacity failed here (the values were already set before the animation started, yet both views still bled through) - most likely hidden simply doesn't participate in the animation at all, which is what fixes it.

Switching to hidden introduces a new wrinkle: view.hidden is also written by React Native itself. Fabric's mounting layer sets self.hidden = layoutMetrics.displayType == DisplayType::None in -[UIView(ComponentViewProtocol) updateLayoutMetrics:oldLayoutMetrics:], which runs on every display change from JS and on the first layout after a (re)mount (forceUpdate). So RN can clobber the visibility the workaround just set, without going through our code. To stay in control, the helper now observes hidden via KVO on both content views and re-applies the correct visibility whenever something external toggles it.

Changes

  • RNSTabsBottomAccessoryHelper: in handleContentViewVisibilityForEnvironmentIfNeeded, swap layer.opacity assignments for hidden (invisible → hidden = YES, visible → hidden = NO). Also observe hidden via KVO on both content views and re-apply the correct visibility when something external toggles it (with a reentrancy guard for our own writes). The observed views are held strongly so the observer is always removed from a live instance.
  • RNSTabsBottomAccessoryContentComponentView: remove the traitCollectionDidChange: and finalizeUpdates: overrides that only existed to re-assert opacity after invalidateLayer reset it. These are redundant once visibility no longer relies on layer.opacity.

Before & after - visual documentation

Before

before.mov
bar.before.mov

After

after.mov
bar.after.mov

Test plan

Run the 3288 issue test. Toggle shown from true to false and back to true. Confirm the two content views (regular & inline) don't overlap / show through each other.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an iOS TabsBottomAccessory rendering glitch by changing the content-view switching workaround to toggle UIView.hidden instead of mutating layer.opacity, avoiding the two content views bleeding through each other during appearance animations.

Changes:

  • Switches the visibility mechanism for the regular/inline content views from layer.opacity to hidden.
  • Removes traitCollectionDidChange: and finalizeUpdates: overrides that only existed to re-assert opacity after layer invalidation.
  • Updates associated inline documentation to match the new behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm Uses hidden toggling to show exactly one content view for the active tab accessory environment.
ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.h Updates docs to describe hidden-based switching (and includes a small typo fix suggestion).
ios/tabs/bottom-accessory/RNSTabsBottomAccessoryContentComponentView.mm Removes now-unnecessary overrides that were compensating for opacity resets during layer invalidation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.h Outdated
sgaczol and others added 2 commits June 19, 2026 14:25
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +180 to 184
} else if (context == RNSTabsBottomAccessoryContentViewHiddenContext) {
if (!_isAdjustingContentViewVisibility) {
[self handleContentViewVisibilityForEnvironmentIfNeeded];
}
} else {
Comment thread ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm Outdated
@sgaczol sgaczol requested a review from kligarski June 26, 2026 12:50
Comment thread ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm
@kligarski

kligarski commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

CI failed on bottom accessory tests - we should have a look but maybe we just need to adjust the e2e after the changes

@sgaczol sgaczol requested a review from LKuchno June 30, 2026 11:37
@sgaczol

sgaczol commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI failed on bottom accessory tests - we should have a look but maybe we just need to adjust the e2e after the changes

We probably need some changes toBeVisible -> notToBeVisible

@LKuchno

LKuchno commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

CI failed on bottom accessory tests - we should have a look but maybe we just need to adjust the e2e after the changes

We probably need some changes toBeVisible -> notToBeVisible

Actually we want bottomAccessory to be visible in this test - thats what we are checking here.
As detox doesn't see bottomAccessory as visible the correct fix I found is to change function expectBottomAccessoryVisible to check not the visibility of bottomAccessory but if it exist - only selected bottomAccessory option appear in ViewHierarchy

index 7e65c4665..625ebde19 100644
--- a/FabricExample/e2e/single-feature-tests/tabs/test-tabs-bottom-accessory-ios.e2e.ts
+++ b/FabricExample/e2e/single-feature-tests/tabs/test-tabs-bottom-accessory-ios.e2e.ts
@@ -12,8 +12,8 @@ const bottomAccessoryElement = (testID: string) =>
     by.id(testID).withAncestor(by.type('RNSTabsBottomAccessoryComponentView')),
   ).atIndex(0);
 
-async function expectBottomAccessoryVisible(testID: string) {
-  await expect(bottomAccessoryElement(testID)).toBeVisible();
+async function expectBottomAccessoryExist(testID: string) {
+  await expect(bottomAccessoryElement(testID)).toExist();
 }
 
 async function expectBottomAccessoryText(testID: string, text: string) {
@@ -73,32 +73,32 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
   });
 
   it('should show the Upper Left accessory variant on initial load', async () => {
-    await expectBottomAccessoryVisible('accessory-upper-left');
+    await expectBottomAccessoryExist('accessory-upper-left');
     await expectBottomAccessoryText('accessory-upper-left', 'Upper Left');
   });
 
   it('should update the accessory when Center variant card is tapped', async () => {
     await element(by.id('variant-center')).tap();
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
   });
 
   it('should update the accessory when Lower Right variant card is tapped', async () => {
     await element(by.id('variant-lower-right')).tap();
-    await expectBottomAccessoryVisible('accessory-lower-right');
+    await expectBottomAccessoryExist('accessory-lower-right');
     await expectBottomAccessoryText('accessory-lower-right', 'Lower Right');
   });
 
   it('should update the accessory when Long variant card is tapped', async () => {
     await element(by.id('variant-long')).tap();
-    await expectBottomAccessoryVisible('accessory-long');
+    await expectBottomAccessoryExist('accessory-long');
   });
 
   it('should update the accessory when RGB variant card is tapped', async () => {
     await element(by.id('variant-rgb')).tap();
-    await expectBottomAccessoryVisible('rgb-strip-0');
-    await expectBottomAccessoryVisible('rgb-strip-1');
-    await expectBottomAccessoryVisible('rgb-strip-2');
+    await expectBottomAccessoryExist('rgb-strip-0');
+    await expectBottomAccessoryExist('rgb-strip-1');
+    await expectBottomAccessoryExist('rgb-strip-2');
   });
 
   // ---------------------------------------------------------------------------
@@ -107,17 +107,17 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
 
   it('should preserve the accessory when switching to the ScrollDown tab and back', async () => {
     await element(by.id('variant-center')).tap();
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
 
     await forceTapByLabeliOS('scroll-down-tab-item-label');
     await expect(element(by.id('scroll-down-scrollview'))).toBeVisible();
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
 
     await forceTapByLabeliOS('config-tab-item-label');
     await expect(element(by.id('config-scrollview'))).toBeVisible();
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
   });
 
@@ -140,7 +140,7 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
       by.type('UITabBar'),
     ).getAttributes()) as IosElementAttributes;
 
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
     expectBottomAccessoryExtended(extendedBottomAccessory, extendedTabBar);
   });
@@ -168,7 +168,7 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
       .atIndex(0)
       .getAttributes()) as IosElementAttributes;
 
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
     expectBottomAccessoryInline(
       inlineBottomAccessory,
@@ -211,7 +211,7 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
       by.type('UITabBar'),
     ).getAttributes()) as IosElementAttributes;
 
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
     expectBottomAccessoryExtended(extendedBottomAccessory, extendedTabBar);
   });
@@ -239,7 +239,7 @@ describeIfiOS('Tabs bottomAccessory (iOS)', () => {
       .atIndex(0)
       .getAttributes()) as IosElementAttributes;
 
-    await expectBottomAccessoryVisible('accessory-center');
+    await expectBottomAccessoryExist('accessory-center');
     await expectBottomAccessoryText('accessory-center', 'Center');
     expectBottomAccessoryInline(
       inlineBottomAccessory,

@kligarski kligarski self-assigned this Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants