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,479 @@ + (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])});
+ }
+ };
+
+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
+ config->_eventEmitter);
+ if (eventEmitter && menuId) {
+ eventEmitter->onPressHeaderBarButtonMenuItem(
+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
+ .menuId = std::string([menuId UTF8String])});
+ }
+ };
+
+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
+ configuration.baseForegroundColor = UIColor.labelColor;
+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
+ if (menuConfig != nil) {
+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
+ menuAction:emitMenuPress
+ imageLoader:config->_imageLoader];
+ button.showsMenuAsPrimaryAction = YES;
+ } else if (buttonId != nil) {
+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
+ emitButtonPress(buttonId);
+ }];
+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
+ }
+ return button;
+ };
+
+ BOOL useFallbackSearchField = [mailSearchToolbarConfig[@"useFallbackSearchField"] boolValue];
+ UISearchBar *searchBar =
+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
+ UITextField *resolvedSearchTextField = nil;
+ if (searchBar != nil) {
+ if (placeholder != nil) {
+ searchBar.placeholder = placeholder;
+ }
+ searchBar.hidden = NO;
+ searchBar.alpha = 1.0;
+ searchBar.userInteractionEnabled = YES;
+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
+ searchBar.backgroundImage = [UIImage new];
+ searchBar.searchTextField.hidden = NO;
+ searchBar.searchTextField.alpha = 1.0;
+ searchBar.searchTextField.backgroundColor = UIColor.clearColor;
+ searchBar.searchTextField.font = searchFont;
+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
+ searchBar.searchTextField.textColor = UIColor.labelColor;
+ searchBar.searchTextField.tintColor = UIColor.labelColor;
+ resolvedSearchTextField = searchBar.searchTextField;
+ if (placeholder != nil) {
+ searchBar.searchTextField.attributedPlaceholder =
+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
+ }
+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
+ [glassView.contentView addSubview:searchBar];
+ [NSLayoutConstraint activateConstraints:@[
+ [searchBar.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:8.0],
+ [searchBar.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-8.0],
+ [searchBar.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
+ [searchBar.heightAnchor constraintEqualToConstant:searchFieldHeight],
+ ]];
+ } else {
+ UISearchTextField *searchField = [UISearchTextField new];
+ if (placeholder != nil) {
+ searchField.placeholder = placeholder;
+ searchField.attributedPlaceholder =
+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
+ }
+ searchField.borderStyle = UITextBorderStyleNone;
+ searchField.font = searchFont;
+ searchField.adjustsFontForContentSizeCategory = YES;
+ searchField.textColor = UIColor.labelColor;
+ searchField.tintColor = UIColor.labelColor;
+ resolvedSearchTextField = searchField;
+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
+ [glassView.contentView addSubview:searchField];
+ [NSLayoutConstraint activateConstraints:@[
+ [searchField.leadingAnchor constraintEqualToAnchor:glassView.contentView.leadingAnchor constant:14.0],
+ [searchField.trailingAnchor constraintEqualToAnchor:glassView.contentView.trailingAnchor constant:-14.0],
+ [searchField.centerYAnchor constraintEqualToAnchor:glassView.contentView.centerYAnchor],
+ [searchField.heightAnchor constraintEqualToConstant:searchFieldHeight],
+ ]];
+ }
+
+ // A bare UISearchTextField keeps UITextField defaults (sentence
+ // capitalization, autocorrect), unlike UISearchBar. Search queries are
+ // identifiers, so match the autoCapitalize "none" the JS fallbacks use.
+ if (resolvedSearchTextField != nil) {
+ resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone;
+ resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo;
+ resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo;
+ }
+
+ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"];
+ NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change";
+ if (resolvedSearchTextField != nil) {
+ [resolvedSearchTextField removeActionForIdentifier:searchTextChangeActionIdentifier
+ forControlEvents:UIControlEventEditingChanged];
+ }
+ if (searchTextChangeId != nil && resolvedSearchTextField != nil) {
+ [resolvedSearchTextField
+ addAction:[UIAction actionWithTitle:@""
+ image:nil
+ identifier:searchTextChangeActionIdentifier
+ handler:^(__kindof UIAction *_Nonnull action) {
+ UITextField *field = (UITextField *)action.sender;
+ emitButtonPress(
+ [NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]);
+ }]
+ forControlEvents:UIControlEventEditingChanged];
+ }
+
+ UIButton *filterButton = nil;
+ if (hasFilterButton) {
+ filterButton = makeGlassButton(
+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
+ mailSearchToolbarConfig[@"filterButtonId"],
+ mailSearchToolbarConfig[@"filterMenu"]);
+ filterButton.translatesAutoresizingMaskIntoConstraints = NO;
+ [toolbarHost addSubview:filterButton];
+ [NSLayoutConstraint activateConstraints:@[
+ [filterButton.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor],
+ [filterButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
+ [filterButton.widthAnchor constraintEqualToConstant:buttonSize],
+ [filterButton.heightAnchor constraintEqualToConstant:buttonSize],
+ ]];
+ }
+
+ UIButton *composeButton = nil;
+ if (hasComposeButton) {
+ composeButton = makeGlassButton(
+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
+ mailSearchToolbarConfig[@"composeButtonId"],
+ mailSearchToolbarConfig[@"composeMenu"]);
+ composeButton.translatesAutoresizingMaskIntoConstraints = NO;
+ [toolbarHost addSubview:composeButton];
+ [NSLayoutConstraint activateConstraints:@[
+ [composeButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
+ [composeButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
+ [composeButton.widthAnchor constraintEqualToConstant:buttonSize],
+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
+ ]];
+ }
+
+ BOOL showsSearchDismissButton =
+ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil;
+ UIButton *searchDismissButton = nil;
+ if (showsSearchDismissButton) {
+ searchDismissButton = makeGlassButton(@"xmark", nil, nil);
+ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard";
+ searchDismissButton.alpha = 0.0;
+ searchDismissButton.hidden = YES;
+ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO;
+ [toolbarHost addSubview:searchDismissButton];
+ [NSLayoutConstraint activateConstraints:@[
+ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
+ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
+ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize],
+ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize],
+ ]];
+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
+ [searchDismissButton
+ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
+ [weakSearchTextField resignFirstResponder];
+ }]
+ forControlEvents:UIControlEventTouchUpInside];
+ }
+
+ __weak UIButton *weakFilterButton = filterButton;
+ __weak UIButton *weakComposeButton = composeButton;
+ __weak UIButton *weakSearchDismissButton = searchDismissButton;
+ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint;
+ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint;
+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
+ __weak UIView *weakToolbarHost = toolbarHost;
+ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
+ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) {
+ weakKeyboardAvoidConstraint.priority =
+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
+ weakRestingBottomConstraint.priority =
+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
+
+ if (showsSearchDismissButton) {
+ if (isEditing) {
+ weakSearchDismissButton.hidden = NO;
+ } else {
+ weakFilterButton.hidden = NO;
+ weakComposeButton.hidden = NO;
+ }
+ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset;
+ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset;
+
+ void (^changes)(void) = ^{
+ weakFilterButton.alpha = isEditing ? 0.0 : 1.0;
+ weakComposeButton.alpha = isEditing ? 0.0 : 1.0;
+ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0;
+ [weakToolbarHost layoutIfNeeded];
+ };
+ void (^completion)(BOOL) = ^(BOOL finished) {
+ if (!finished || weakSearchTextField.isFirstResponder != isEditing) {
+ return;
+ }
+ weakFilterButton.hidden = isEditing;
+ weakComposeButton.hidden = isEditing;
+ weakSearchDismissButton.hidden = !isEditing;
+ };
+ if (animated) {
+ [UIView animateWithDuration:0.2
+ delay:0.0
+ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut
+ animations:changes
+ completion:completion];
+ } else {
+ changes();
+ completion(YES);
+ }
+ }
+ [weakToolbarHost setNeedsLayout];
+ };
+
+ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO);
+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
+ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier
+ forControlEvents:UIControlEventEditingDidBegin];
+ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier
+ forControlEvents:UIControlEventEditingDidEnd];
+ [resolvedSearchTextField
+ addAction:[UIAction actionWithTitle:@""
+ image:nil
+ identifier:beginActionIdentifier
+ handler:^(__kindof UIAction *_Nonnull action) {
+ setSearchEditingAppearance(YES, YES);
+ }]
+ forControlEvents:UIControlEventEditingDidBegin];
+ [resolvedSearchTextField
+ addAction:[UIAction actionWithTitle:@""
+ image:nil
+ identifier:endActionIdentifier
+ handler:^(__kindof UIAction *_Nonnull action) {
+ setSearchEditingAppearance(NO, YES);
+ }]
+ forControlEvents:UIControlEventEditingDidEnd];
+ }
+#endif
+ }
+
+ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[];
+ // Same reuse rule as the header item groups above (including the config
+ // identity — cached toolbar items capture this config's event emitter).
+ // The top-view-controller and hidden-state checks scope the skip to
+ // same-screen refreshes, so transitions between screens with different
+ // toolbars still reapply.
+ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ];
+ BOOL reuseToolbarItems = navctr.topViewController == vc &&
+ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) &&
+ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey];
+ if (!reuseToolbarItems) {
+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
+ withCurrentItems:@[]
+ navigationItem:navitem
+ allowsSearchBarPlacement:YES];
+ if (toolbarItems.count > 0) {
+ vc.toolbarItems = toolbarItems;
+ [navctr setToolbarHidden:NO animated:animated];
+ } else {
+ vc.toolbarItems = nil;
+ [navctr setToolbarHidden:YES animated:animated];
+ }
+ objc_setAssociatedObject(
+ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ }
// Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
// (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
@@ -784,6 +1289,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
- (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
+ navigationItem:(UINavigationItem *)navitem
+ allowsSearchBarPlacement:(BOOL)allowsSearchBarPlacement
{
if (dicts.count == 0) {
return currentItems;
@@ -792,7 +1299,200 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
[items addObjectsFromArray:currentItems];
for (NSUInteger i = 0; i < dicts.count; i++) {
NSDictionary *dict = dicts[i];
- if (dict[@"buttonId"] || dict[@"menu"]) {
+ if (dict[@"mailSearchToolbar"]) {
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ NSNumber *width = dict[@"width"];
+ CGFloat resolvedWidth = width != nil ? width.doubleValue : MAX(320.0, [UIScreen mainScreen].bounds.size.width - 56.0);
+ CGFloat resolvedHeight = 58.0;
+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
+ container.translatesAutoresizingMaskIntoConstraints = NO;
+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
+
+ UIStackView *stackView = [[UIStackView alloc] initWithFrame:container.bounds];
+ stackView.axis = UILayoutConstraintAxisHorizontal;
+ stackView.alignment = UIStackViewAlignmentCenter;
+ stackView.distribution = UIStackViewDistributionFill;
+ stackView.spacing = 10.0;
+ stackView.translatesAutoresizingMaskIntoConstraints = NO;
+ [container addSubview:stackView];
+ [NSLayoutConstraint activateConstraints:@[
+ [stackView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
+ [stackView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
+ [stackView.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
+ [stackView.heightAnchor constraintEqualToConstant:50.0],
+ ]];
+
+ void (^emitButtonPress)(NSString *) = ^(NSString *buttonId) {
+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
+ self->_eventEmitter);
+ if (eventEmitter && buttonId) {
+ eventEmitter->onPressHeaderBarButtonItem(
+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonItem{
+ .buttonId = std::string([buttonId UTF8String])});
+ }
+ };
+
+ void (^emitMenuPress)(NSString *) = ^(NSString *menuId) {
+ auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
+ self->_eventEmitter);
+ if (eventEmitter && menuId) {
+ eventEmitter->onPressHeaderBarButtonMenuItem(
+ facebook::react::RNSScreenStackHeaderConfigEventEmitter::OnPressHeaderBarButtonMenuItem{
+ .menuId = std::string([menuId UTF8String])});
+ }
+ };
+
+ UIButton *(^makeGlassButton)(NSString *, NSString *, NSDictionary *) =
+ ^UIButton *(NSString *systemImageName, NSString *buttonId, NSDictionary *menuConfig) {
+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
+ configuration.image = [UIImage systemImageNamed:systemImageName ?: @"circle"];
+
+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
+ button.translatesAutoresizingMaskIntoConstraints = NO;
+ [button.widthAnchor constraintEqualToConstant:58.0].active = YES;
+ [button.heightAnchor constraintEqualToConstant:50.0].active = YES;
+ if (menuConfig != nil) {
+ button.menu = [RNSBarButtonItem initUIMenuWithDict:menuConfig
+ menuAction:emitMenuPress
+ imageLoader:self->_imageLoader];
+ button.showsMenuAsPrimaryAction = YES;
+ } else if (buttonId != nil) {
+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
+ emitButtonPress(buttonId);
+ }];
+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
+ }
+ return button;
+ };
+
+ BOOL hasFilterButton = dict[@"filterButtonId"] != nil || dict[@"filterMenu"] != nil;
+ BOOL hasComposeButton = dict[@"composeButtonId"] != nil || dict[@"composeMenu"] != nil;
+
+ if (hasFilterButton) {
+ UIButton *filterButton = makeGlassButton(
+ dict[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
+ dict[@"filterButtonId"],
+ dict[@"filterMenu"]);
+ [stackView addArrangedSubview:filterButton];
+ }
+
+ UISearchBar *searchBar = navitem.searchController != nil ? navitem.searchController.searchBar : [UISearchBar new];
+ NSString *placeholder = dict[@"placeholder"];
+ if (placeholder != nil) {
+ searchBar.placeholder = placeholder;
+ }
+ searchBar.searchBarStyle = UISearchBarStyleMinimal;
+ searchBar.translatesAutoresizingMaskIntoConstraints = NO;
+ [searchBar.heightAnchor constraintEqualToConstant:50.0].active = YES;
+ [stackView addArrangedSubview:searchBar];
+
+ if (hasComposeButton) {
+ UIButton *composeButton = makeGlassButton(
+ dict[@"composeSystemImageName"] ?: @"square.and.pencil",
+ dict[@"composeButtonId"],
+ dict[@"composeMenu"]);
+ [stackView addArrangedSubview:composeButton];
+ }
+
+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
+ [items addObject:item];
+ }
+#endif
+ } else if (dict[@"searchField"]) {
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ NSNumber *width = dict[@"width"];
+ CGFloat resolvedWidth = width != nil ? width.doubleValue : 280.0;
+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, 44.0)];
+ container.translatesAutoresizingMaskIntoConstraints = NO;
+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
+ [container.heightAnchor constraintEqualToConstant:44.0].active = YES;
+
+ UISearchTextField *searchField = [UISearchTextField new];
+ NSString *placeholder = dict[@"placeholder"];
+ if (placeholder != nil) {
+ searchField.placeholder = placeholder;
+ }
+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
+ [container addSubview:searchField];
+ [NSLayoutConstraint activateConstraints:@[
+ [searchField.leadingAnchor constraintEqualToAnchor:container.leadingAnchor],
+ [searchField.trailingAnchor constraintEqualToAnchor:container.trailingAnchor],
+ [searchField.centerYAnchor constraintEqualToAnchor:container.centerYAnchor],
+ [searchField.heightAnchor constraintEqualToConstant:40.0],
+ ]];
+
+ UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:container];
+ [items addObject:item];
+ }
+#endif
+ } else if (dict[@"searchBarPlacement"]) {
+ if (!allowsSearchBarPlacement) {
+ continue;
+ }
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ NSNumber *width = dict[@"width"];
+ UIBarButtonItem *item = nil;
+ if ([dict[@"activatesSearchController"] boolValue]) {
+ UIButtonConfiguration *configuration = [UIButtonConfiguration glassButtonConfiguration];
+ configuration.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
+ configuration.image = [UIImage systemImageNamed:@"magnifyingglass"];
+ configuration.baseForegroundColor = UIColor.labelColor;
+ UIButton *button = [UIButton buttonWithConfiguration:configuration primaryAction:nil];
+ button.translatesAutoresizingMaskIntoConstraints = NO;
+ [button.widthAnchor constraintEqualToConstant:56.0].active = YES;
+ [button.heightAnchor constraintEqualToConstant:56.0].active = YES;
+ UIAction *action = [UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
+ if (navitem.searchController != nil) {
+ [navitem.searchController setActive:YES];
+ [navitem.searchController.searchBar becomeFirstResponder];
+ }
+ }];
+ [button addAction:action forControlEvents:UIControlEventTouchUpInside];
+ item = [[UIBarButtonItem alloc] initWithCustomView:button];
+ } else if ([dict[@"customView"] boolValue] && navitem.searchController != nil) {
+ UISearchBar *searchBar = navitem.searchController.searchBar;
+ CGFloat resolvedWidth = width != nil ? width.doubleValue : searchBar.frame.size.width;
+ CGFloat resolvedHeight = searchBar.frame.size.height > 0 ? searchBar.frame.size.height : 44.0;
+ UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, resolvedWidth, resolvedHeight)];
+ container.translatesAutoresizingMaskIntoConstraints = NO;
+ [container.widthAnchor constraintEqualToConstant:resolvedWidth].active = YES;
+ [container.heightAnchor constraintEqualToConstant:resolvedHeight].active = YES;
+ searchBar.frame = container.bounds;
+ searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ [container addSubview:searchBar];
+ item = [[UIBarButtonItem alloc] initWithCustomView:container];
+ } else {
+ item = navitem.searchBarPlacementBarButtonItem;
+ if (width != nil) {
+ item.width = width.doubleValue;
+ }
+ }
+ NSNumber *index = dict[@"index"];
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ NSNumber *sharesBackground = dict[@"sharesBackground"];
+ if (sharesBackground != nil) {
+ item.sharesBackground = sharesBackground.boolValue;
+ }
+ NSNumber *hidesSharedBackground = dict[@"hidesSharedBackground"];
+ if (hidesSharedBackground != nil) {
+ item.hidesSharedBackground = hidesSharedBackground.boolValue;
+ }
+ }
+#endif
+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
+ [items insertObject:item atIndex:index.integerValue];
+ } else {
+ [items addObject:item];
+ }
+ }
+#endif
+ } else if (dict[@"buttonId"] || dict[@"menu"]) {
RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
action:^(NSString *buttonId) {
auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
@@ -814,19 +1514,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
}
imageLoader:_imageLoader];
NSNumber *index = dict[@"index"];
- if (index.integerValue < items.count) {
+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
[items insertObject:item atIndex:index.integerValue];
} else {
[items addObject:item];
}
} else if (dict[@"spacing"]) {
- UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace
- target:nil
- action:nil];
+ BOOL flexible = [dict[@"flexible"] boolValue];
+ UIBarButtonItem *item = [[UIBarButtonItem alloc]
+ initWithBarButtonSystemItem:(flexible ? UIBarButtonSystemItemFlexibleSpace : UIBarButtonSystemItemFixedSpace)
+ target:nil
+ action:nil];
NSNumber *spacingValue = dict[@"spacing"];
- item.width = [spacingValue doubleValue];
+ if (!flexible) {
+ item.width = [spacingValue doubleValue];
+ }
NSNumber *index = dict[@"index"];
- if (index.integerValue < items.count) {
+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
[items insertObject:item atIndex:index.integerValue];
} else {
[items addObject:item];
@@ -836,6 +1540,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
return items;
}
+#if !TARGET_OS_TV
+- (NSArray<UIBarButtonItemGroup *> *)barButtonItemGroupsFromItems:(NSArray<UIBarButtonItem *> *)items
+{
+ if (items.count == 0) {
+ return @[];
+ }
+
+ NSMutableArray<UIBarButtonItemGroup *> *groups = [NSMutableArray array];
+ NSMutableArray<UIBarButtonItem *> *sharedRun = [NSMutableArray array];
+
+ void (^flushSharedRun)(void) = ^{
+ if (sharedRun.count == 0) {
+ return;
+ }
+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil
+ items:[sharedRun copy]]];
+ [sharedRun removeAllObjects];
+ };
+
+ for (UIBarButtonItem *item in items) {
+ BOOL shouldShareBackground = NO;
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ shouldShareBackground = item.sharesBackground && !item.hidesSharedBackground;
+ }
+#endif
+
+ if (shouldShareBackground) {
+ [sharedRun addObject:item];
+ continue;
+ }
+
+ flushSharedRun();
+ [groups addObject:[UIBarButtonItemGroup fixedGroupWithRepresentativeItem:nil items:@[ item ]]];
+ }
+
+ flushSharedRun();
+ return groups;
+}
+#endif
+
RNS_IGNORE_SUPER_CALL_BEGIN
- (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
{
@@ -1023,6 +1768,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
}
_title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
+ _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle);
+ _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle);
if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
_titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
}
@@ -1048,6 +1795,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
_disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
_backButtonDisplayMode =
[RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
+ _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle);
if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
_userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
@@ -1094,6 +1842,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
_headerRightBarButtonItems = array;
}
+ if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) {
+ const auto &vec = newScreenProps.headerCenterBarButtonItems;
+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
+ for (const auto &item : vec) {
+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
+ if ([dict isKindOfClass:[NSDictionary class]]) {
+ [array addObject:dict];
+ }
+ }
+ _headerCenterBarButtonItems = array;
+ }
+
+ if (newScreenProps.headerToolbarItems != oldScreenProps.headerToolbarItems) {
+ const auto &vec = newScreenProps.headerToolbarItems;
+ NSMutableArray<NSDictionary<NSString *, id> *> *array = [NSMutableArray arrayWithCapacity:vec.size()];
+ for (const auto &item : vec) {
+ NSDictionary *dict = [RNSConvert idFromFollyDynamic:item];
+ if ([dict isKindOfClass:[NSDictionary class]]) {
+ [array addObject:dict];
+ }
+ }
+ _headerToolbarItems = array;
+ }
+
[self updateViewControllerIfNeeded];
if (needsNavigationControllerLayout) {
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -1074,6 +1074,15 @@ RNS_IGNORE_SUPER_CALL_END
return scrollView.contentSize.width > scrollView.frame.size.width;
}
+// Whether a horizontally scrollable ScrollView is resting at its leading edge, so a
+// pan in the back-gesture direction has nothing left to scroll. Checked in the
+// ScrollView's own coordinate space: React Native mirrors the view for RTL layout,
+// which keeps the leading edge at the minimum content offset in both directions.
+- (BOOL)scrollViewIsAtLeadingEdge:(UIScrollView *)scrollView
+{
+ return scrollView.contentOffset.x <= -scrollView.adjustedContentInset.left + 1.0;
+}
+
// Custom method for compatibility with iOS < 13.4
// RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers:
// RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer
@@ -1167,9 +1176,11 @@ RNS_IGNORE_SUPER_CALL_END
if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
[self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
// ScrollView should take precedence when scrolling horizontally (it should be required to fail).
- // However, if it does not allow for horizontal scrolling, there should be no such restriction,
+ // However, if it does not allow for horizontal scrolling, or is already resting at its leading
+ // edge so a back swipe has nothing left to scroll, there should be no such restriction,
// and swiping horizontally should dismiss the screen.
- return [self scrollViewHasHorizontalPart:static_cast<UIScrollView *>(otherGestureRecognizer.view)];
+ UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view);
+ return [self scrollViewHasHorizontalPart:scrollView] && ![self scrollViewIsAtLeadingEdge:scrollView];
}
}
@@ -1189,6 +1200,15 @@ RNS_IGNORE_SUPER_CALL_END
// gestureRecognizer:shouldRequireFailureOfGestureRecognizer
isEdgeSwipeGestureRecognizer =
isEdgeSwipeGestureRecognizer && gestureRecognizer != _controller.interactiveContentPopGestureRecognizer;
+
+ // The exception is a horizontal ScrollView resting at its leading edge: its pan recognizer would
+ // still begin (and merely bounce) on a back swipe, so it must yield to the pop gesture the same
+ // way plain content does.
+ if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
+ [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) {
+ UIScrollView *scrollView = static_cast<UIScrollView *>(otherGestureRecognizer.view);
+ return [self scrollViewHasHorizontalPart:scrollView] && [self scrollViewIsAtLeadingEdge:scrollView];
+ }
}
#endif // check for iOS >= 26
diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm
index add33c4807e038dcd846f6c72b2fc472ded02809..489afa5fa2da09c1ec6986d1832b58bb595396ae 100644
--- a/ios/RNSScreenStackHeaderSubview.mm
+++ b/ios/RNSScreenStackHeaderSubview.mm
@@ -20,7 +20,9 @@ @implementation RNSScreenStackHeaderSubview {
// This is a strong reference to UIBarButtonItem which creates a retain cycle.
// The cycle is cleared via `invalidateUIBarButtonItem` method, called by `invalidate` callback.
UIBarButtonItem *_barButtonItem;
+ NSString *_barButtonItemIdentifier;
BOOL _hidesSharedBackground;
+ UILabel *_titleScrollEdgeEffectGuide;
}
#pragma mark - Common
@@ -102,5 +104,24 @@ - (void)updateShadowStateWithFrame:(CGRect)frame
- (void)layoutSubviews
{
[super layoutSubviews];
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ if (_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) {
+ // UIKit shapes the navigation bar's scroll-edge fade around native labels,
+ // but does not recognize Fabric's custom text views. An empty, non-interactive
+ // label supplies the title's geometry without drawing or becoming a bar item.
+ if (_titleScrollEdgeEffectGuide == nil) {
+ _titleScrollEdgeEffectGuide = [UILabel new];
+ _titleScrollEdgeEffectGuide.accessibilityElementsHidden = YES;
+ // Append after Fabric's children so their mount/unmount indices stay intact.
+ [self addSubview:_titleScrollEdgeEffectGuide];
+ }
+ _titleScrollEdgeEffectGuide.frame = self.bounds;
+ } else {
+ [_titleScrollEdgeEffectGuide removeFromSuperview];
+ _titleScrollEdgeEffectGuide = nil;
+ }
+ }
+#endif
[self updateShadowStateInContextOfAncestorView:[self findNavigationBar]];
}
@@ -124,6 +145,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
const auto &newHeaderSubviewProps = *std::static_pointer_cast<const react::RNSScreenStackHeaderSubviewProps>(props);
[self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]];
+ [self setBarButtonItemIdentifier:RCTNSStringFromStringNilIfEmpty(newHeaderSubviewProps.identifier)];
[self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground];
[self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled];
[super updateProps:props oldProps:oldProps];
@@ -276,7 +298,14 @@ - (void)configureBarButtonItem
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
if (@available(iOS 26.0, *)) {
if (_barButtonItem != nil) {
- [_barButtonItem setHidesSharedBackground:_hidesSharedBackground];
+ NSString *currentIdentifier = _barButtonItem.identifier;
+ if (currentIdentifier != _barButtonItemIdentifier &&
+ ![currentIdentifier isEqualToString:_barButtonItemIdentifier]) {
+ _barButtonItem.identifier = _barButtonItemIdentifier;
+ }
+ if (_barButtonItem.hidesSharedBackground != _hidesSharedBackground) {
+ _barButtonItem.hidesSharedBackground = _hidesSharedBackground;
+ }
}
}
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
@@ -284,10 +313,22 @@ - (void)configureBarButtonItem
- (void)setHidesSharedBackground:(BOOL)hidesSharedBackground
{
+ if (_hidesSharedBackground == hidesSharedBackground) {
+ return;
+ }
_hidesSharedBackground = hidesSharedBackground;
[self configureBarButtonItem];
}
+- (void)setBarButtonItemIdentifier:(nullable NSString *)identifier
+{
+ if (_barButtonItemIdentifier == identifier || [_barButtonItemIdentifier isEqualToString:identifier]) {
+ return;
+ }
+ _barButtonItemIdentifier = [identifier copy];
+ [self configureBarButtonItem];
+}
+
#pragma mark - Dynamic frameworks support
// Needed because of this: https://github.com/facebook/react-native/pull/37274
diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js
index 8c509f2657105cfd86448347c1a01fb73651ff9e..e668e089a1788973cb122a150be7846364a2a2cf 100644
--- a/lib/commonjs/components/ScreenStackHeaderConfig.js
+++ b/lib/commonjs/components/ScreenStackHeaderConfig.js
@@ -26,17 +26,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
} = (0, _EdgeInsetApplicationContext.useEdgeInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
const {
headerLeftBarButtonItems,
- headerRightBarButtonItems
+ headerRightBarButtonItems,
+ headerCenterBarButtonItems,
+ headerToolbarItems
} = props;
const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined;
const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined;
- const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'center') : undefined;
+ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'toolbar') : undefined;
+ const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
// Handle bar button item presses
const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
+ const buttonId = event.nativeEvent.buttonId;
+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
pressedItem.onPress();
+ return;
+ }
+ for (const item of allItems) {
+ if (!item || item.type !== 'mailSearchToolbar') {
+ continue;
+ }
+ if (item.filterButtonId === buttonId) {
+ item.onFilterPress?.();
+ return;
+ }
+ if (item.composeButtonId === buttonId) {
+ item.onComposePress?.();
+ return;
+ }
+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
+ return;
+ }
}
} : undefined;
@@ -59,7 +84,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
};
// Check each bar-button item with a menu
- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
for (const item of allItems) {
if (item && item.type === 'menu' && item.menu) {
const action = findInMenu(item.menu, event.nativeEvent.menuId);
@@ -67,6 +92,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
action.onPress();
return;
}
+ } else if (item && item.type === 'mailSearchToolbar') {
+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
+ for (const toolbarMenu of toolbarMenus) {
+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
+ if (action) {
+ action.onPress();
+ return;
+ }
+ }
}
}
} : undefined;
@@ -74,6 +108,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_
userInterfaceStyle: props.experimental_userInterfaceStyle,
headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
+ headerToolbarItems: preparedHeaderToolbarItems,
onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
ref: ref,
diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..9dca2ab640e92161eb647b819a989acd0d41d9fe 100644
--- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
+++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js
@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", {
});
exports.prepareHeaderBarButtonItems = void 0;
var _reactNative = require("react-native");
-const prepareMenu = (menu, index, side, path = '') => {
+const prepareMenu = (menu, index, placement, path = '') => {
return {
...menu,
items: menu.items.map((menuItem, menuIndex) => {
@@ -26,7 +26,7 @@ const prepareMenu = (menu, index, side, path = '') => {
xcassetName,
imageSource,
templateSource,
- ...prepareMenu(menuItem, index, side, currentPath)
+ ...prepareMenu(menuItem, index, placement, currentPath)
};
}
return {
@@ -35,16 +35,40 @@ const prepareMenu = (menu, index, side, path = '') => {
xcassetName,
imageSource,
templateSource,
- menuId: `${currentPath}-${index}-${side}`
+ menuId: `${currentPath}-${index}-${placement}`
};
})
};
};
-const prepareHeaderBarButtonItems = (barButtonItems, side) => {
- return barButtonItems?.map((item, index) => {
+const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
+ return items?.map((item, index) => {
if (item.type === 'spacing') {
return item;
}
+ if (item.type === 'searchBarPlacement') {
+ if (placement !== 'toolbar') {
+ return null;
+ }
+ return {
+ ...item,
+ searchBarPlacement: true
+ };
+ }
+ if (item.type === 'searchField') {
+ return {
+ ...item,
+ searchField: true
+ };
+ }
+ if (item.type === 'mailSearchToolbar') {
+ return {
+ ...item,
+ mailSearchToolbar: true,
+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
+ };
+ }
let imageSource, templateSource;
if (item.icon?.type === 'imageSource') {
imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource);
@@ -77,17 +101,17 @@ const prepareHeaderBarButtonItems = (barButtonItems, side) => {
if (item.type === 'button') {
return {
...processedItem,
- buttonId: `${index}-${side}`
+ buttonId: `${index}-${placement}`
};
}
if (item.type === 'menu') {
return {
...processedItem,
- menu: prepareMenu(item.menu, index, side)
+ menu: prepareMenu(item.menu, index, placement)
};
}
return null;
- });
+ }).filter(item => item !== null);
};
exports.prepareHeaderBarButtonItems = prepareHeaderBarButtonItems;
//# sourceMappingURL=prepareHeaderBarButtonItems.js.map
\ No newline at end of file
diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js
index 68e3ba6a78314aba908d07098bb6982d11e6ee80..db5d17bfdac19ed3e664b55b6a73ce49766ac7c5 100644
--- a/lib/module/components/ScreenStackHeaderConfig.js
+++ b/lib/module/components/ScreenStackHeaderConfig.js
@@ -22,17 +22,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
} = useEdgeInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false);
const {
headerLeftBarButtonItems,
- headerRightBarButtonItems
+ headerRightBarButtonItems,
+ headerCenterBarButtonItems,
+ headerToolbarItems
} = props;
const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined;
const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined;
- const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length);
+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') : undefined;
+ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') : undefined;
+ const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length);
// Handle bar button item presses
const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => {
- const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId);
+ const buttonId = event.nativeEvent.buttonId;
+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
+ const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId);
if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) {
pressedItem.onPress();
+ return;
+ }
+ for (const item of allItems) {
+ if (!item || item.type !== 'mailSearchToolbar') {
+ continue;
+ }
+ if (item.filterButtonId === buttonId) {
+ item.onFilterPress?.();
+ return;
+ }
+ if (item.composeButtonId === buttonId) {
+ item.onComposePress?.();
+ return;
+ }
+ const searchTextChangePrefix = item.searchTextChangeId ? `${item.searchTextChangeId}:` : undefined;
+ if (searchTextChangePrefix && buttonId.startsWith(searchTextChangePrefix)) {
+ item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length));
+ return;
+ }
}
} : undefined;
@@ -55,7 +80,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
};
// Check each bar-button item with a menu
- const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])];
+ const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])];
for (const item of allItems) {
if (item && item.type === 'menu' && item.menu) {
const action = findInMenu(item.menu, event.nativeEvent.menuId);
@@ -63,6 +88,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
action.onPress();
return;
}
+ } else if (item && item.type === 'mailSearchToolbar') {
+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(Boolean);
+ for (const toolbarMenu of toolbarMenus) {
+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
+ if (action) {
+ action.onPress();
+ return;
+ }
+ }
}
}
} : undefined;
@@ -70,6 +104,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref
userInterfaceStyle: props.experimental_userInterfaceStyle,
headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems,
headerRightBarButtonItems: preparedHeaderRightBarButtonItems,
+ headerCenterBarButtonItems: preparedHeaderCenterBarButtonItems,
+ headerToolbarItems: preparedHeaderToolbarItems,
onPressHeaderBarButtonItem: onPressHeaderBarButtonItem,
onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem,
ref: ref,
diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
index 8a70ffd78617147418d628c03c580bb7ff9a8a72..bca930372d2a7c3f8e0a35c3dcf28474f899f21c 100644
--- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js
+++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js
@@ -1,5 +1,5 @@
import { Image, processColor } from 'react-native';
-const prepareMenu = (menu, index, side, path = '') => {
+const prepareMenu = (menu, index, placement, path = '') => {
return {
...menu,
items: menu.items.map((menuItem, menuIndex) => {
@@ -20,7 +20,7 @@ const prepareMenu = (menu, index, side, path = '') => {
xcassetName,
imageSource,
templateSource,
- ...prepareMenu(menuItem, index, side, currentPath)
+ ...prepareMenu(menuItem, index, placement, currentPath)
};
}
return {
@@ -29,16 +29,40 @@ const prepareMenu = (menu, index, side, path = '') => {
xcassetName,
imageSource,
templateSource,
- menuId: `${currentPath}-${index}-${side}`
+ menuId: `${currentPath}-${index}-${placement}`
};
})
};
};
-export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
- return barButtonItems?.map((item, index) => {
+export const prepareHeaderBarButtonItems = (barButtonItems, placement) => {
+ const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined;
+ return items?.map((item, index) => {
if (item.type === 'spacing') {
return item;
}
+ if (item.type === 'searchBarPlacement') {
+ if (placement !== 'toolbar') {
+ return null;
+ }
+ return {
+ ...item,
+ searchBarPlacement: true
+ };
+ }
+ if (item.type === 'searchField') {
+ return {
+ ...item,
+ searchField: true
+ };
+ }
+ if (item.type === 'mailSearchToolbar') {
+ return {
+ ...item,
+ mailSearchToolbar: true,
+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined
+ };
+ }
let imageSource, templateSource;
if (item.icon?.type === 'imageSource') {
imageSource = Image.resolveAssetSource(item.icon.imageSource);
@@ -71,16 +95,16 @@ export const prepareHeaderBarButtonItems = (barButtonItems, side) => {
if (item.type === 'button') {
return {
...processedItem,
- buttonId: `${index}-${side}`
+ buttonId: `${index}-${placement}`
};
}
if (item.type === 'menu') {
return {
...processedItem,
- menu: prepareMenu(item.menu, index, side)
+ menu: prepareMenu(item.menu, index, placement)
};
}
return null;
- });
+ }).filter(item => item !== null);
};
//# sourceMappingURL=prepareHeaderBarButtonItems.js.map
\ No newline at end of file
diff --git a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
index c41ef943e554754f599f2fcd157dd0dc61d78b7e..ed02d44f78ee3f1387ba93bf292be2b99bb0e3b3 100644
--- a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
+++ b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts
@@ -1,5 +1,5 @@
import { HeaderBarButtonItem } from 'react-native-screens/types';
-export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right") => (import("react-native-screens/types").HeaderBarButtonItemSpacing | {
+export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right" | "center" | "toolbar") => (import("react-native-screens/types").HeaderBarButtonItemSpacing | {
buttonId: string;
imageSource: import("react-native").ImageResolvedAssetSource | undefined;
templateSource: import("react-native").ImageResolvedAssetSource | undefined;
@@ -161,5 +161,5 @@ export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButt
identifier?: string | undefined;
accessibilityLabel?: string | undefined;
accessibilityHint?: string | undefined;
-} | null)[];
+})[];
//# sourceMappingURL=prepareHeaderBarButtonItems.d.ts.map
\ No newline at end of file
diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
index a51d8648567cff29cd2be0d5262bd15c6fc69c61..8b7439aff4d7e6fe8a72bf0bac93c3c19568b1a6 100644
--- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
+++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts
@@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{
menuId: string;
}>;
type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
type BlurEffect = 'none' | 'extraLight' | 'light' | 'dark' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
type UserInterfaceStyle = 'unspecified' | 'light' | 'dark';
export interface NativeProps extends ViewProps {
@@ -32,18 +33,23 @@ export interface NativeProps extends ViewProps {
largeTitleColor?: ColorValue | undefined;
translucent?: boolean | undefined;
title?: string | undefined;
+ subtitle?: string | undefined;
+ largeSubtitle?: string | undefined;
titleFontFamily?: string | undefined;
titleFontSize?: CT.Int32 | undefined;
titleFontWeight?: string | undefined;
titleColor?: ColorValue | undefined;
disableBackButtonMenu?: boolean | undefined;
backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
hideBackButton?: boolean | undefined;
backButtonInCustomView?: boolean | undefined;
blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
topInsetEnabled?: boolean | undefined;
headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
onPressHeaderBarButtonItem?: CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent> | undefined;
onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler<OnPressHeaderBarButtonMenuItemEvent> | undefined;
synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
diff --git a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
index 1ca926e6753294861f5f79ab6267dc7999f70639..94e676fdb08ff9d1cf81a1cf06a45e931b5461f1 100644
--- a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
+++ b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts
@@ -3,6 +3,7 @@ export type HeaderSubviewTypes = 'back' | 'right' | 'left' | 'title' | 'center'
export interface NativeProps extends ViewProps {
type?: CT.WithDefault<HeaderSubviewTypes, 'left'>;
hidesSharedBackground?: boolean | undefined;
+ identifier?: string | undefined;
synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
}
declare const _default: import("react-native").HostComponent<NativeProps>;
diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts
index 10a31341f4eff258a7f8ea6abae3eddf0dd21218..bab5031f5f9b246f15e5396f3f2fee90b91d826a 100644
--- a/lib/typescript/types.d.ts
+++ b/lib/typescript/types.d.ts
@@ -10,6 +10,7 @@ export type SearchBarCommands = {
cancelSearch: () => void;
};
export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
export type StackPresentationTypes = 'push' | 'modal' | 'transparentModal' | 'containedModal' | 'containedTransparentModal' | 'fullScreenModal' | 'formSheet' | 'pageSheet';
export type StackAnimationTypes = 'default' | 'fade' | 'fade_from_bottom' | 'flip' | 'none' | 'simple_push' | 'slide_from_bottom' | 'slide_from_right' | 'slide_from_left' | 'ios_from_right' | 'ios_from_left';
export type BlurEffectTypes = BlurEffect;
@@ -631,6 +632,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* @platform ios
*/
backButtonDisplayMode?: BackButtonDisplayMode | undefined;
+ /**
+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
+ *
+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
+ *
+ * @platform ios
+ */
+ navigationItemStyle?: NavigationItemStyle | undefined;
/**
* Array of UIBarButtomItems to the left side of the header.
*
@@ -643,6 +652,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* @platform ios
*/
headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
+ /**
+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
+ *
+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
+ * document/editor-style chrome keep primary actions visually centered while
+ * leaving trailing search separate.
+ *
+ * @platform ios
+ */
+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
+ /**
+ * Array of UIBarButtonItems to display in the native toolbar attached to
+ * the current screen's navigation controller.
+ *
+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
+ * render integrated Mail-style search chrome using the attached SearchBar.
+ *
+ * @platform ios
+ */
+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
/**
* When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
*/
@@ -704,6 +733,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* String that can be displayed in the header as a fallback for `headerTitle`.
*/
title?: string | undefined;
+ /**
+ * String displayed below the compact navigation title on iOS 26+.
+ *
+ * @platform ios
+ */
+ subtitle?: string | undefined;
+ /**
+ * String displayed below the large navigation title on iOS 26+.
+ *
+ * Falls back to `subtitle` when omitted.
+ *
+ * @platform ios
+ */
+ largeSubtitle?: string | undefined;
/**
* Allows for setting text color of the title.
*/
@@ -999,6 +1042,13 @@ export interface SearchBarProps {
shouldShowHintSearchIcon?: boolean | undefined;
}
export interface ScreenStackHeaderSubviewProps {
+ /**
+ * An identifier used to match this item across navigation bar transitions.
+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
+ *
+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
+ */
+ identifier?: string | undefined;
/**
* A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
* Only applicable to type="right" and type="left" subviews.
@@ -1037,6 +1087,11 @@ interface SharedHeaderBarButtonItem {
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
*/
variant?: 'plain' | 'done' | 'prominent' | undefined;
+ /**
+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
+ * Only available from iOS 26.0 and later.
+ */
+ glassEffect?: boolean | undefined;
/**
* The tint color to apply to the item.
*
@@ -1181,8 +1236,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
export interface HeaderBarButtonItemSpacing {
type: 'spacing';
spacing: number;
+ flexible?: boolean | undefined;
+}
+export interface HeaderBarButtonSearchBarPlacementItem {
+ type: 'searchBarPlacement';
+ activatesSearchController?: boolean | undefined;
+ customView?: boolean | undefined;
+ index?: number | undefined;
+ width?: number | undefined;
+}
+export interface HeaderBarButtonSearchFieldItem {
+ type: 'searchField';
+ placeholder?: string | undefined;
+ width?: number | undefined;
+}
+export interface HeaderBarButtonMailSearchToolbarItem {
+ type: 'mailSearchToolbar';
+ composeButtonId?: string | undefined;
+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
+ composeSystemImageName?: string | undefined;
+ filterButtonId?: string | undefined;
+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
+ filterSystemImageName?: string | undefined;
+ onComposePress?: (() => void) | undefined;
+ onFilterPress?: (() => void) | undefined;
+ onSearchTextChange?: ((text: string) => void) | undefined;
+ placeholder?: string | undefined;
+ searchTextChangeId?: string | undefined;
+ showsSearchDismissButton?: boolean | undefined;
+ useFallbackSearchField?: boolean | undefined;
+ width?: number | undefined;
}
-export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonItemSpacing;
+export type HeaderBarButtonItem = HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu | HeaderBarButtonMailSearchToolbarItem | HeaderBarButtonSearchFieldItem | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing;
/**
* Custom Screen Transition
*/
diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx
index c3a88b6f6c8362cf717372a35cdc35a6b397bdd0..9e8173da567933fd3f6819fb26bf4a0e87640767 100644
--- a/src/components/ScreenStackHeaderConfig.tsx
+++ b/src/components/ScreenStackHeaderConfig.tsx
@@ -48,7 +48,12 @@ export const ScreenStackHeaderConfig = React.forwardRef<
props.disableBottomInsetApplication ?? false,
);
- const { headerLeftBarButtonItems, headerRightBarButtonItems } = props;
+ const {
+ headerLeftBarButtonItems,
+ headerRightBarButtonItems,
+ headerCenterBarButtonItems,
+ headerToolbarItems,
+ } = props;
const preparedHeaderLeftBarButtonItems =
headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
@@ -58,22 +63,36 @@ export const ScreenStackHeaderConfig = React.forwardRef<
headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right')
: undefined;
+ const preparedHeaderCenterBarButtonItems =
+ headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform
+ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center')
+ : undefined;
+ const preparedHeaderToolbarItems =
+ headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform
+ ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar')
+ : undefined;
const hasHeaderBarButtonItems =
isHeaderBarButtonsAvailableForCurrentPlatform &&
(preparedHeaderLeftBarButtonItems?.length ||
- preparedHeaderRightBarButtonItems?.length);
+ preparedHeaderRightBarButtonItems?.length ||
+ preparedHeaderCenterBarButtonItems?.length ||
+ preparedHeaderToolbarItems?.length);
// Handle bar button item presses
const onPressHeaderBarButtonItem = hasHeaderBarButtonItems
? (event: NativeSyntheticEvent<{ buttonId: string }>) => {
- const pressedItem = [
+ const buttonId = event.nativeEvent.buttonId;
+ const allItems = [
...(preparedHeaderLeftBarButtonItems ?? []),
...(preparedHeaderRightBarButtonItems ?? []),
- ].find(
+ ...(preparedHeaderCenterBarButtonItems ?? []),
+ ...(preparedHeaderToolbarItems ?? []),
+ ];
+ const pressedItem = allItems.find(
item =>
item &&
'buttonId' in item &&
- item.buttonId === event.nativeEvent.buttonId,
+ item.buttonId === buttonId,
);
if (
pressedItem &&
@@ -81,6 +100,32 @@ export const ScreenStackHeaderConfig = React.forwardRef<
pressedItem.onPress
) {
pressedItem.onPress();
+ return;
+ }
+ for (const item of allItems) {
+ if (!item || item.type !== 'mailSearchToolbar') {
+ continue;
+ }
+ if (item.filterButtonId === buttonId) {
+ item.onFilterPress?.();
+ return;
+ }
+ if (item.composeButtonId === buttonId) {
+ item.onComposePress?.();
+ return;
+ }
+ const searchTextChangePrefix = item.searchTextChangeId
+ ? `${item.searchTextChangeId}:`
+ : undefined;
+ if (
+ searchTextChangePrefix &&
+ buttonId.startsWith(searchTextChangePrefix)
+ ) {
+ item.onSearchTextChange?.(
+ buttonId.slice(searchTextChangePrefix.length),
+ );
+ return;
+ }
}
}
: undefined;
@@ -111,6 +156,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
const allItems = [
...(preparedHeaderLeftBarButtonItems ?? []),
...(preparedHeaderRightBarButtonItems ?? []),
+ ...(preparedHeaderCenterBarButtonItems ?? []),
+ ...(preparedHeaderToolbarItems ?? []),
];
for (const item of allItems) {
if (item && item.type === 'menu' && item.menu) {
@@ -119,6 +166,17 @@ export const ScreenStackHeaderConfig = React.forwardRef<
action.onPress();
return;
}
+ } else if (item && item.type === 'mailSearchToolbar') {
+ const toolbarMenus = [item.filterMenu, item.composeMenu].filter(
+ Boolean,
+ );
+ for (const toolbarMenu of toolbarMenus) {
+ const action = findInMenu(toolbarMenu, event.nativeEvent.menuId);
+ if (action) {
+ action.onPress();
+ return;
+ }
+ }
}
}
}
@@ -130,6 +188,8 @@ export const ScreenStackHeaderConfig = React.forwardRef<
userInterfaceStyle={props.experimental_userInterfaceStyle}
headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems}
headerRightBarButtonItems={preparedHeaderRightBarButtonItems}
+ headerCenterBarButtonItems={preparedHeaderCenterBarButtonItems}
+ headerToolbarItems={preparedHeaderToolbarItems}
onPressHeaderBarButtonItem={onPressHeaderBarButtonItem}
onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem}
ref={ref}
diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts
index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..2008a342f13806641909c19a9f4ab6829f239016 100644
--- a/src/components/helpers/prepareHeaderBarButtonItems.ts
+++ b/src/components/helpers/prepareHeaderBarButtonItems.ts
@@ -7,7 +7,7 @@ import {
const prepareMenu = (
menu: HeaderBarButtonItemWithMenu['menu'],
index: number,
- side: 'left' | 'right',
+ placement: 'left' | 'right' | 'center' | 'toolbar',
path: string = '',
): HeaderBarButtonItemWithMenu['menu'] => {
return {
@@ -34,7 +34,7 @@ const prepareMenu = (
xcassetName,
imageSource,
templateSource,
- ...prepareMenu(menuItem, index, side, currentPath),
+ ...prepareMenu(menuItem, index, placement, currentPath),
};
}
return {
@@ -43,20 +43,53 @@ const prepareMenu = (
xcassetName,
imageSource,
templateSource,
- menuId: `${currentPath}-${index}-${side}`,
+ menuId: `${currentPath}-${index}-${placement}`,
};
}),
};
};
export const prepareHeaderBarButtonItems = (
- barButtonItems: HeaderBarButtonItem[],
- side: 'left' | 'right',
+ barButtonItems:
+ | HeaderBarButtonItem[]
+ | HeaderBarButtonItem
+ | null
+ | undefined,
+ placement: 'left' | 'right' | 'center' | 'toolbar',
) => {
- return barButtonItems?.map((item, index) => {
+ const items = Array.isArray(barButtonItems)
+ ? barButtonItems
+ : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems
+ ? [barButtonItems]
+ : undefined;
+
+ return items?.map((item, index) => {
if (item.type === 'spacing') {
return item;
}
+ if (item.type === 'searchBarPlacement') {
+ if (placement !== 'toolbar') {
+ return null;
+ }
+ return {
+ ...item,
+ searchBarPlacement: true,
+ };
+ }
+ if (item.type === 'searchField') {
+ return {
+ ...item,
+ searchField: true,
+ };
+ }
+ if (item.type === 'mailSearchToolbar') {
+ return {
+ ...item,
+ mailSearchToolbar: true,
+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined,
+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined,
+ };
+ }
let imageSource, templateSource;
if (item.icon?.type === 'imageSource') {
imageSource = Image.resolveAssetSource(item.icon.imageSource);
@@ -91,15 +124,15 @@ export const prepareHeaderBarButtonItems = (
if (item.type === 'button') {
return {
...processedItem,
- buttonId: `${index}-${side}`,
+ buttonId: `${index}-${placement}`,
};
}
if (item.type === 'menu') {
return {
...processedItem,
- menu: prepareMenu(item.menu, index, side),
+ menu: prepareMenu(item.menu, index, placement),
};
}
return null;
- });
+ }).filter(item => item !== null);
};
diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
index a80d9fef0c0ab5ce8221c5e0c7fae65162bc6164..fc16f51425702a418c09de553a54753331f19d15 100644
--- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
+++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts
@@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>;
type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>;
type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';
+type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
type BlurEffect =
| 'none'
@@ -61,12 +62,15 @@ export interface NativeProps extends ViewProps {
largeTitleColor?: ColorValue | undefined;
translucent?: boolean | undefined;
title?: string | undefined;
+ subtitle?: string | undefined;
+ largeSubtitle?: string | undefined;
titleFontFamily?: string | undefined;
titleFontSize?: CT.Int32 | undefined;
titleFontWeight?: string | undefined;
titleColor?: ColorValue | undefined;
disableBackButtonMenu?: boolean | undefined;
backButtonDisplayMode?: CT.WithDefault<BackButtonDisplayMode, 'default'>;
+ navigationItemStyle?: CT.WithDefault<NavigationItemStyle, 'navigator'>;
hideBackButton?: boolean | undefined;
backButtonInCustomView?: boolean | undefined;
blurEffect?: CT.WithDefault<BlurEffect, 'none'>;
@@ -74,6 +78,8 @@ export interface NativeProps extends ViewProps {
topInsetEnabled?: boolean | undefined;
headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined;
headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined;
+ headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined;
+ headerToolbarItems?: CT.UnsafeMixed[] | undefined;
onPressHeaderBarButtonItem?:
| CT.DirectEventHandler<OnPressHeaderBarButtonItemEvent>
| undefined;
diff --git a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
index 7d3fbfe08c70461936832a40a1ce8d9c11b22457..1f9caca6271b68d182b70a180f180d6a4ce3988c 100644
--- a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
+++ b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
@@ -14,6 +14,7 @@ export type HeaderSubviewTypes =
export interface NativeProps extends ViewProps {
type?: CT.WithDefault<HeaderSubviewTypes, 'left'>;
hidesSharedBackground?: boolean | undefined;
+ identifier?: string | undefined;
synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, true>;
}
diff --git a/src/types.tsx b/src/types.tsx
index 9c3ab4c3653ee1769addda8a6464cdf49695a601..6f242ad2bbd7152ea9f873ef7754ad47caf2466d 100644
--- a/src/types.tsx
+++ b/src/types.tsx
@@ -26,6 +26,7 @@ export type SearchBarCommands = {
};
export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
+export type NavigationItemStyle = 'navigator' | 'browser' | 'editor';
export type StackPresentationTypes =
| 'push'
| 'modal'
@@ -735,6 +736,14 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* @platform ios
*/
backButtonDisplayMode?: BackButtonDisplayMode | undefined;
+ /**
+ * Controls the UIKit `UINavigationItem.style` used by native iOS headers.
+ *
+ * `editor` matches modern document/editor-style bars with leading-aligned compact titles and glass controls.
+ *
+ * @platform ios
+ */
+ navigationItemStyle?: NavigationItemStyle | undefined;
/**
* Array of UIBarButtomItems to the left side of the header.
*
@@ -747,6 +756,26 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* @platform ios
*/
headerRightBarButtonItems?: HeaderBarButtonItem[] | undefined;
+ /**
+ * Array of UIBarButtonItems to the centered item group in modern iOS headers.
+ *
+ * This maps to `UINavigationItem.centerItemGroups` on iOS 26+ and lets
+ * document/editor-style chrome keep primary actions visually centered while
+ * leaving trailing search separate.
+ *
+ * @platform ios
+ */
+ headerCenterBarButtonItems?: HeaderBarButtonItem[] | undefined;
+ /**
+ * Array of UIBarButtonItems to display in the native toolbar attached to
+ * the current screen's navigation controller.
+ *
+ * On iOS 26+, this can contain a `searchBarPlacement` item to let UIKit
+ * render integrated Mail-style search chrome using the attached SearchBar.
+ *
+ * @platform ios
+ */
+ headerToolbarItems?: HeaderBarButtonItem[] | undefined;
/**
* When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
*/
@@ -808,6 +837,20 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* String that can be displayed in the header as a fallback for `headerTitle`.
*/
title?: string | undefined;
+ /**
+ * String displayed below the compact navigation title on iOS 26+.
+ *
+ * @platform ios
+ */
+ subtitle?: string | undefined;
+ /**
+ * String displayed below the large navigation title on iOS 26+.
+ *
+ * Falls back to `subtitle` when omitted.
+ *
+ * @platform ios
+ */
+ largeSubtitle?: string | undefined;
/**
* Allows for setting text color of the title.
*/
@@ -1120,6 +1163,13 @@ export interface SearchBarProps {
}
export interface ScreenStackHeaderSubviewProps {
+ /**
+ * An identifier used to match this item across navigation bar transitions.
+ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later.
+ *
+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
+ */
+ identifier?: string | undefined;
/**
* A boolean value indicating whether the background this item may share with other items in the bar should be hidden.
* Only applicable to type="right" and type="left" subviews.
@@ -1161,6 +1211,11 @@ interface SharedHeaderBarButtonItem {
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
*/
variant?: 'plain' | 'done' | 'prominent' | undefined;
+ /**
+ * Render the item with UIButtonConfiguration.glassButtonConfiguration.
+ * Only available from iOS 26.0 and later.
+ */
+ glassEffect?: boolean | undefined;
/**
* The tint color to apply to the item.
*
@@ -1315,11 +1370,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
export interface HeaderBarButtonItemSpacing {
type: 'spacing';
spacing: number;
+ flexible?: boolean | undefined;
+}
+
+export interface HeaderBarButtonSearchBarPlacementItem {
+ type: 'searchBarPlacement';
+ activatesSearchController?: boolean | undefined;
+ customView?: boolean | undefined;
+ index?: number | undefined;
+ width?: number | undefined;
+}
+
+export interface HeaderBarButtonSearchFieldItem {
+ type: 'searchField';
+ placeholder?: string | undefined;
+ width?: number | undefined;
+}
+
+export interface HeaderBarButtonMailSearchToolbarItem {
+ type: 'mailSearchToolbar';
+ composeButtonId?: string | undefined;
+ composeMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
+ composeSystemImageName?: string | undefined;
+ filterButtonId?: string | undefined;
+ filterMenu?: HeaderBarButtonItemWithMenu['menu'] | undefined;
+ filterSystemImageName?: string | undefined;
+ onComposePress?: (() => void) | undefined;
+ onFilterPress?: (() => void) | undefined;
+ onSearchTextChange?: ((text: string) => void) | undefined;
+ placeholder?: string | undefined;
+ searchTextChangeId?: string | undefined;
+ showsSearchDismissButton?: boolean | undefined;
+ useFallbackSearchField?: boolean | undefined;
+ width?: number | undefined;
}
export type HeaderBarButtonItem =
| HeaderBarButtonItemWithAction
| HeaderBarButtonItemWithMenu
+ | HeaderBarButtonMailSearchToolbarItem
+ | HeaderBarButtonSearchFieldItem
+ | HeaderBarButtonSearchBarPlacementItem
| HeaderBarButtonItemSpacing;
/**