291 lines
9.1 KiB
Swift
291 lines
9.1 KiB
Swift
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
|
|
}
|
|
}
|