Skip to content

Conversation

@kligarski
Copy link
Contributor

@kligarski kligarski commented Oct 7, 2025

Description

Adds support for botttomAccessory in Bottom Tabs starting from iOS 26.

Synchronization with ShadowTree

When bottom accessory transitions between regular and inline environments (when tab bar is minimized), we need to update the position and size of the bottom accessory. Our approach is different for RN < 0.82 and RN >= 0.82.

Legacy architecture & New architecture prior to [email protected]

In versions prior to RN 0.82, we need use DisplayLink and presentation layer frames to get intermediate frames during the transition. This approach however has a major drawback - we are always at least one frame behind the current state as we're observing what is currently presented. When the difference in size/origin between frames is significant, you can see the content "jumping". In the case of bottom accessory, this is especially visible when using non-translucent background and transitioning from inline to regular environment (pay attention to the right edge of the accessory).

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.08.31.39.mov

Introduction of synchronous state updates in RN 0.82 (more details below) does not improve the situation when using this approach as we are still going to be at least one frame behind the animation.

[email protected] and higher

Thanks to introduction of synchronous state updates in RN 0.82, we can rely fully on native mechanisms for handling the transition. Bottom accessory only receives the final frame of the transition and thanks to synchronous state updates, we can immediately recalculate the layout in the Shadow Tree and update the Host Tree. This allows Core Animation framework to make the transition smooth. Details of how we think this works are available here.

Unfortunately, when using react-native, the situation is a little bit more complicated.

Text

Text component behaves differently to the native platform. During the transition, it immediately adapts to the final frame size and then it is stretched. In bare UIKit app, the text adapt to new frame size at the end of the transition.

react-native UIKit
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.16.33.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.22.22.mov

This requires more investigation and potentially changes in react-native.

Borders

CoreAnimation does not support non-uniform borders so react-native handles them in a custom way that does not seem to be compatible with the transition mechanism.

non-uniform borders uniform borders + CA enabled
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.28.39.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.10.32.17.mov

This requires more investigation and potentially changes in react-native.

Images

Similar problem (in a way it looks, not the exact mechanism of the bug) happens when using images e.g. with width: 100%.

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.11.08.09.mov

This requires more investigation and potentially changes in react-native.

Mounting/unmounting views during transition

While state updates are performed synchronously, any changes to React Element Tree in reaction to environment change are handled asynchronously. We think that this is why the transition handled by CoreAnimation breaks when trying to mount/unmount components on environment change.

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.08.16.12.mov

Here, we try to remove the note icon. Unfortunately, the rest of the layout does not adapt. You can also observe the text stretching as mentioned 2 sections above.

Changes

  • add BottomTabsAccessory JS component and use it in BottomTabs,
  • add BottomTabsAccessoryComponentView, BottomTabsAccessoryEventEmitter, BottomTabsAccessoryComponentViewManager,
  • add BottomAccessoryHelper to synchronize state between Host and ShadowTree,
  • adapt BottomTabsHost to accept 2 types of children (Screen and Accessory),
  • add test screen.

Test code and steps to reproduce

Run Test3288.

Checklist

@kligarski kligarski marked this pull request as ready for review October 13, 2025 09:10
Copy link
Contributor

@t0maboro t0maboro left a comment

Choose a reason for hiding this comment

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

I haven't finished yet, flushing the 1st part

@aledebla03
Copy link

Is there any ETA? Looks fantastic 🔥

@kligarski
Copy link
Contributor Author

I'm glad you like it. Recently I had to switch my focus to investigate some higher priority issues related to iOS 26 support. Once I finish, I'll come back to bottom accessory.

Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Thank you for very nice PR description. It's really helpful during review process. Keep it up!

Here I address the problems mentioned in PR description. I'll do a follow-up review regarding the code.


The "text" behaviour you describe is fine. Having it at the beginning / end is not that much of a difference. Text animations are expensive, probably one of the reasons this is done this way in UIKit + the accessory view *does not seem like a place for pages of text.


The "borders problem" is the same we have with sync updates in split view. @t0maboro already created ticket for this. We should start conversation with react-native team how we should approach this problem.


The "images" problem is interesting. It would be worth to investigate it a bit, initially in direction: who is responsible for image frame change. Is that Yoga or platform framework? Who interpolates it?


