first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import React
import UIKit
struct RouterFontUtils {
static func convertTitleStyleToFont(_ titleStyle: TitleStyle) -> UIFont {
let fontFamily = titleStyle.fontFamily
let fontWeight = titleStyle.fontWeight
let resolvedFontSize = resolveFontSize(titleStyle.fontSize)
if fontFamily != nil || fontWeight != nil {
return RCTFont.update(
nil,
withFamily: fontFamily,
size: NSNumber(value: Float(resolvedFontSize)),
weight: fontWeight,
style: nil,
variant: nil,
scaleMultiplier: 1.0)
}
return UIFont.systemFont(ofSize: resolvedFontSize)
}
static func setTitleStyle(fromConfig titleStyle: TitleStyle, for item: UIBarButtonItem) {
var attrs: [NSAttributedString.Key: Any] = [:]
attrs[.font] = convertTitleStyleToFont(titleStyle)
if let color = titleStyle.color {
attrs[.foregroundColor] = color
}
item.setTitleTextAttributes(attrs, for: .normal)
item.setTitleTextAttributes(attrs, for: .highlighted)
item.setTitleTextAttributes(attrs, for: .disabled)
item.setTitleTextAttributes(attrs, for: .selected)
item.setTitleTextAttributes(attrs, for: .focused)
}
static func clearTitleStyle(for item: UIBarButtonItem) {
item.setTitleTextAttributes(nil, for: .normal)
item.setTitleTextAttributes(nil, for: .highlighted)
item.setTitleTextAttributes(nil, for: .disabled)
item.setTitleTextAttributes(nil, for: .selected)
item.setTitleTextAttributes(nil, for: .focused)
}
private static func resolveFontSize(_ fontSize: Double?) -> CGFloat {
if let fontSize = fontSize {
return CGFloat(fontSize)
}
#if os(tvOS)
return 17.0
#else
return UIFont.labelFontSize
#endif
}
}

View File

