diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.ktindex fa1b77317f609983ae2434764174072e51130550..270c81c90971b0ff3038b999973dd4d128a9d140 100644--- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt@@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager : config.setTitle(title) } + // The t3 patch adds these iOS glass-chrome props to the native spec; they have+ // no Android implementation, but the codegen-generated interface requires the+ // setters to exist. No-ops keep the Android build compiling.+ override fun setSubtitle(+ config: ScreenStackHeaderConfig,+ subtitle: String?,+ ) = Unit++ override fun setLargeSubtitle(+ config: ScreenStackHeaderConfig,+ largeSubtitle: String?,+ ) = Unit++ override fun setNavigationItemStyle(+ config: ScreenStackHeaderConfig,+ navigationItemStyle: String?,+ ) = Unit++ override fun setHeaderCenterBarButtonItems(+ config: ScreenStackHeaderConfig,+ headerCenterBarButtonItems: ReadableArray?,+ ) = Unit++ override fun setHeaderToolbarItems(+ config: ScreenStackHeaderConfig,+ headerToolbarItems: ReadableArray?,+ ) = Unit+ override fun setTitleFontFamily( config: ScreenStackHeaderConfig, titleFontFamily: String?,diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.ktindex 64f14f0c5ade16eb9519b8780d4ce013f88fbd3a..6cb1d69f97a95759c662a6006a9626925d9fefd9 100644--- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt+++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt@@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager : Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android") } + // Bar button transition identifiers only apply on iOS.+ override fun setIdentifier(+ view: ScreenStackHeaderSubview,+ identifier: String?,+ ) = Unit+ // synchronousShadowStateUpdatesEnabled is not available on Android atm, // however we must override their setters override fun setSynchronousShadowStateUpdatesEnabled(diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.hindex a26d1254eafd89268f65d968988cf6a3b8c1e457..62eea42ac26c532801189556c5f51012c3df3a52 100644--- a/ios/RNSBarButtonItem.h+++ b/ios/RNSBarButtonItem.h@@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction imageLoader:(RCTImageLoader *)imageLoader;+++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict+ menuAction:(RNSBarButtonMenuItemAction)menuAction+ imageLoader:(RCTImageLoader *)imageLoader; #endif // defined(__cplusplus) @enddiff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mmindex 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644--- a/ios/RNSBarButtonItem.mm+++ b/ios/RNSBarButtonItem.mm@@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict if (dict[@"accessibilityHint"]) { self.accessibilityHint = dict[@"accessibilityHint"]; }+#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)+ NSNumber *glassEffectNum = dict[@"glassEffect"];+ if (@available(iOS 26.0, *)) {+ if ([glassEffectNum boolValue]) {+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;+ UIBackgroundConfiguration *background = configuration.background;+ background.strokeWidth = 0.0;+ background.strokeColor = UIColor.clearColor;+ configuration.background = background;+ configuration.contentInsets = NSDirectionalEdgeInsetsZero;+ if (title != nil) {+ configuration.title = title;+ }++ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];+ button.translatesAutoresizingMaskIntoConstraints = NO;+ button.enabled = self.enabled;+ button.accessibilityLabel = self.accessibilityLabel;+ button.accessibilityHint = self.accessibilityHint;++ CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0;+ [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;++ [[self class] resolveImageFromConfig:dict+ imageLoader:imageLoader+ completionBlock:^(UIImage *img) {+ UIButtonConfiguration *updatedConfiguration = button.configuration;+ updatedConfiguration.image = img;+ button.configuration = updatedConfiguration;+ }];++ NSDictionary *menu = dict[@"menu"];+ if (menu) {+ button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader];+ button.showsMenuAsPrimaryAction = YES;+ } else {+ NSString *buttonId = dict[@"buttonId"];+ if (buttonId && action) {+ UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) {+ action(buttonId);+ }];+ [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside];+ }+ }++ self.customView = button;+ return self;+ }+ }+#endif #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 if (@available(tvOS 17.0, *)) {diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.hindex 8a41697223f6c4b5cb2dd6d450fc58cbb3b27536..309ba2f762068dcf452c172f7c7cbf2a55d03cb2 100644--- a/ios/RNSScreenStackHeaderConfig.h+++ b/ios/RNSScreenStackHeaderConfig.h@@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, retain) NSString *title;+@property (nonatomic, retain) NSString *subtitle;+@property (nonatomic, retain) NSString *largeSubtitle; @property (nonatomic, retain) NSString *titleFontFamily; @property (nonatomic, retain) NSNumber *titleFontSize; @property (nonatomic, retain) NSString *titleFontWeight;@@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL backButtonInCustomView; @property (nonatomic) UISemanticContentAttribute direction; @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode;+@property (nonatomic) NSInteger navigationItemStyle; @property (nonatomic) RNSBlurEffectStyle blurEffect; @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems; @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems;+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems;+@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems; @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; NS_ASSUME_NONNULL_ENDdiff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mmindex 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab60e185033 100644--- a/ios/RNSScreenStackHeaderConfig.mm+++ b/ios/RNSScreenStackHeaderConfig.mm@@ -24,11 +24,46 @@ #import "RNSSearchBar.h" #import "UINavigationBar+RNSUtility.h" +#import <objc/runtime.h>+ namespace react = facebook::react; static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;++#if !TARGET_OS_TV+// Each placement keeps its own cache so changes to one side do not move live+// items on the other side into new groups during a navigation transition.+static char RNSAppliedLeadingHeaderItemsKey;+static char RNSAppliedTrailingHeaderItemsKey;+static char RNSAppliedCenterHeaderItemsKey;++// Records a placement's owner and contents, returning whether they changed.+static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey)+{+ if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) {+ return NO;+ }+ objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);+ return YES;+}+#endif+static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; +static NSInteger navigationItemStyleFromCppEquivalent(+ react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle)+{+ switch (navigationItemStyle) {+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser:+ return UINavigationItemStyleBrowser;+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor:+ return UINavigationItemStyleEditor;+ case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator:+ default:+ return UINavigationItemStyleNavigator;+ }+}+ @interface RCTImageLoader (Private) - (id<RCTImageCache>)imageCache; @end@@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>+#if !TARGET_OS_TV+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items;+#endif @end @implementation RNSScreenStackHeaderConfig {@@ -80,6 +118,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES;+ _navigationItemStyle = UINavigationItemStyleNavigator; _blurEffect = RNSBlurEffectStyleNone; _synchronousShadowStateUpdatesEnabled = YES; }@@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title;+ if (@available(iOS 26.0, *)) {+ navitem.subtitle = config.subtitle;+ navitem.largeSubtitle = config.largeSubtitle;+ } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated];@@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;++ if (@available(iOS 16.0, *)) {+ navitem.style = (UINavigationItemStyle)config.navigationItemStyle;+ } #endif UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; navitem.standardAppearance = appearance; navitem.compactAppearance = appearance;+ if (@available(iOS 26.0, *)) {+ navitem.subtitle = config.subtitle;+ navitem.largeSubtitle = config.largeSubtitle;+ } // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV@@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc navitem.leftItemsSupplementBackButton = config.backButtonInCustomView; #endif navitem.titleView = nil;- navitem.leftBarButtonItems = nil;- navitem.rightBarButtonItems = nil; #if !TARGET_OS_TV // We want to set navitem.searchController to nil only if we are sure@@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc bool searchBarPresent = false; #endif /* !TARGET_OS_TV */ + NSMutableArray<UIBarButtonItem *> *subviewLeftItems = [NSMutableArray array];+ NSMutableArray<UIBarButtonItem *> *subviewRightItems = [NSMutableArray array]; for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { // This code should be kept in sync on Fabric with analogous switch statement in // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method. switch (subview.type) { case RNSScreenStackHeaderSubviewTypeLeft: {- NSArray<UIBarButtonItem *> *currentItems = navitem.leftBarButtonItems ?: @[];- NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy];- [mutableItems addObject:[subview getUIBarButtonItem]];- navitem.leftBarButtonItems = mutableItems;+ [subviewLeftItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeRight: {- NSArray<UIBarButtonItem *> *currentItems = navitem.rightBarButtonItems ?: @[];- NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy];- [mutableItems addObject:[subview getUIBarButtonItem]];- navitem.rightBarButtonItems = mutableItems;+ [subviewRightItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeCenter:@@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title;- navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems- withCurrentItems:navitem.leftBarButtonItems];- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems- withCurrentItems:navitem.rightBarButtonItems];+ // Retain the config in the key so its address cannot be reused while cached.+ // Items capture this config's event emitter in their press handlers, so a+ // remounted header-config view with value-equal configs must still rebuild+ // instead of dispatching presses into the old config's emitter.+ // getUIBarButtonItem is stable for the lifetime of its React header+ // subview. Include those native identities in the key so React-backed+ // items can use the same cache without reusing a group after its view was+ // replaced.+ // A bar button item can belong to only one group. Even if the item itself+ // is reused, constructing a new group removes it from the old group that+ // UIKit may still be animating. Preserve each unchanged placement's group,+ // including the empty leading group beside the system back button.+#if !TARGET_OS_TV+ if (@available(iOS 16.0, *)) {+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[];+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[];+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[];+ if (RNSUpdateHeaderItemsCache(+ navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) {+ NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems+ withCurrentItems:subviewLeftItems+ navigationItem:navitem+ allowsSearchBarPlacement:NO];+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items];+ }+ if (RNSUpdateHeaderItemsCache(+ navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) {+ NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems+ withCurrentItems:subviewRightItems+ navigationItem:navitem+ allowsSearchBarPlacement:NO];+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items];+ }+ if (@available(iOS 26.0, *)) {+ if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) {+ NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems+ withCurrentItems:@[]+ navigationItem:navitem+ allowsSearchBarPlacement:NO];+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items];+ }+ }+ } else+#endif+ {+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems+ withCurrentItems:subviewLeftItems+ navigationItem:navitem+ allowsSearchBarPlacement:NO];+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems+ withCurrentItems:subviewRightItems+ navigationItem:navitem+ allowsSearchBarPlacement:NO];+ navitem.leftBarButtonItems = leftBarButtonItems;+ navitem.rightBarButtonItems = rightBarButtonItems;+ }+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;+ NSMutableArray<NSDictionary<NSString *, id> *> *nonMailSearchToolbarConfigs = [NSMutableArray array];+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {+ if (toolbarConfig[@"mailSearchToolbar"]) {+ if (mailSearchToolbarConfig == nil) {+ mailSearchToolbarConfig = toolbarConfig;+ }+ } else {+ [nonMailSearchToolbarConfigs addObject:toolbarConfig];+ }+ }+ NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy];+ if (mailSearchToolbarConfig == nil) {+ for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) {+ if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) {+ mailSearchToolbarConfig = rightConfig;+ break;+ }+ }+ }++ static NSInteger const RNSMailSearchToolbarViewTag = 260628;+ UIView *chromeHostView = vc.view;+ UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag];+ if (existingMailSearchToolbar == nil) {+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];+ }+ // Reuse the live toolbar when nothing it was built from changed: rebuilding+ // replaces the fallback UISearchTextField with a fresh empty one, dropping+ // the user's in-progress search text and first responder on every unrelated+ // header update. Keyed on the config instance (the button/search blocks+ // capture its event emitter), the toolbar config values, and the host width+ // (the width constraint constant is resolved from it at build time).+ static char RNSAppliedMailSearchToolbarConfigKey;+ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil+ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ]+ : nil;+ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil &&+ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey)+ isEqual:mailSearchToolbarKey];+ if (!reuseMailSearchToolbar) {+ [existingMailSearchToolbar removeFromSuperview];+ }++ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) {+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)+ if (@available(iOS 26.0, *)) {+ CGFloat horizontalInset = 18.0;+ NSNumber *width = mailSearchToolbarConfig[@"width"];+ CGFloat hostWidth = chromeHostView.bounds.size.width;+ if (hostWidth <= 0.0) {+ hostWidth = vc.view.bounds.size.width;+ }+ if (hostWidth <= 0.0) {+ hostWidth = [UIScreen mainScreen].bounds.size.width;+ }+ CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0)));+ CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth;+ if (hostWidth > 0.0) {+ resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0));+ }++ CGFloat toolbarHeight = 48.0;+ CGFloat toolbarRadius = toolbarHeight / 2.0;+ CGFloat buttonSize = toolbarHeight;+ CGFloat sideButtonReserve = buttonSize + 8.0;+ CGFloat searchFieldHeight = 42.0;+ CGFloat toolbarBottomSpacing = 4.0;+ UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3];+ NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{+ NSForegroundColorAttributeName : UIColor.secondaryLabelColor,+ NSFontAttributeName : searchFont,+ };+ UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide;++ UIView *toolbarHost = [[UIView alloc] init];+ toolbarHost.tag = RNSMailSearchToolbarViewTag;+ objc_setAssociatedObject(+ toolbarHost,+ &RNSAppliedMailSearchToolbarConfigKey,+ mailSearchToolbarKey,+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;+ [chromeHostView addSubview:toolbarHost];+ // The screen stays mounted beneath pushed routes, so its keyboard layout+ // guide can track a keyboard owned by another screen. Keep the toolbar at+ // rest unless its own search field is editing.+ NSLayoutConstraint *keyboardAvoidConstraint =+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor+ constant:-toolbarBottomSpacing];+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;+ NSLayoutConstraint *restingBottomConstraint =+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor+ constant:-toolbarBottomSpacing];+ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh;+ [NSLayoutConstraint activateConstraints:@[+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor+ constant:-toolbarBottomSpacing],+ keyboardAvoidConstraint,+ restingBottomConstraint,+ [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth],+ [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight],+ ]];+ [chromeHostView bringSubviewToFront:toolbarHost];++ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];+ glassEffect.interactive = YES;+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];+ glassView.clipsToBounds = YES;+ glassView.layer.cornerRadius = toolbarRadius;+ glassView.translatesAutoresizingMaskIntoConstraints = NO;+ [toolbarHost addSubview:glassView];+ BOOL hasFilterButton =+ mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil;+ BOOL hasComposeButton =+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;+ NSLayoutConstraint *glassLeadingConstraint =+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset];+ NSLayoutConstraint *glassTrailingConstraint =+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset];+ [NSLayoutConstraint activateConstraints:@[+ glassLeadingConstraint,+ glassTrailingConstraint,+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],+ ]];++ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(+ config->_eventEmitter);+ if (eventEmitter && buttonId) {+ eventEmitter->onPressHeaderBarButtonItem(+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{+ .buttonId = std::string([buttonId UTF8String])});+ }+ };+Lines 1–500 of 2217. Load more
diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt index fa1b77317f609983ae2434764174072e51130550..270c81c90971b0ff3038b999973dd4d128a9d140 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt @@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager : config.setTitle(title) } + // The t3 patch adds these iOS glass-chrome props to the native spec; they have + // no Android implementation, but the codegen-generated interface requires the + // setters to exist. No-ops keep the Android build compiling. + override fun setSubtitle( + config: ScreenStackHeaderConfig, + subtitle: String?, + ) = Unit + + override fun setLargeSubtitle( + config: ScreenStackHeaderConfig, + largeSubtitle: String?, + ) = Unit + + override fun setNavigationItemStyle( + config: ScreenStackHeaderConfig, + navigationItemStyle: String?, + ) = Unit + + override fun setHeaderCenterBarButtonItems( + config: ScreenStackHeaderConfig, + headerCenterBarButtonItems: ReadableArray?, + ) = Unit + + override fun setHeaderToolbarItems( + config: ScreenStackHeaderConfig, + headerToolbarItems: ReadableArray?, + ) = Unit + override fun setTitleFontFamily( config: ScreenStackHeaderConfig, titleFontFamily: String?, diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt index 64f14f0c5ade16eb9519b8780d4ce013f88fbd3a..6cb1d69f97a95759c662a6006a9626925d9fefd9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt @@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager : Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android") } + // Bar button transition identifiers only apply on iOS. + override fun setIdentifier( + view: ScreenStackHeaderSubview, + identifier: String?, + ) = Unit + // synchronousShadowStateUpdatesEnabled is not available on Android atm, // however we must override their setters override fun setSynchronousShadowStateUpdatesEnabled( diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h index a26d1254eafd89268f65d968988cf6a3b8c1e457..62eea42ac26c532801189556c5f51012c3df3a52 100644 --- a/ios/RNSBarButtonItem.h +++ b/ios/RNSBarButtonItem.h @@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction imageLoader:(RCTImageLoader *)imageLoader; + ++ (UIMenu *)initUIMenuWithDict:(NSDictionary<NSString *, id> *)dict + menuAction:(RNSBarButtonMenuItemAction)menuAction + imageLoader:(RCTImageLoader *)imageLoader; #endif // defined(__cplusplus) @end diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644 --- a/ios/RNSBarButtonItem.mm +++ b/ios/RNSBarButtonItem.mm @@ -110,6 +110,58 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict if (dict[@"accessibilityHint"]) { self.accessibilityHint = dict[@"accessibilityHint"]; } +#if !TARGET_OS_TV && RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + NSNumber *glassEffectNum = dict[@"glassEffect"]; + if (@available(iOS 26.0, *)) { + if ([glassEffectNum boolValue]) { + UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration]; + configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule; + UIBackgroundConfiguration *background = configuration.background; + background.strokeWidth = 0.0; + background.strokeColor = UIColor.clearColor; + configuration.background = background; + configuration.contentInsets = NSDirectionalEdgeInsetsZero; + if (title != nil) { + configuration.title = title; + } + + UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil]; + button.translatesAutoresizingMaskIntoConstraints = NO; + button.enabled = self.enabled; + button.accessibilityLabel = self.accessibilityLabel; + button.accessibilityHint = self.accessibilityHint; + + CGFloat resolvedWidth = width != nil ? width.doubleValue : 56.0; + [button.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES; + [button.heightAnchor constraintEqualToConstant:50.0].active = YES; + + [[self class] resolveImageFromConfig:dict + imageLoader:imageLoader + completionBlock:^(UIImage *img) { + UIButtonConfiguration *updatedConfiguration = button.configuration; + updatedConfiguration.image = img; + button.configuration = updatedConfiguration; + }]; + + NSDictionary *menu = dict[@"menu"]; + if (menu) { + button.menu = [[self class] initUIMenuWithDict:menu menuAction:menuAction imageLoader:imageLoader]; + button.showsMenuAsPrimaryAction = YES; + } else { + NSString *buttonId = dict[@"buttonId"]; + if (buttonId && action) { + UIAction *pressAction = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull sender) { + action(buttonId); + }]; + [button addAction:pressAction forControlEvents:UIControlEventTouchUpInside]; + } + } + + self.customView = button; + return self; + } + } +#endif #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 if (@available(tvOS 17.0, *)) { diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h index 8a41697223f6c4b5cb2dd6d450fc58cbb3b27536..309ba2f762068dcf452c172f7c7cbf2a55d03cb2 100644 --- a/ios/RNSScreenStackHeaderConfig.h +++ b/ios/RNSScreenStackHeaderConfig.h @@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, retain) NSString *title; +@property (nonatomic, retain) NSString *subtitle; +@property (nonatomic, retain) NSString *largeSubtitle; @property (nonatomic, retain) NSString *titleFontFamily; @property (nonatomic, retain) NSNumber *titleFontSize; @property (nonatomic, retain) NSString *titleFontWeight; @@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL backButtonInCustomView; @property (nonatomic) UISemanticContentAttribute direction; @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode; +@property (nonatomic) NSInteger navigationItemStyle; @property (nonatomic) RNSBlurEffectStyle blurEffect; @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerRightBarButtonItems; @property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerLeftBarButtonItems; +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerCenterBarButtonItems; +@property (nonatomic, copy, nullable) NSArray<NSDictionary<NSString *, id> *> *headerToolbarItems; @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab60e185033 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -24,11 +24,46 @@ #import "RNSSearchBar.h" #import "UINavigationBar+RNSUtility.h" +#import <objc/runtime.h> + namespace react = facebook::react; static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; + +#if !TARGET_OS_TV +// Each placement keeps its own cache so changes to one side do not move live +// items on the other side into new groups during a navigation transition. +static char RNSAppliedLeadingHeaderItemsKey; +static char RNSAppliedTrailingHeaderItemsKey; +static char RNSAppliedCenterHeaderItemsKey; + +// Records a placement's owner and contents, returning whether they changed. +static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey) +{ + if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) { + return NO; + } + objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return YES; +} +#endif +static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; +static NSInteger navigationItemStyleFromCppEquivalent( + react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle) +{ + switch (navigationItemStyle) { + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Browser: + return UINavigationItemStyleBrowser; + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Editor: + return UINavigationItemStyleEditor; + case react::RNSScreenStackHeaderConfigNavigationItemStyle::Navigator: + default: + return UINavigationItemStyleNavigator; + } +} + @interface RCTImageLoader (Private) - (id<RCTImageCache>)imageCache; @end @@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving> +#if !TARGET_OS_TV +- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items; +#endif @end @implementation RNSScreenStackHeaderConfig { @@ -80,6 +118,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES; + _navigationItemStyle = UINavigationItemStyleNavigator; _blurEffect = RNSBlurEffectStyleNone; _synchronousShadowStateUpdatesEnabled = YES; } @@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title; + if (@available(iOS 26.0, *)) { + navitem.subtitle = config.subtitle; + navitem.largeSubtitle = config.largeSubtitle; + } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; @@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; + + if (@available(iOS 16.0, *)) { + navitem.style = (UINavigationItemStyle)config.navigationItemStyle; + } #endif UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; navitem.standardAppearance = appearance; navitem.compactAppearance = appearance; + if (@available(iOS 26.0, *)) { + navitem.subtitle = config.subtitle; + navitem.largeSubtitle = config.largeSubtitle; + } // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV @@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc navitem.leftItemsSupplementBackButton = config.backButtonInCustomView; #endif navitem.titleView = nil; - navitem.leftBarButtonItems = nil; - navitem.rightBarButtonItems = nil; #if !TARGET_OS_TV // We want to set navitem.searchController to nil only if we are sure @@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc bool searchBarPresent = false; #endif /* !TARGET_OS_TV */ + NSMutableArray<UIBarButtonItem *> *subviewLeftItems = [NSMutableArray array]; + NSMutableArray<UIBarButtonItem *> *subviewRightItems = [NSMutableArray array]; for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { // This code should be kept in sync on Fabric with analogous switch statement in // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method. switch (subview.type) { case RNSScreenStackHeaderSubviewTypeLeft: { - NSArray<UIBarButtonItem *> *currentItems = navitem.leftBarButtonItems ?: @[]; - NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy]; - [mutableItems addObject:[subview getUIBarButtonItem]]; - navitem.leftBarButtonItems = mutableItems; + [subviewLeftItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeRight: { - NSArray<UIBarButtonItem *> *currentItems = navitem.rightBarButtonItems ?: @[]; - NSMutableArray<UIBarButtonItem *> *mutableItems = [currentItems mutableCopy]; - [mutableItems addObject:[subview getUIBarButtonItem]]; - navitem.rightBarButtonItems = mutableItems; + [subviewRightItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeCenter: @@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; - navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; + // Retain the config in the key so its address cannot be reused while cached. + // Items capture this config's event emitter in their press handlers, so a + // remounted header-config view with value-equal configs must still rebuild + // instead of dispatching presses into the old config's emitter. + // getUIBarButtonItem is stable for the lifetime of its React header + // subview. Include those native identities in the key so React-backed + // items can use the same cache without reusing a group after its view was + // replaced. + // A bar button item can belong to only one group. Even if the item itself + // is reused, constructing a new group removes it from the old group that + // UIKit may still be animating. Preserve each unchanged placement's group, + // including the empty leading group beside the system back button. +#if !TARGET_OS_TV + if (@available(iOS 16.0, *)) { + NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; + NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; + if (RNSUpdateHeaderItemsCache( + navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems + withCurrentItems:subviewLeftItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items]; + } + if (RNSUpdateHeaderItemsCache( + navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems + withCurrentItems:subviewRightItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items]; + } + if (@available(iOS 26.0, *)) { + if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems + withCurrentItems:@[] + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items]; + } + } + } else +#endif + { + NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems + withCurrentItems:subviewLeftItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems + withCurrentItems:subviewRightItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; + } + NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil; + NSMutableArray<NSDictionary<NSString *, id> *> *nonMailSearchToolbarConfigs = [NSMutableArray array]; + for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { + if (mailSearchToolbarConfig == nil) { + mailSearchToolbarConfig = toolbarConfig; + } + } else { + [nonMailSearchToolbarConfigs addObject:toolbarConfig]; + } + } + NSArray<NSDictionary<NSString *, id> *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy]; + if (mailSearchToolbarConfig == nil) { + for (NSDictionary<NSString *, id> *rightConfig in config.headerRightBarButtonItems) { + if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) { + mailSearchToolbarConfig = rightConfig; + break; + } + } + } + + static NSInteger const RNSMailSearchToolbarViewTag = 260628; + UIView *chromeHostView = vc.view; + UIView *existingMailSearchToolbar = [chromeHostView viewWithTag:RNSMailSearchToolbarViewTag]; + if (existingMailSearchToolbar == nil) { + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; + } + // Reuse the live toolbar when nothing it was built from changed: rebuilding + // replaces the fallback UISearchTextField with a fresh empty one, dropping + // the user's in-progress search text and first responder on every unrelated + // header update. Keyed on the config instance (the button/search blocks + // capture its event emitter), the toolbar config values, and the host width + // (the width constraint constant is resolved from it at build time). + static char RNSAppliedMailSearchToolbarConfigKey; + NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil + ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] + : nil; + BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && + [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) + isEqual:mailSearchToolbarKey]; + if (!reuseMailSearchToolbar) { + [existingMailSearchToolbar removeFromSuperview]; + } + + if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + CGFloat horizontalInset = 18.0; + NSNumber *width = mailSearchToolbarConfig[@"width"]; + CGFloat hostWidth = chromeHostView.bounds.size.width; + if (hostWidth <= 0.0) { + hostWidth = vc.view.bounds.size.width; + } + if (hostWidth <= 0.0) { + hostWidth = [UIScreen mainScreen].bounds.size.width; + } + CGFloat fallbackWidth = MIN(560.0, MAX(300.0, hostWidth - (horizontalInset * 2.0))); + CGFloat resolvedWidth = width != nil ? width.doubleValue : fallbackWidth; + if (hostWidth > 0.0) { + resolvedWidth = MIN(resolvedWidth, MAX(260.0, hostWidth - 12.0)); + } + + CGFloat toolbarHeight = 48.0; + CGFloat toolbarRadius = toolbarHeight / 2.0; + CGFloat buttonSize = toolbarHeight; + CGFloat sideButtonReserve = buttonSize + 8.0; + CGFloat searchFieldHeight = 42.0; + CGFloat toolbarBottomSpacing = 4.0; + UIFont *searchFont = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3]; + NSDictionary<NSAttributedStringKey, id> *placeholderAttributes = @{ + NSForegroundColorAttributeName : UIColor.secondaryLabelColor, + NSFontAttributeName : searchFont, + }; + UIKeyboardLayoutGuide *keyboardLayoutGuide = chromeHostView.keyboardLayoutGuide; + + UIView *toolbarHost = [[UIView alloc] init]; + toolbarHost.tag = RNSMailSearchToolbarViewTag; + objc_setAssociatedObject( + toolbarHost, + &RNSAppliedMailSearchToolbarConfigKey, + mailSearchToolbarKey, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; + [chromeHostView addSubview:toolbarHost]; + // The screen stays mounted beneath pushed routes, so its keyboard layout + // guide can track a keyboard owned by another screen. Keep the toolbar at + // rest unless its own search field is editing. + NSLayoutConstraint *keyboardAvoidConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor + constant:-toolbarBottomSpacing]; + keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; + NSLayoutConstraint *restingBottomConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor + constant:-toolbarBottomSpacing]; + restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; + [NSLayoutConstraint activateConstraints:@[ + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor + constant:-toolbarBottomSpacing], + keyboardAvoidConstraint, + restingBottomConstraint, + [toolbarHost.widthAnchor constraintEqualToConstant:resolvedWidth], + [toolbarHost.heightAnchor constraintEqualToConstant:toolbarHeight], + ]]; + [chromeHostView bringSubviewToFront:toolbarHost]; + + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; + glassView.clipsToBounds = YES; + glassView.layer.cornerRadius = toolbarRadius; + glassView.translatesAutoresizingMaskIntoConstraints = NO; + [toolbarHost addSubview:glassView]; + BOOL hasFilterButton = + mailSearchToolbarConfig[@"filterButtonId"] != nil || mailSearchToolbarConfig[@"filterMenu"] != nil; + BOOL hasComposeButton = + mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil; + CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0; + CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0; + NSLayoutConstraint *glassLeadingConstraint = + [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset]; + NSLayoutConstraint *glassTrailingConstraint = + [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset]; + [NSLayoutConstraint activateConstraints:@[ + glassLeadingConstraint, + glassTrailingConstraint, + [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor], + [glassView.heightAnchor constraintEqualToConstant:toolbarHeight], + ]]; + + void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) { + auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>( + config->_eventEmitter); + if (eventEmitter && buttonId) { + eventEmitter->onPressHeaderBarButtonItem( + facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{ + .buttonId = std::string([buttonId UTF8String])}); + } + }; +