The "Mounting/unmounting views during transition" problem

Yeah, it looks ugly. But it should not be like that. At least since 0.82. There are mechanisms in RN to emit sync events to JS now. Take a look here for usage examples. We should try that out. We can schedule a call here. Using this we should be able to trigger sync react render & have it hopefully nicely animated.

Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

This PR looks really good overall. Great job. Few comments here and there.

@kkafar
Copy link
Member

kkafar commented Nov 5, 2025

Bottom accessory view + view mount / unmount during transition between environments.

patched "sync" events "sync" events (current state)
bottom-accessory-with-sync-events.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2025-10-13.at.08.16.12.mov

@kligarski kligarski requested review from kkafar and t0maboro November 6, 2025 08:06
Copy link
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Really good job. The PR is in really good shape & code is of high quality - kudos.

I've got few remarks, refactor requests & questions. Please answer them.

shadowNode.getState());
auto stateData = state->getData();

if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
Copy link
Member

Choose a reason for hiding this comment

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

We need refactor across whole codebase that will extract this particular condition to a separate function. We have too many places where this is stated explicitly & it is hard to change definition of what "empty state" means. Let's create ticket for this, please.

namespace react = facebook::react;

@implementation RNSBottomAccessoryHelper {
RNSBottomTabsAccessoryComponentView *_bottomAccessoryView;
Copy link
Member

Choose a reason for hiding this comment

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

Will this create retain cycle? If so, let's document it here & state how to break it.

Comment on lines +114 to +116
UITabAccessoryEnvironment environment =
self->_bottomAccessoryView.traitCollection.tabAccessoryEnvironment;
[self->_bottomAccessoryView.reactEventEmitter emitOnEnvironmentChangeIfNeeded:environment];
Copy link
Member

Choose a reason for hiding this comment

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

Do you recall whether this callback is invoked only when the trait has really changed value? Or is it called also if some one just wrote to the value? My question basically is whether we should diff with previousTraitCollection inside the handler or is this done for us?

return _bottomAccessoryView.superview.superview;
}

- (void)notifyFrameUpdate
Copy link
Member

Choose a reason for hiding this comment

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

I'd like the naming of this method to be more specific. Something like nativeWrapperViewChangedFrame or something of this kind.

// We want the enable the display link as well so that it takes over later with correct origin.
if (!_initialStateUpdateSent) {
CGRect frame = CGRectMake(0, 0, self.nativeWrapperView.frame.size.width, self.nativeWrapperView.frame.size.height);
[self updateShadowStateWithFrame:frame];
Copy link
Member

Choose a reason for hiding this comment

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

Let's go with SplitView example & create a shadow state proxy for handling all shadow state updates.

Also the proxy should be retained by the view & helper should access it via the view.

[_reactSubviews insertObject:childScreen atIndex:index];
[super insertReactSubview:subview atIndex:index];
[self validateAndHandleReactSubview:subview didMount:YES];
[_reactSubviews insertObject:subview atIndex:index];
Copy link
Member

Choose a reason for hiding this comment

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

Also why isn't this call to insert / remove object from _reactSubview handled inside the callback?


#pragma mark - Common

- (void)validateAndHandleReactSubview:(UIView *)subview didMount:(BOOL)mount
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- (void)validateAndHandleReactSubview:(UIView *)subview didMount:(BOOL)mount
- (void)validateAndHandleReactSubview:(UIView *)subview shouldMount:(BOOL)mount

Comment on lines +79 to +82
<BottomTabsAccessoryContent environment="regular">
{bottomAccessory('regular')}
</BottomTabsAccessoryContent>
<BottomTabsAccessoryContent environment="inline">
Copy link
Member

Choose a reason for hiding this comment

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

Do we do this double-rendering trick only on >= 82? whaat

<BottomTabsAccessoryNativeComponent
{...props}
collapsable={false}
style={[props.style, styles.container]}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
style={[props.style, styles.container]}
style={[props.style, styles.absolutePositioned]}

Copy link
Member

Choose a reason for hiding this comment

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

or positionAbsolute or align with predefined style sets from StyleSheet.xxx namespace.

Comment on lines +26 to +27
width: '100%',
height: '100%',
Copy link
Member

Choose a reason for hiding this comment

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

Is this intentionally chosen over right: 0, bottom: 0?

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.

5 participants