@@ -0,0 +1,200 @@
import ExpoModulesCore
import RNScreens
import UIKit
class RouterToolbarHostView: RouterViewWithLogger, LinkPreviewMenuUpdatable {
// Cached reference to the view controller to avoid responder chain traversal
private weak var cachedController: RNSScreen?
// Mutable map of toolbar items
var toolbarItemsArray: [String] = []
var toolbarItemsMap: [String: RouterToolbarItemView] = [:]
var menuItemsMap: [String: LinkPreviewNativeActionView] = [:]
// Batching state for toolbar updates
private var hasPendingToolbarUpdate = false
private func addRouterToolbarItemAtIndex(
_ item: RouterToolbarItemView,
index: Int
) {
let identifier = item.identifier
toolbarItemsArray.insert(identifier, at: index)
toolbarItemsMap[identifier] = item
item.host = self
}
private func addMenuToolbarItemAtIndex(
_ item: LinkPreviewNativeActionView,
index: Int
) {
let identifier = item.identifier
toolbarItemsArray.insert(identifier, at: index)
menuItemsMap[identifier] = item
}
private func removeToolbarItemWithId(_ id: String) {
if let index = toolbarItemsArray.firstIndex(of: id) {
toolbarItemsArray.remove(at: index)
toolbarItemsMap.removeValue(forKey: id)
menuItemsMap.removeValue(forKey: id)
}
}
func updateToolbarItems() {
// If update already scheduled, skip - the pending async block will handle it
if hasPendingToolbarUpdate {
return
}
hasPendingToolbarUpdate = true
// Defer actual update to next run loop iteration
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.hasPendingToolbarUpdate = false
self.performToolbarUpdate()
}
}
private func performToolbarUpdate() {
if let controller = self.findViewController() {
if #available(iOS 18.0, *) {
let items = toolbarItemsArray.compactMap { identifier -> UIBarButtonItem? in
if let item = toolbarItemsMap[identifier] {
if item.routerHidden {
return nil
}
return item.barButtonItem
}
// TODO: Extract this logic to separate function
if let menu = menuItemsMap[identifier] {
if menu.routerHidden {
return nil
}
let item = UIBarButtonItem(
title: menu.label,
image: menu.image,
primaryAction: nil,
menu: menu.uiAction as? UIMenu
)
// Otherwise, the menu items will be reversed in the toolbar
item.preferredMenuElementOrder = .fixed
if #available(iOS 26.0, *) {
if let hidesSharedBackground = menu.hidesSharedBackground {
item.hidesSharedBackground = hidesSharedBackground
}
if let sharesBackground = menu.sharesBackground {
item.sharesBackground = sharesBackground
}
}
if let titleStyle = menu.titleStyle {
RouterFontUtils.setTitleStyle(fromConfig: titleStyle, for: item)
}
item.isEnabled = !menu.disabled
if let accessibilityLabel = menu.accessibilityLabelForMenu {
item.accessibilityLabel = accessibilityLabel
} else if let label = menu.label {
item.accessibilityLabel = label
}
if let accessibilityHint = menu.accessibilityHintForMenu {
item.accessibilityHint = accessibilityHint
}
item.tintColor = menu.customTintColor
if let style = menu.barButtonItemStyle {
item.style = style
}
return item
}
logger?.warn(
"[expo-router] Warning: Could not find toolbar item or menu for identifier \(identifier). This is most likely a bug in expo-router."
)
return nil
}
controller.setToolbarItems(items, animated: true)
controller.navigationController?.setToolbarHidden(
false, animated: true)
return
}
}
}
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
if let toolbarItem = childComponentView as? RouterToolbarItemView {
if toolbarItem.identifier.isEmpty {
logger?.warn(
"[expo-router] RouterToolbarItemView identifier is empty. This is most likely a bug in expo-router."
)
return
}
addRouterToolbarItemAtIndex(toolbarItem, index: index)
} else if let menu = childComponentView as? LinkPreviewNativeActionView {
menu.parentMenuUpdatable = self
addMenuToolbarItemAtIndex(menu, index: index)
} else {
logger?.warn(
"[expo-router] Unknown child component view (\(childComponentView)) mounted to RouterToolbarHost. This is most likely a bug in expo-router."
)
}
updateToolbarItems()
}
override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
if let toolbarItem = childComponentView as? RouterToolbarItemView {
if toolbarItem.identifier.isEmpty {
logger?.warn(
"[expo-router] RouterToolbarItemView identifier is empty. This is most likely a bug in expo-router."
)
return
}
removeToolbarItemWithId(toolbarItem.identifier)
} else if let menu = childComponentView as? LinkPreviewNativeActionView {
if menu.identifier.isEmpty {
logger?.warn(
"[expo-router] Menu identifier is empty. This is most likely a bug in expo-router.")
return
}
removeToolbarItemWithId(menu.identifier)
} else {
logger?.warn(
"[expo-router] Unknown child component view (\(childComponentView)) unmounted from RouterToolbarHost. This is most likely a bug in expo-router."
)
}
updateToolbarItems()
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window == nil {
// View was removed from window - hide toolbar and clear items
// Use cached controller since responder chain may be broken
if let controller = cachedController {
controller.setToolbarItems(nil, animated: true)
}
cachedController = nil // Clear cache when removed from window
} else {
// View was added to window - update toolbar items
updateToolbarItems()
}
}
func updateMenu() {
updateToolbarItems()
}
func findViewController() -> RNSScreen? {
if let cached = cachedController {
return cached
}
var responder: UIResponder? = self
while let r = responder {
if let screen = r as? RNSScreen {
cachedController = screen
return screen
}
responder = r.next
}
return nil
}
}

View File

