201 lines
6.6 KiB
Swift
201 lines
6.6 KiB
Swift
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
|
|
}
|
|
}
|