Skip to content

Commit fb49fca

Browse files
authored
fix(iOS 26, Stack): center view inside bar button item (#3449)
## Description Fixes alignment of custom view inside bar button item on iOS 26. > [!NOTE] > > Please note that this will also change positioning of bar button items with `hidesSharedBackground: true` on iOS 26. | before | after | | --- | --- | | <video src="https://github.com/user-attachments/assets/d188be5c-48bf-4dee-97e2-599eac487af0" /> | <video src="https://github.com/user-attachments/assets/82d5ca33-8ef2-40bb-8d4e-030b0b53054c" /> | Closes #2990. ### Explanation Starting from iOS 26, custom view inside `UIBarButtonItem` is stretched to at least 36 width. This causes our `RNSScreenStackHeaderSubview` to stretch if its width is smaller than 36, leaving subviews in the same origin (for one subview, it's usually `(0,0)`; that's why it is aligned to the left, visible in "before" recording). In order to mitigate this, we add `wrapperView` which will be stretched by UIKit to required minimum size. `RNSScreenStackHeaderSubview` will be centered inside of it. Additional constraints and properties needed to be added to make sure that `wrapperView` is not stretched to all available space in `UINavigationBar` (this would happen in `headerRight` if we used only `wrapperView.width >= RNSScreenStackHeaderSubview.width` constraint): - `wrapperView.width == RNSScreenStackHeaderSubview.width` - makes sure that wrapper matches content. By using lower priority than default, we allow UIKit to "take over" with its minimum width, while maintaining "fit-content"/"content hugging" behavior, - `[self setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]` - prevents stretching to all available width in `UINavigationBar`. `RNSScreenStackHeaderSubview` needs to use `intrinsicContentSize` in order to bridge auto-layout centering and frame size from Yoga. ## Changes - on iOS 26, add wrapper view between `UIBarButtonItem` and `RNSScreenStackHeaderSubview` - center `RNSScreenStackHeaderSubview` inside `wrapperView` while maintaining correct sizing via auto layout - use `intrinsicContentSize` to bridge between auto-layout and frames calculated by Yoga - fix frame passed to shadow tree (now, `RNSScreenStackHeaderSubview` can have non-zero offset (which was not the case previously); without change to `self.bounds`, this offset would be accounted for 2 times when using `[self convertRect:frame toView:ancestorView]`) ## Test code and steps to reproduce Run `Test3446`. ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes
1 parent e172686 commit fb49fca

File tree

1 file changed

+65
-3
lines changed

1 file changed

+65
-3
lines changed

ios/RNSScreenStackHeaderSubview.mm

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
#endif // RCT_NEW_ARCH_ENABLED
2121

2222
@implementation RNSScreenStackHeaderSubview {
23-
#ifdef RCT_NEW_ARCH_ENABLED
23+
#if RCT_NEW_ARCH_ENABLED
2424
react::RNSScreenStackHeaderSubviewShadowNode::ConcreteState::Shared _state;
2525
CGRect _lastScheduledFrame;
26-
#endif
26+
#endif // RCT_NEW_ARCH_ENABLED
27+
#if !RCT_NEW_ARCH_ENABLED && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
28+
CGSize _lastReactFrameSize;
29+
#endif // !RCT_NEW_ARCH_ENABLED && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
2730
// TODO: Refactor this, so that we don't keep reference here at all.
2831
// Currently this likely creates retain cycle between subview & the bar button item.
2932
UIBarButtonItem *_barButtonItem;
@@ -88,7 +91,7 @@ - (void)updateShadowStateInContextOfAncestorView:(nullable UIView *)ancestorView
8891

8992
- (void)updateShadowStateInContextOfAncestorView:(nullable UIView *)ancestorView
9093
{
91-
[self updateShadowStateInContextOfAncestorView:ancestorView withFrame:self.frame];
94+
[self updateShadowStateInContextOfAncestorView:ancestorView withFrame:self.bounds];
9295
}
9396

9497
- (void)updateShadowStateWithFrame:(CGRect)frame
@@ -170,7 +173,15 @@ - (void)updateLayoutMetrics:(const react::LayoutMetrics &)layoutMetrics
170173
NSStringFromCGRect(frame),
171174
self);
172175
} else {
176+
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
177+
BOOL sizeHasChanged = _layoutMetrics.frame.size != layoutMetrics.frame.size;
178+
_layoutMetrics = layoutMetrics;
179+
if (sizeHasChanged) {
180+
[self invalidateIntrinsicContentSize];
181+
}
182+
#else // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
173183
self.bounds = CGRect{CGPointZero, frame.size};
184+
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
174185
[self layoutNavigationBar];
175186
}
176187
}
@@ -194,23 +205,74 @@ - (void)reactSetFrame:(CGRect)frame
194205
// Block any attempt to set coordinates on RNSScreenStackHeaderSubview. This
195206
// makes UINavigationBar the only one to control the position of header content.
196207
if (!CGSizeEqualToSize(frame.size, self.frame.size)) {
208+
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
209+
_lastReactFrameSize = frame.size;
210+
[self invalidateIntrinsicContentSize];
211+
#else // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
197212
[super reactSetFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
213+
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
198214
[self layoutNavigationBar];
199215
}
200216
}
217+
201218
#endif // RCT_NEW_ARCH_ENABLED
202219