@@ -0,0 +1,290 @@
import ExpoModulesCore
import UIKit
class RouterToolbarItemView: RouterViewWithLogger {
var identifier: String = ""
// Properties requiring full rebuild
@ReactiveProp(needsFullRebuild: true) var type: ItemType?
@ReactiveProp(needsFullRebuild: true) var customView: UIView?
// Properties allowing in-place updates
@ReactiveProp var title: String?
@ReactiveProp var systemImageName: String?
@ReactiveProp var xcassetName: String?
var customImage: SharedRef<UIImage>? {
didSet {
performUpdate()
}
}
@ReactiveProp var customTintColor: UIColor?
@ReactiveProp var imageRenderingMode: ImageRenderingMode?
@ReactiveProp var hidesSharedBackground: Bool = false
@ReactiveProp var sharesBackground: Bool = true
@ReactiveProp var barButtonItemStyle: UIBarButtonItem.Style?
@ReactiveProp var width: Double?
@ReactiveProp var selected: Bool = false
@ReactiveProp var possibleTitles: Set<String>?
@ReactiveProp var badgeConfiguration: BadgeConfiguration?
@ReactiveProp var titleStyle: TitleStyle?
@ReactiveProp var routerAccessibilityLabel: String?
@ReactiveProp var routerAccessibilityHint: String?
@ReactiveProp var disabled: Bool = false
// Using "routerHidden" to avoid conflict with UIView's "isHidden"
// This property is not applied in this component, but read by the host
@ReactiveProp var routerHidden: Bool = false
var host: RouterToolbarHostView?
private var currentBarButtonItem: UIBarButtonItem?
let onSelected = EventDispatcher()
func performRebuild() {
// There is no need to rebuild if not mounted
guard self.host != nil else { return }
rebuildBarButtonItem()
self.host?.updateToolbarItems()
}
func performUpdate() {
// There is no need to update if not mounted
guard self.host != nil else { return }
updateBarButtonItem()
// Even though we update in place, we need to notify the host
// so the toolbar array reference is updated and UIKit refreshes
self.host?.updateToolbarItems()
}
@objc func handleAction() {
onSelected()
}
var barButtonItem: UIBarButtonItem {
if let item = currentBarButtonItem {
return item
}
// If no item exists yet, create one
rebuildBarButtonItem()
return currentBarButtonItem ?? UIBarButtonItem()
}
private func updateBarButtonItem() {
guard let item = currentBarButtonItem else {
// If no current item exists, create one
rebuildBarButtonItem()
return
}
// Update content properties (title, image, etc.) for normal buttons
applyContentProperties(to: item)
// Update all common properties
applyCommonProperties(to: item)
}
private func rebuildBarButtonItem() {
var item = UIBarButtonItem()
if let customView {
item = UIBarButtonItem(customView: customView)
} else if type == .fluidSpacer {
item = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
} else if type == .fixedSpacer {
item = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
} else if type == .searchBar {
guard #available(iOS 26.0, *), let controller = self.host?.findViewController() else {
// Check for iOS 26, should already be guarded by the JS side, so this warning will only fire if controller is nil
logger?.warn(
"[expo-router] navigationItem.searchBarPlacementBarButtonItem not available. This is most likely a bug in expo-router."
)
currentBarButtonItem = nil
return
}
guard let navController = controller.navigationController else {
currentBarButtonItem = nil
return
}
guard navController.isNavigationBarHidden == false else {
logger?.warn(
"[expo-router] Toolbar.SearchBarPreferredSlot should only be used when stack header is shown."
)
currentBarButtonItem = nil
return
}
item = controller.navigationItem.searchBarPlacementBarButtonItem
} else {
// Normal button - apply content properties during initial creation
applyContentProperties(to: item)
}
// Set target and action for interactive buttons
item.target = self
item.action = #selector(handleAction)
applyCommonProperties(to: item)
currentBarButtonItem = item
}
private func applyContentProperties(to item: UIBarButtonItem) {
// Only apply content properties for normal buttons
if type == .normal || type == nil {
if let title {
item.title = title
} else {
item.title = ""
}
item.possibleTitles = possibleTitles
if let customImage {
// Use the UIImage from the SharedRef
let renderingMode: UIImage.RenderingMode = imageRenderingMode == .template ? .alwaysTemplate : .alwaysOriginal
item.image = customImage.ref.withRenderingMode(renderingMode)
} else if let xcassetName {
let renderingMode: UIImage.RenderingMode = imageRenderingMode == .template ? .alwaysTemplate : .alwaysOriginal
item.image = UIImage(named: xcassetName)?.withRenderingMode(renderingMode)
} else if let systemImageName {
// Fallback to SF Symbol
item.image = UIImage(systemName: systemImageName)
} else {
item.image = nil
}
item.tintColor = customTintColor
if let titleStyle {
RouterFontUtils.setTitleStyle(fromConfig: titleStyle, for: item)
} else {
RouterFontUtils.clearTitleStyle(for: item)
}
}
}
private func applyCommonProperties(to item: UIBarButtonItem) {
if #available(iOS 26.0, *) {
item.hidesSharedBackground = hidesSharedBackground
item.sharesBackground = sharesBackground
}
item.style = barButtonItemStyle ?? .plain
item.width = width.map { CGFloat($0) } ?? 0
item.isSelected = selected
item.accessibilityLabel = routerAccessibilityLabel
item.accessibilityHint = routerAccessibilityHint
item.isEnabled = !disabled
if #available(iOS 26.0, *) {
if let badgeConfig = badgeConfiguration {
var badge = UIBarButtonItem.Badge.indicator()
if let value = badgeConfig.value {
badge = .string(value)
}
if let backgroundColor = badgeConfig.backgroundColor {
badge.backgroundColor = backgroundColor
}
if let foregroundColor = badgeConfig.color {
badge.foregroundColor = foregroundColor
}
if badgeConfig.fontFamily != nil || badgeConfig.fontSize != nil
|| badgeConfig.fontWeight != nil {
let font = RouterFontUtils.convertTitleStyleToFont(
TitleStyle(
fontFamily: badgeConfig.fontFamily,
fontSize: badgeConfig.fontSize,
fontWeight: badgeConfig.fontWeight
))
badge.font = font
}
item.badge = badge
} else {
item.badge = nil
}
}
}
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
}
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
guard customView == nil else {
logger?.warn(
"[expo-router] RouterToolbarItemView can only have one child view. This is most likely a bug in expo-router."
)
return
}
customView = childComponentView
}
override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
if customView == childComponentView {
childComponentView.removeFromSuperview()
customView = nil
}
}
}
enum ItemType: String, Enumerable {
case normal
case fixedSpacer
case fluidSpacer
case searchBar
}
struct BadgeConfiguration: Equatable {
var value: String?
var backgroundColor: UIColor?
var color: UIColor?
var fontFamily: String?
var fontSize: Double?
var fontWeight: String?
}
struct TitleStyle: Equatable {
var fontFamily: String?
var fontSize: Double?
var fontWeight: String?
var color: UIColor?
}
@propertyWrapper
struct ReactiveProp<Value: Equatable> {
private var value: Value
let needsFullRebuild: Bool
init(wrappedValue: Value, needsFullRebuild: Bool = false) {
self.value = wrappedValue
self.needsFullRebuild = needsFullRebuild
}
static subscript<EnclosingSelf: RouterToolbarItemView>(
_enclosingInstance instance: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, ReactiveProp<Value>>
) -> Value {
get {
instance[keyPath: storageKeyPath].value
}
set {
let oldValue = instance[keyPath: storageKeyPath].value
if oldValue != newValue {
instance[keyPath: storageKeyPath].value = newValue
if instance[keyPath: storageKeyPath].needsFullRebuild {
instance.performRebuild()
} else {
instance.performUpdate()
}
}
}
}
@available(*, unavailable, message: "Use the enclosing instance subscript.")
var wrappedValue: Value {
get { fatalError() }
set { fatalError() }
}
}
extension ReactiveProp where Value: ExpressibleByNilLiteral {
init(needsFullRebuild: Bool = false) {
self.value = nil
self.needsFullRebuild = needsFullRebuild
}
}

