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 *)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 *)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 *> *headerRightBarButtonItems; @property (nonatomic, copy, nullable) NSArray *> *headerLeftBarButtonItems; +@property (nonatomic, copy, nullable) NSArray *> *headerCenterBarButtonItems; +@property (nonatomic, copy, nullable) NSArray *> *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 + 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)imageCache; @end @@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () +#if !TARGET_OS_TV +- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)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 *subviewLeftItems = [NSMutableArray array]; + NSMutableArray *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 *currentItems = navitem.leftBarButtonItems ?: @[]; - NSMutableArray *mutableItems = [currentItems mutableCopy]; - [mutableItems addObject:[subview getUIBarButtonItem]]; - navitem.leftBarButtonItems = mutableItems; + [subviewLeftItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeRight: { - NSArray *currentItems = navitem.rightBarButtonItems ?: @[]; - NSMutableArray *mutableItems = [currentItems mutableCopy]; - [mutableItems addObject:[subview getUIBarButtonItem]]; - navitem.rightBarButtonItems = mutableItems; + [subviewRightItems addObject:[subview getUIBarButtonItem]]; break; } case RNSScreenStackHeaderSubviewTypeCenter: @@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; - navitem.leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; + // Retain the config in the key so its address cannot be reused while cached. + // Items capture this config's event emitter in their press handlers, so a + // remounted header-config view with value-equal configs must still rebuild + // instead of dispatching presses into the old config's emitter. + // getUIBarButtonItem is stable for the lifetime of its React header + // subview. Include those native identities in the key so React-backed + // items can use the same cache without reusing a group after its view was + // replaced. + // A bar button item can belong to only one group. Even if the item itself + // is reused, constructing a new group removes it from the old group that + // UIKit may still be animating. Preserve each unchanged placement's group, + // including the empty leading group beside the system back button. +#if !TARGET_OS_TV + if (@available(iOS 16.0, *)) { + NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; + NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; + if (RNSUpdateHeaderItemsCache( + navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems + withCurrentItems:subviewLeftItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items]; + } + if (RNSUpdateHeaderItemsCache( + navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems + withCurrentItems:subviewRightItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items]; + } + if (@available(iOS 26.0, *)) { + if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) { + NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems + withCurrentItems:@[] + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items]; + } + } + } else +#endif + { + NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems + withCurrentItems:subviewLeftItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems + withCurrentItems:subviewRightItems + navigationItem:navitem + allowsSearchBarPlacement:NO]; + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; + } + NSDictionary *mailSearchToolbarConfig = nil; + NSMutableArray *> *nonMailSearchToolbarConfigs = [NSMutableArray array]; + for (NSDictionary *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { + if (mailSearchToolbarConfig == nil) { + mailSearchToolbarConfig = toolbarConfig; + } + } else { + [nonMailSearchToolbarConfigs addObject:toolbarConfig]; + } + } + NSArray *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy]; + if (mailSearchToolbarConfig == nil) { + for (NSDictionary *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 *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( + 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( + 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], + ]]; + } + + 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 *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 *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)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( + 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( + 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( @@ -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 *)barButtonItemGroupsFromItems:(NSArray *)items +{ + if (items.count == 0) { + return @[]; + } + + NSMutableArray *groups = [NSMutableArray array]; + NSMutableArray *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 *> *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 *> *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(otherGestureRecognizer.view)]; + UIScrollView *scrollView = static_cast(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(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(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; + navigationItemStyle?: CT.WithDefault; hideBackButton?: boolean | undefined; backButtonInCustomView?: boolean | undefined; blurEffect?: CT.WithDefault; topInsetEnabled?: boolean | undefined; headerLeftBarButtonItems?: CT.UnsafeMixed[] | undefined; headerRightBarButtonItems?: CT.UnsafeMixed[] | undefined; + headerCenterBarButtonItems?: CT.UnsafeMixed[] | undefined; + headerToolbarItems?: CT.UnsafeMixed[] | undefined; onPressHeaderBarButtonItem?: CT.DirectEventHandler | undefined; onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler | undefined; synchronousShadowStateUpdatesEnabled?: CT.WithDefault; 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; hidesSharedBackground?: boolean | undefined; + identifier?: string | undefined; synchronousShadowStateUpdatesEnabled?: CT.WithDefault; } declare const _default: import("react-native").HostComponent; 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; + navigationItemStyle?: CT.WithDefault; hideBackButton?: boolean | undefined; backButtonInCustomView?: boolean | undefined; blurEffect?: CT.WithDefault; @@ -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 | 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; hidesSharedBackground?: boolean | undefined; + identifier?: string | undefined; synchronousShadowStateUpdatesEnabled?: CT.WithDefault; } 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; /**