203220
#pragma mark - UIBarButtonItem specific
204221

205222
- (UIBarButtonItem *)getUIBarButtonItem
206223
{
207224
if (_barButtonItem == nil) {
225+
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
226+
// Starting from iOS 26, UIBarButtonItem's customView is streched to have at least 36 width.
227+
// Stretching RNSScreenStackHeaderSubview means that its subviews are aligned to left instead
228+
// of the center. To mitigate this, we add a wrapper view that will center
229+
// RNSScreenStackHeaderSubview inside of itself.
230+
UIView *wrapperView = [UIView new];
231+
wrapperView.translatesAutoresizingMaskIntoConstraints = NO;
232+
233+
self.translatesAutoresizingMaskIntoConstraints = NO;
234+
[wrapperView addSubview:self];
235+
236+
[self.centerXAnchor constraintEqualToAnchor:wrapperView.centerXAnchor].active = YES;
237+
[self.centerYAnchor constraintEqualToAnchor:wrapperView.centerYAnchor].active = YES;
238+
239+
// To prevent UIKit from stretching subviews to all available width, we need to:
240+
// 1. Set width of wrapperView to match RNSScreenStackHeaderSubview BUT when
241+
// RNSScreenStackHeaderSubview's width is smaller that minimal required 36 width, it breaks
242+
// UIKit's constraint. That's why we need to lower the priority of the constraint.
243+
NSLayoutConstraint *widthEqual = [wrapperView.widthAnchor constraintEqualToAnchor:self.widthAnchor];
244+
widthEqual.priority = UILayoutPriorityDefaultHigh;
245+
widthEqual.active = YES;
246+
247+
NSLayoutConstraint *heightEqual = [wrapperView.heightAnchor constraintEqualToAnchor:self.heightAnchor];
248+
heightEqual.priority = UILayoutPriorityDefaultHigh;
249+
heightEqual.active = YES;
250+
251+
// 2. Set content hugging priority for RNSScreenStackHeaderSubview.
252+
[self setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
253+
[self setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical];
254+
255+
_barButtonItem = [[UIBarButtonItem alloc] initWithCustomView:wrapperView];
256+
#else // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
208257
_barButtonItem = [[UIBarButtonItem alloc] initWithCustomView:self];
258+
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
209259
[self configureBarButtonItem];
210260
}
261+
211262
return _barButtonItem;
212263
}
213264

265+
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
266+
- (CGSize)intrinsicContentSize
267+
{
268+
#if RCT_NEW_ARCH_ENABLED
269+
return RCTCGSizeFromSize(_layoutMetrics.frame.size);
270+
#else // RCT_NEW_ARCH_ENABLED
271+
return _lastReactFrameSize;
272+
#endif // RCT_NEW_ARCH_ENABLED
273+
}
274+
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
275+
214276
- (void)configureBarButtonItem
215277
{
216278
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)

0 commit comments

Comments
 (0)