View File

@@ -0,0 +1,145 @@
import ExpoModulesCore
import UIKit
public class RouterToolbarModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoRouterToolbarModule")
View(RouterToolbarHostView.self) {
Prop("disableForceFlatten") { (_: RouterToolbarHostView, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
}
View(RouterToolbarItemView.self) {
Prop("identifier") { (view: RouterToolbarItemView, identifier: String) in
view.identifier = identifier
}
Prop("type") { (view: RouterToolbarItemView, type: ItemType) in
view.type = type
}
Prop("title") { (view: RouterToolbarItemView, title: String?) in
view.title = title
}
Prop("systemImageName") { (view: RouterToolbarItemView, systemImageName: String?) in
view.systemImageName = systemImageName
}
Prop("xcassetName") { (view: RouterToolbarItemView, xcassetName: String?) in
view.xcassetName = xcassetName
}
Prop("image") { (view: RouterToolbarItemView, image: SharedRef<UIImage>?) in
view.customImage = image
}
Prop("tintColor") { (view: RouterToolbarItemView, tintColor: UIColor?) in
view.customTintColor = tintColor
}
Prop("imageRenderingMode") { (view: RouterToolbarItemView, mode: ImageRenderingMode?) in
view.imageRenderingMode = mode
}
Prop("hidesSharedBackground") { (view: RouterToolbarItemView, hidesSharedBackground: Bool) in
view.hidesSharedBackground = hidesSharedBackground
}
Prop("sharesBackground") { (view: RouterToolbarItemView, sharesBackground: Bool) in
view.sharesBackground = sharesBackground
}
Prop("disableForceFlatten") { (_: RouterToolbarItemView, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
Prop("barButtonItemStyle") { (view: RouterToolbarItemView, style: BarItemStyle?) in
view.barButtonItemStyle = style?.toUIBarButtonItemStyle()
}
Prop("width") { (view: RouterToolbarItemView, width: Double?) in
view.width = width
}
Prop("hidden") { (view: RouterToolbarItemView, hidden: Bool) in
view.routerHidden = hidden
}
Prop("selected") { (view: RouterToolbarItemView, selected: Bool) in
view.selected = selected
}
Prop("possibleTitles") { (view: RouterToolbarItemView, possibleTitles: [String]?) in
if let possibleTitles = possibleTitles {
view.possibleTitles = Set(possibleTitles)
} else {
view.possibleTitles = nil
}
}
Prop("badgeConfiguration") {
(view: RouterToolbarItemView, config: BadgeConfigurationRecord?) in
view.badgeConfiguration = config?.toBadgeConfiguration()
}
Prop("titleStyle") { (view: RouterToolbarItemView, style: TitleStyleRecord?) in
view.titleStyle = style?.toTitleStyle()
}
Prop("accessibilityLabel") { (view: RouterToolbarItemView, accessibilityLabel: String?) in
view.accessibilityLabel = accessibilityLabel
}
Prop("accessibilityHint") { (view: RouterToolbarItemView, accessibilityHint: String?) in
view.accessibilityHint = accessibilityHint
}
Prop("disabled") { (view: RouterToolbarItemView, disabled: Bool?) in
view.disabled = disabled ?? false
}
Events("onSelected")
}
}
}
enum BarItemStyle: String, Enumerable {
case plain
case prominent
func toUIBarButtonItemStyle() -> UIBarButtonItem.Style {
switch self {
case .plain:
return .plain
case .prominent:
if #available(iOS 26.0, *) {
return .prominent
} else {
return .done
}
}
}
}
enum ImageRenderingMode: String, Enumerable {
case template
case original
}
struct BadgeConfigurationRecord: Record {
@Field var value: String?
@Field var backgroundColor: UIColor?
@Field var color: UIColor?
@Field var fontFamily: String?
@Field var fontSize: Double?
@Field var fontWeight: String?
func toBadgeConfiguration() -> BadgeConfiguration {
return BadgeConfiguration(
value: value,
backgroundColor: backgroundColor,
color: color,
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: fontWeight
)
}
}
struct TitleStyleRecord: Record {
@Field var fontFamily: String?
@Field var fontSize: Double?
@Field var fontWeight: String?
@Field var color: UIColor?
func toTitleStyle() -> TitleStyle {
return TitleStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: fontWeight,
color: color
)
}
}