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,94 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
import CoreSpotlight
/// Represents the Info.plist.
public struct InfoPlist {
public init() {}
/// Returns the custom URL schemes registered by the app ('CFBundleURLSchemes' array).
public static func bundleURLSchemes() -> [String] {
guard let path = Bundle.main.path(forResource: "Info", ofType: "plist") else {
log.error("Can't find path to Info.plist in the main bundle.")
return []
}
guard
// swiftlint:disable:next legacy_objc_type
let infoDict = NSDictionary(contentsOfFile: path) as? [String: AnyObject],
let anyDictionary = (infoDict["CFBundleURLTypes"] as? [[String: Any]])?.first,
let urlSchemes = anyDictionary["CFBundleURLSchemes"] as? [String]
else {
log.error("Can't find path to CFBundleURLSchemes in the Info.plist.")
return []
}
return urlSchemes
}
}
func encoded(_ value: String) -> String {
return value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? value
}
func sendFakeDeepLinkEventToReactNative(obj: Any, url: String) {
NotificationCenter.default.post(
// swiftlint:disable:next legacy_objc_type
name: NSNotification.Name(rawValue: "RCTOpenURLNotification"),
object: obj,
userInfo: ["url": url])
}
func userInfoToQueryString(_ userInfo: [String: NSSecureCoding]?) -> String {
guard let userInfo = userInfo else {
return ""
}
var queryString = ""
for (key, value) in userInfo {
if let value = value as? String {
if key != "href" {
queryString += "&\(encoded(key))=\(encoded(value))"
}
}
}
return queryString
}
func prefixDeepLink(fragment: String) -> String {
// This can happen when an NSUserActivity href is used to activate the app.
if fragment.starts(with: "/") {
let schemes = InfoPlist.bundleURLSchemes()
return "\(schemes[0]):/\(fragment)"
}
return fragment
}
public class ExpoHeadAppDelegateSubscriber: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
launchedActivity = userActivity
if let wellKnownHref = userActivity.userInfo?["href"] as? String {
// From a stored NSUserActivity, e.g. Quick Note or Siri Reminder
// From other native device to app
sendFakeDeepLinkEventToReactNative(obj: self, url: prefixDeepLink(fragment: wellKnownHref))
} else if userActivity.activityType == CSQueryContinuationActionType {
// From Spotlight search
if let query = userActivity.userInfo?[CSSearchQueryString] as? String {
let schemes = InfoPlist.bundleURLSchemes()
let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? query
// swiftlint:disable:next todo
// TODO(EvanBacon): Allow user to define the scheme using structured data or something.
// opensearch = Chrome. spotlight = custom thing we're using to identify iOS
let url = "\(schemes[0])://search?q=\(encodedQuery)&ref=spotlight"
// https://github.com/search?q=
sendFakeDeepLinkEventToReactNative(obj: self, url: url)
}
}
return false
}
}

184
node_modules/expo-router/ios/ExpoHeadModule.swift generated vendored Normal file
View File

@@ -0,0 +1,184 @@
// Copyright 2023-present 650 Industries. All rights reserved.
import ExpoModulesCore
import CoreSpotlight
import MobileCoreServices
struct MetadataOptions: Record {
@Field
// swiftlint:disable:next implicitly_unwrapped_optional
var activityType: String!
@Field
// swiftlint:disable:next implicitly_unwrapped_optional
var id: String!
@Field
var isEligibleForHandoff: Bool = true
@Field
var isEligibleForPrediction: Bool = true
@Field
var isEligibleForPublicIndexing: Bool = false
@Field
var isEligibleForSearch: Bool = true
@Field
var title: String?
@Field
var webpageURL: URL?
@Field
var imageUrl: URL?
@Field
var keywords: [String]?
@Field
var userInfo: [String: AnyHashable]?
@Field
var description: String?
}
// swiftlint:disable:next force_unwrapping
let indexRouteTag = Bundle.main.bundleIdentifier! + ".expo.index_route"
var launchedActivity: NSUserActivity?
internal class InvalidSchemeException: Exception {
override var reason: String {
"Scheme file:// is not allowed for location origin (webpageUrl in NSUserActivity)"
}
}
public class ExpoHeadModule: Module {
private var activities = Set<NSUserActivity>()
public required init(appContext: AppContext) {
super.init(appContext: appContext)
}
// Each module class must implement the definition function. The definition consists of components
// that describes the module's functionality and behavior.
// See https://docs.expo.dev/modules/module-api for more details about available components.
public func definition() -> ModuleDefinition {
// Sets the name of the module that JavaScript code will use to refer to the module.
// Takes a string as an argument. Can be inferred from module's class name, but it's
// recommended to set it explicitly for clarity.
// The module will be accessible from `requireNativeModule('ExpoHead')` in JavaScript.
Name("ExpoHead")
Constant("activities") {
[
"INDEXED_ROUTE": indexRouteTag
]
}
Function("getLaunchActivity") { () -> [String: Any]? in
if let activity = launchedActivity {
return [
"activityType": activity.activityType,
"description": activity.contentAttributeSet?.contentDescription,
"id": activity.persistentIdentifier,
"isEligibleForHandoff": activity.isEligibleForHandoff,
"isEligibleForPrediction": activity.isEligibleForPrediction,
"isEligibleForPublicIndexing": activity.isEligibleForPublicIndexing,
"isEligibleForSearch": activity.isEligibleForSearch,
"title": activity.title,
"webpageURL": activity.webpageURL,
"imageUrl": activity.contentAttributeSet?.thumbnailURL,
"keywords": activity.keywords,
"dateModified": activity.contentAttributeSet?.metadataModificationDate,
"userInfo": activity.userInfo
]
}
return nil
}
Function("createActivity") { (value: MetadataOptions) in
if let webpageUrl = value.webpageURL {
if webpageUrl.absoluteString.starts(with: "file://") == true {
throw Exception(name: "Invalid webpageUrl", description: "Scheme file:// is not allowed for location origin (webpageUrl in NSUserActivity). URL: \(webpageUrl.absoluteString)")
}
}
let activity = createOrUpdateActivity(value: value)
activity.becomeCurrent()
}
AsyncFunction("clearActivitiesAsync") { (ids: [String], promise: Promise) in
ids.forEach { id in
self.revokeActivity(id: id)
}
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids, completionHandler: { error in
if error != nil {
// swiftlint:disable:next force_cast
promise.reject(error as! Exception)
} else {
promise.resolve()
}
})
}
Function("suspendActivity") { (id: String) in
let activity = self.activities.first(where: { $0.persistentIdentifier == id })
activity?.resignCurrent()
}
Function("revokeActivity") { (id: String) in
self.revokeActivity(id: id)
}
}
func createOrUpdateActivity(value: MetadataOptions) -> NSUserActivity {
let att = CSSearchableItemAttributeSet(itemContentType: kUTTypeText as String)
let existing = self.activities.first(where: { $0.persistentIdentifier == value.id })
let activity = existing ?? NSUserActivity(activityType: value.activityType)
if existing == nil {
self.activities.insert(activity)
}
activity.targetContentIdentifier = value.id
activity.persistentIdentifier = value.id
activity.isEligibleForHandoff = value.isEligibleForHandoff
activity.isEligibleForPrediction = value.isEligibleForPrediction
activity.isEligibleForPublicIndexing = value.isEligibleForPublicIndexing
activity.isEligibleForSearch = value.isEligibleForSearch
activity.title = value.title
if let keywords = value.keywords {
activity.keywords = Set(keywords)
}
activity.userInfo = value.userInfo
if value.webpageURL != nil {
// If youre using all three APIs, it works well to use the URL of the relevant webpage as the value
// for uniqueIdentifier, relatedUniqueIdentifier, and webpageURL.
// https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/CombiningAPIs.html
activity.webpageURL = value.webpageURL
att.relatedUniqueIdentifier = value.webpageURL?.absoluteString
}
att.title = value.title
// Make all indexed routes deletable
att.domainIdentifier = indexRouteTag
if let localUrl = value.imageUrl?.path {
att.thumbnailURL = value.imageUrl
}
if let description = value.description {
att.contentDescription = description
}
activity.contentAttributeSet = att
return activity
}
@discardableResult
func revokeActivity(id: String) -> NSUserActivity? {
let activity = self.activities.first(where: { $0.persistentIdentifier == id })
activity?.invalidate()
if let activity = activity {
self.activities.remove(activity)
}
return activity
}
}

51
node_modules/expo-router/ios/ExpoRouter.podspec generated vendored Normal file
View File

@@ -0,0 +1,51 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
new_arch_compiler_flags = '-DRCT_NEW_ARCH_ENABLED'
compiler_flags = ''
if new_arch_enabled
compiler_flags << ' ' << new_arch_compiler_flags
end
Pod::Spec.new do |s|
s.name = 'ExpoRouter'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
add_dependency(s, "RNScreens")
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule',
'OTHER_SWIFT_FLAGS' => "$(inherited) #{compiler_flags}",
}
s.exclude_files = 'Tests/**/*'
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift,mm,cpp}"
end
s.test_spec 'Tests' do |test_spec|
test_spec.source_files = 'Tests/**/*.{m,swift}'
end
end

View File

@@ -0,0 +1,223 @@
import ExpoModulesCore
class LinkPreviewNativeActionView: RouterViewWithLogger, LinkPreviewMenuUpdatable {
var identifier: String = ""
// MARK: - Shared props
@NativeActionProp(updateAction: true, updateMenu: true) var title: String = ""
@NativeActionProp(updateMenu: true) var label: String?
@NativeActionProp(updateAction: true, updateMenu: true) var icon: String?
@NativeActionProp(updateAction: true, updateMenu: true) var xcassetName: String?
var customImage: SharedRef<UIImage>? {
didSet {
updateUiAction()
updateMenu()
}
}
@NativeActionProp(updateAction: true, updateMenu: true) var imageRenderingMode: ImageRenderingMode?
@NativeActionProp(updateAction: true, updateMenu: true) var destructive: Bool?
@NativeActionProp(updateAction: true, updateMenu: true) var disabled: Bool = false
// MARK: - Action only props
@NativeActionProp(updateAction: true) var isOn: Bool?
@NativeActionProp(updateAction: true) var keepPresented: Bool?
@NativeActionProp(updateAction: true) var discoverabilityLabel: String?
@NativeActionProp(updateAction: true, updateMenu: true) var subtitle: String?
// MARK: - Menu only props
@NativeActionProp(updateMenu: true) var singleSelection: Bool = false
@NativeActionProp(updateMenu: true) var displayAsPalette: Bool = false
@NativeActionProp(updateMenu: true) var displayInline: Bool = false
@NativeActionProp(updateMenu: true) var preferredElementSize: MenuElementSize?
// MARK: - UIBarButtonItem props
@NativeActionProp(updateAction: true, updateMenu: true) var routerHidden: Bool = false
@NativeActionProp(updateMenu: true) var titleStyle: TitleStyle?
@NativeActionProp(updateMenu: true) var sharesBackground: Bool?
@NativeActionProp(updateMenu: true) var hidesSharedBackground: Bool?
@NativeActionProp(updateAction: true, updateMenu: true) var customTintColor: UIColor?
@NativeActionProp(updateMenu: true) var barButtonItemStyle: UIBarButtonItem.Style?
@NativeActionProp(updateMenu: true) var subActions: [LinkPreviewNativeActionView] = []
@NativeActionProp(updateMenu: true) var accessibilityLabelForMenu: String?
@NativeActionProp(updateMenu: true) var accessibilityHintForMenu: String?
// MARK: - Events
let onSelected = EventDispatcher()
// MARK: - Native API
weak var parentMenuUpdatable: LinkPreviewMenuUpdatable?
private var baseUiAction: UIAction
private var menuAction: UIMenu
var isMenuAction: Bool {
return !subActions.isEmpty
}
var uiAction: UIMenuElement {
isMenuAction ? menuAction : baseUiAction
}
var image: UIImage? {
if let customImage = customImage {
let renderingMode: UIImage.RenderingMode = imageRenderingMode == .template ? .alwaysTemplate : .alwaysOriginal
return customImage.ref.withRenderingMode(renderingMode)
}
if let xcassetName = xcassetName {
let renderingMode: UIImage.RenderingMode = imageRenderingMode == .template ? .alwaysTemplate : .alwaysOriginal
return UIImage(named: xcassetName)?.withRenderingMode(renderingMode)
}
if let icon = icon {
return UIImage(systemName: icon)
}
return nil
}
required init(appContext: AppContext? = nil) {
baseUiAction = UIAction(title: "", handler: { _ in })
menuAction = UIMenu(title: "", image: nil, options: [], children: [])
super.init(appContext: appContext)
clipsToBounds = true
baseUiAction = UIAction(title: "", handler: { _ in self.onSelected() })
}
func updateMenu() {
let subActions = subActions.map { subAction in
subAction.uiAction
}
var options: UIMenu.Options = []
if #available(iOS 17.0, *) {
if displayAsPalette {
options.insert(.displayAsPalette)
}
}
if singleSelection {
options.insert(.singleSelection)
}
if displayInline {
options.insert(.displayInline)
}
if destructive == true {
options.insert(.destructive)
}
menuAction = UIMenu(
title: title,
image: image,
options: options,
children: subActions
)
if let subtitle = subtitle {
menuAction.subtitle = subtitle
}
if #available(iOS 16.0, *) {
if let preferredElementSize = preferredElementSize {
menuAction.preferredElementSize = preferredElementSize.toUIMenuElementSize()
}
}
parentMenuUpdatable?.updateMenu()
}
func updateUiAction() {
var attributes: UIMenuElement.Attributes = []
if destructive == true { attributes.insert(.destructive) }
if disabled == true { attributes.insert(.disabled) }
if routerHidden {
attributes.insert(.hidden)
}
if #available(iOS 16.0, *) {
if keepPresented == true { attributes.insert(.keepsMenuPresented) }
}
baseUiAction.title = title
baseUiAction.image = image
baseUiAction.attributes = attributes
baseUiAction.state = isOn == true ? .on : .off
if let subtitle = subtitle {
baseUiAction.subtitle = subtitle
}
if let label = discoverabilityLabel {
baseUiAction.discoverabilityTitle = label
}
parentMenuUpdatable?.updateMenu()
}
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
if let childActionView = childComponentView as? LinkPreviewNativeActionView {
subActions.insert(childActionView, at: index)
childActionView.parentMenuUpdatable = self
} else {
logger?.warn(
"[expo-router] Unknown child component view (\(childComponentView)) mounted to NativeLinkPreviewActionView. This is most likely a bug in expo-router."
)
}
}
override func unmountChildComponentView(_ child: UIView, index: Int) {
if let childActionView = child as? LinkPreviewNativeActionView {
subActions.removeAll(where: { $0 == childActionView })
} else {
logger?.warn(
"ExpoRouter: Unknown child component view (\(child)) unmounted from NativeLinkPreviewActionView. This is most likely a bug in expo-router."
)
}
}
@propertyWrapper
struct NativeActionProp<Value: Equatable> {
var value: Value
let updateAction: Bool
let updateMenu: Bool
init(wrappedValue: Value, updateAction: Bool = false, updateMenu: Bool = false) {
self.value = wrappedValue
self.updateAction = updateAction
self.updateMenu = updateMenu
}
static subscript<EnclosingSelf: LinkPreviewNativeActionView>(
_enclosingInstance instance: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, NativeActionProp<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].updateAction {
instance.updateUiAction()
}
if instance[keyPath: storageKeyPath].updateMenu {
instance.updateMenu()
}
}
}
}
var wrappedValue: Value {
get { value }
set { value = newValue }
}
}
}
// Needed to allow optional properties without default `= nil` to avoid repetition
extension LinkPreviewNativeActionView.NativeActionProp where Value: ExpressibleByNilLiteral {
init(updateAction: Bool = false, updateMenu: Bool = false) {
self.value = nil
self.updateAction = updateAction
self.updateMenu = updateMenu
}
}
protocol LinkPreviewMenuUpdatable: AnyObject {
func updateMenu()
}

View File

@@ -0,0 +1,223 @@
import ExpoModulesCore
public class LinkPreviewNativeModule: Module {
static let moduleName: String = "ExpoRouterNativeLinkPreview"
lazy var zoomSourceRepository = LinkZoomTransitionsSourceRepository(logger: appContext?.jsLogger)
lazy var zoomAlignmentViewRepository = LinkZoomTransitionsAlignmentViewRepository()
public func definition() -> ModuleDefinition {
Name(LinkPreviewNativeModule.moduleName)
View(NativeLinkPreviewView.self) {
Prop("nextScreenId") { (view: NativeLinkPreviewView, nextScreenId: String) in
view.nextScreenId = nextScreenId
}
Prop("tabPath") { (view: NativeLinkPreviewView, tabPath: TabPathPayload) in
view.tabPath = tabPath
}
Prop("disableForceFlatten") { (_: NativeLinkPreviewView, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
Events(
"onPreviewTapped",
"onPreviewTappedAnimationCompleted",
"onWillPreviewOpen",
"onDidPreviewOpen",
"onPreviewWillClose",
"onPreviewDidClose"
)
}
View(NativeLinkPreviewContentView.self) {
Prop("preferredContentSize") { (view: NativeLinkPreviewContentView, size: [String: Float]) in
let width = size["width", default: 0]
let height = size["height", default: 0]
guard width >= 0, height >= 0 else {
view.logger?.warn("[expo-router] Preferred content size cannot be negative (\(width), \(height))")
return
}
view.preferredContentSize = CGSize(
width: CGFloat(width),
height: CGFloat(height)
)
}
}
View(LinkPreviewNativeActionView.self) {
Prop("title") { (view: LinkPreviewNativeActionView, title: String) in
view.title = title
}
Prop("label") { (view: LinkPreviewNativeActionView, label: String?) in
view.label = label
}
Prop("identifier") { (view: LinkPreviewNativeActionView, identifier: String) in
view.identifier = identifier
}
Prop("icon") { (view: LinkPreviewNativeActionView, icon: String?) in
view.icon = icon
}
Prop("xcassetName") { (view: LinkPreviewNativeActionView, xcassetName: String?) in
view.xcassetName = xcassetName
}
Prop("image") { (view: LinkPreviewNativeActionView, image: SharedRef<UIImage>?) in
view.customImage = image
}
Prop("imageRenderingMode") { (view: LinkPreviewNativeActionView, mode: ImageRenderingMode?) in
view.imageRenderingMode = mode
}
Prop("disabled") { (view: LinkPreviewNativeActionView, disabled: Bool?) in
view.disabled = disabled ?? false
}
Prop("destructive") { (view: LinkPreviewNativeActionView, destructive: Bool?) in
view.destructive = destructive
}
Prop("discoverabilityLabel") { (view: LinkPreviewNativeActionView, label: String?) in
view.discoverabilityLabel = label
}
Prop("subtitle") { (view: LinkPreviewNativeActionView, subtitle: String?) in
view.subtitle = subtitle
}
Prop("accessibilityLabel") { (view: LinkPreviewNativeActionView, label: String?) in
view.accessibilityLabelForMenu = label
}
Prop("accessibilityHint") { (view: LinkPreviewNativeActionView, hint: String?) in
view.accessibilityHintForMenu = hint
}
Prop("singleSelection") { (view: LinkPreviewNativeActionView, singleSelection: Bool?) in
view.singleSelection = singleSelection ?? false
}
Prop("displayAsPalette") { (view: LinkPreviewNativeActionView, displayAsPalette: Bool?) in
view.displayAsPalette = displayAsPalette ?? false
}
Prop("isOn") { (view: LinkPreviewNativeActionView, isOn: Bool?) in
view.isOn = isOn
}
Prop("keepPresented") { (view: LinkPreviewNativeActionView, keepPresented: Bool?) in
view.keepPresented = keepPresented
}
Prop("displayInline") { (view: LinkPreviewNativeActionView, displayInline: Bool?) in
view.displayInline = displayInline ?? false
}
Prop("hidden") { (view: LinkPreviewNativeActionView, hidden: Bool?) in
view.routerHidden = hidden ?? false
}
Prop("sharesBackground") { (view: LinkPreviewNativeActionView, sharesBackground: Bool?) in
view.sharesBackground = sharesBackground
}
Prop("hidesSharedBackground") { (view: LinkPreviewNativeActionView, hidesSharedBackground: Bool?) in
view.hidesSharedBackground = hidesSharedBackground
}
Prop("tintColor") { (view: LinkPreviewNativeActionView, tintColor: UIColor?) in
view.customTintColor = tintColor
}
Prop("barButtonItemStyle") { (view: LinkPreviewNativeActionView, style: BarItemStyle?) in
view.barButtonItemStyle = style?.toUIBarButtonItemStyle()
}
Prop("preferredElementSize") { (view: LinkPreviewNativeActionView, preferredElementSize: MenuElementSize?) in
view.preferredElementSize = preferredElementSize
}
Events("onSelected")
}
View(LinkZoomTransitionSource.self) {
Prop("disableForceFlatten") { (_: LinkZoomTransitionSource, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
Prop("identifier") { (view: LinkZoomTransitionSource, identifier: String) in
view.identifier = identifier
}
Prop("alignment") { (view: LinkZoomTransitionSource, alignment: LinkSourceAlignmentRect?) in
if let alignment = alignment {
view.alignment = CGRect(
x: alignment.x,
y: alignment.y,
width: alignment.width,
height: alignment.height
)
} else {
view.alignment = nil
}
}
Prop("animateAspectRatioChange") { (view: LinkZoomTransitionSource, value: Bool?) in
view.animateAspectRatioChange = value ?? false
}
}
View(LinkZoomTransitionEnabler.self) {
Prop("zoomTransitionSourceIdentifier") {
(view: LinkZoomTransitionEnabler, identifier: String) in
view.zoomTransitionSourceIdentifier = identifier
}
Prop("disableForceFlatten") { (_: LinkZoomTransitionEnabler, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
Prop("dismissalBoundsRect") { (view: LinkZoomTransitionEnabler, rect: DismissalBoundsRect?) in
view.dismissalBoundsRect = rect
}
}
View(LinkZoomTransitionAlignmentRectDetector.self) {
Prop("identifier") {
(view: LinkZoomTransitionAlignmentRectDetector, identifier: String) in
view.identifier = identifier
}
Prop("disableForceFlatten") { (_: LinkZoomTransitionAlignmentRectDetector, _: Bool) in
// This prop is used in ExpoShadowNode in order to disable force flattening, when display: contents is used
}
}
}
}
struct TabPathPayload: Record {
@Field var path: [TabStatePath]
}
struct TabStatePath: Record {
@Field var oldTabKey: String
@Field var newTabKey: String
}
struct LinkSourceAlignmentRect: Record {
@Field var x: Double
@Field var y: Double
@Field var width: Double
@Field var height: Double
}
enum MenuElementSize: String, Enumerable {
case small
case medium
case large
case auto
@available(iOS 16.0, *)
func toUIMenuElementSize() -> UIMenu.ElementSize {
switch self {
case .small:
return .small
case .medium:
return .medium
case .large:
return .large
case .auto:
if #available(iOS 17.0, *) {
return .automatic
} else {
return .medium
}
}
}
}
struct DismissalBoundsRect: Record {
@Field var minX: Double?
@Field var maxX: Double?
@Field var minY: Double?
@Field var maxY: Double?
}

View File

@@ -0,0 +1,190 @@
import ExpoModulesCore
import RNScreens
import UIKit
struct TabChangeCommand {
weak var tabBarController: UITabBarController?
let tabIndex: Int
}
internal class LinkPreviewNativeNavigation {
private weak var preloadedScreenView: RNSScreenView?
private weak var preloadedStackView: RNSScreenStackView?
private var tabChangeCommands: [TabChangeCommand] = []
private let logger: ExpoModulesCore.Logger?
init(logger: ExpoModulesCore.Logger?) {
self.logger = logger
}
func pushPreloadedView() {
self.performTabChanges()
guard let preloadedScreenView,
let preloadedStackView
else {
// Check if there were any tab change commands to perform
// If there were, the preview transition could be to a different tab only
if self.tabChangeCommands.isEmpty {
logger?.warn(
"[expo-router] No preloaded screen view to push. Link.Preview transition is only supported inside a native stack or native tabs navigators."
)
}
return
}
// Instead of pushing the preloaded screen view, we set its activity state
// React native screens will then handle the rest.
preloadedScreenView.activityState = Int32(RNSActivityState.onTop.rawValue)
preloadedStackView.markChildUpdated()
self.pushModalInnerScreenIfNeeded(screenView: preloadedScreenView)
}
func updatePreloadedView(screenId: String?, tabPath: TabPathPayload?, responder: UIView) {
self.tabChangeCommands = []
let oldTabKeys = tabPath?.path.map { $0.oldTabKey } ?? []
let stackOrTabView = findStackViewWithScreenIdOrTabBarController(
screenId: screenId, tabKeys: oldTabKeys, responder: responder)
guard let stackOrTabView else {
return
}
if RNScreensTabCompat.isTabScreen(stackOrTabView) {
let newTabKeys = tabPath?.path.map { $0.newTabKey } ?? []
// The order is important here. findStackViewWithScreenIdInSubViews must be called
// even if screenId is nil to compute the tabChangeCommands.
if let stackView = findStackViewWithScreenIdInSubViews(
screenId: screenId, tabKeys: newTabKeys, rootView: stackOrTabView), let screenId {
setPreloadedView(stackView: stackView, screenId: screenId)
}
} else if let stackView = stackOrTabView as? RNSScreenStackView, let screenId {
setPreloadedView(stackView: stackView, screenId: screenId)
}
}
private func performTabChanges() {
self.tabChangeCommands.forEach { command in
command.tabBarController?.selectedIndex = command.tabIndex
}
}
// If screen is a modal with header, it will have an inner stack screen
// https://github.com/software-mansion/react-native-screens/blob/8b82e081e8fdfa6e0864821134bda9e87a745b00/src/components/ScreenStackItem.tsx#L146-L160
// In this case we need to set the activity state of the inner screen as well.
private func pushModalInnerScreenIfNeeded(screenView: RNSScreenView) {
// If the screen is modal with header then it will have exactly one child - RNSNavigationController.
if screenView.isModal() && screenView.controller.children.count == 1 {
// To get the inner screen stack we need to go through RNSNavigationController.
// The structure is as follows:
// RNSScreenView (preloadedScreenView)
// RNSNavigationController (outer stack)
// RNSScreenStackView (innerScreenStack)
if let rnsNavController = screenView.controller.children.first
as? RNSNavigationController,
// The delegate of RNSNavigationController is RNSScreenStackView.
let innerScreenStack = rnsNavController.delegate as? RNSScreenStackView,
// The first and only child of the inner screen stack should be
// RNSScreenView (<ScreenStackItem>).
let screenContentView = innerScreenStack.reactSubviews().first as? RNSScreenView {
// Same as above, we let React Native Screens handle the transition.
// We need to set the activity of inner screen as well, because its
// react value is the same as the preloaded screen - 0.
// https://github.com/software-mansion/react-native-screens/blob/8b82e081e8fdfa6e0864821134bda9e87a745b00/src/components/ScreenStackItem.tsx#L151
screenContentView.activityState = Int32(RNSActivityState.onTop.rawValue)
innerScreenStack.markChildUpdated()
}
}
}
private func setPreloadedView(
stackView: RNSScreenStackView, screenId: String
) {
let screenViews = stackView.reactSubviews()
if let screenView = screenViews?.first(where: {
($0 as? RNSScreenView)?.screenId == screenId
}) as? RNSScreenView {
preloadedScreenView = screenView
preloadedStackView = stackView
}
}
// Allowing for null screenId to support preloading tab navigators
// Even if the desired screenId is not found, we still need to compute the tabChangeCommands
private func findStackViewWithScreenIdInSubViews(
screenId: String?, tabKeys: [String], rootView: UIView
) -> RNSScreenStackView? {
if let rootView = rootView as? RNSScreenStackView,
let screenId {
if rootView.screenIds.contains(screenId) {
return rootView
}
} else if let tabBarController = getTabBarControllerFromTabView(view: rootView) {
if let (tabIndex, tabView) = getIndexAndViewOfFirstTabWithKey(
tabBarController: tabBarController, tabKeys: tabKeys) {
self.tabChangeCommands.append(
TabChangeCommand(tabBarController: tabBarController, tabIndex: tabIndex))
for subview in tabView.subviews {
if let result = findStackViewWithScreenIdInSubViews(
screenId: screenId, tabKeys: tabKeys, rootView: subview) {
return result
}
}
}
} else {
for subview in rootView.subviews {
let result = findStackViewWithScreenIdInSubViews(
screenId: screenId, tabKeys: tabKeys, rootView: subview)
if result != nil {
return result
}
}
}
return nil
}
private func getIndexAndViewOfFirstTabWithKey(
tabBarController: UITabBarController, tabKeys: [String]
) -> (tabIndex: Int, tabView: UIView)? {
let views = tabBarController.viewControllers?.compactMap { $0.view } ?? []
let enumeratedViews = views.enumerated()
if let result =
enumeratedViews
.first(where: { _, view in
guard let tabKey = RNScreensTabCompat.tabKey(from: view) else {
return false
}
return tabKeys.contains(tabKey)
}) {
return (result.offset, result.element)
}
return nil
}
private func getTabBarControllerFromTabView(view: UIView) -> UITabBarController? {
if let tabBarController = RNScreensTabCompat.tabBarController(fromTabScreen: view) {
return tabBarController
}
return RNScreensTabCompat.tabBarController(fromTabHost: view)
}
private func findStackViewWithScreenIdOrTabBarController(
screenId: String?, tabKeys: [String], responder: UIView
) -> UIView? {
var currentResponder: UIResponder? = responder
while let nextResponder = currentResponder?.next {
if let view = nextResponder as? RNSScreenStackView,
let screenId {
if view.screenIds.contains(screenId) {
return view
}
} else if let nextView = nextResponder as? UIView,
let tabKey = RNScreensTabCompat.tabKey(from: nextView),
tabKeys.contains(tabKey) {
return nextView
}
currentResponder = nextResponder
}
return nil
}
}

View File

@@ -0,0 +1,9 @@
import ExpoModulesCore
class NativeLinkPreviewContentView: RouterViewWithLogger {
var preferredContentSize: CGSize = .zero
func setInitialSize(bounds: CGRect) {
self.setShadowNodeSize(Float(bounds.width), height: Float(bounds.height))
}
}

View File

@@ -0,0 +1,246 @@
import ExpoModulesCore
import RNScreens
class NativeLinkPreviewView: RouterViewWithLogger, UIContextMenuInteractionDelegate,
RNSDismissibleModalProtocol, LinkPreviewMenuUpdatable {
private var preview: NativeLinkPreviewContentView?
private var interaction: UIContextMenuInteraction?
var directChild: UIView?
var nextScreenId: String? {
didSet {
performUpdateOfPreloadedView()
}
}
var tabPath: TabPathPayload? {
didSet {
performUpdateOfPreloadedView()
}
}
private var actions: [LinkPreviewNativeActionView] = []
private lazy var linkPreviewNativeNavigation: LinkPreviewNativeNavigation = {
return LinkPreviewNativeNavigation(logger: logger)
}()
let onPreviewTapped = EventDispatcher()
let onPreviewTappedAnimationCompleted = EventDispatcher()
let onWillPreviewOpen = EventDispatcher()
let onDidPreviewOpen = EventDispatcher()
let onPreviewWillClose = EventDispatcher()
let onPreviewDidClose = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.interaction = UIContextMenuInteraction(delegate: self)
}
// MARK: - LinkPreviewModalDismissable
func isDismissible() -> Bool {
return false
}
// MARK: - Props
func performUpdateOfPreloadedView() {
if nextScreenId == nil && tabPath?.path.isEmpty != false {
// If we have no tab to change and no screen to push, then we can't update the preloaded view
return
}
// However if one these is defined then we can perform the native update
linkPreviewNativeNavigation.updatePreloadedView(
screenId: nextScreenId, tabPath: tabPath, responder: self)
}
// MARK: - Children
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
if let previewView = childComponentView as? NativeLinkPreviewContentView {
preview = previewView
} else if let actionView = childComponentView as? LinkPreviewNativeActionView {
actionView.parentMenuUpdatable = self
actions.append(actionView)
} else {
if directChild != nil {
logger?.warn(
"[expo-router] Found a second child of <Link.Trigger>. Only one is allowed. This is most likely a bug in expo-router."
)
return
}
directChild = childComponentView
if let interaction = self.interaction {
if let indirectTrigger = childComponentView as? LinkPreviewIndirectTriggerProtocol {
indirectTrigger.indirectTrigger?.addInteraction(interaction)
} else {
childComponentView.addInteraction(interaction)
}
}
super.mountChildComponentView(childComponentView, index: index)
}
}
override func unmountChildComponentView(_ child: UIView, index: Int) {
if child is NativeLinkPreviewContentView {
preview = nil
} else if let actionView = child as? LinkPreviewNativeActionView {
actions.removeAll(where: {
$0 == actionView
})
} else {
if let directChild = directChild {
if directChild != child {
logger?.warn(
"[expo-router] Unmounting unexpected child from <Link.Trigger>. This is most likely a bug in expo-router."
)
return
}
if let interaction = self.interaction {
if let indirectTrigger = directChild as? LinkPreviewIndirectTriggerProtocol {
indirectTrigger.indirectTrigger?.removeInteraction(interaction)
} else {
directChild.removeInteraction(interaction)
}
}
super.unmountChildComponentView(child, index: index)
} else {
logger?.warn(
"[expo-router] No link child found to unmount. This is most likely a bug in expo-router."
)
return
}
}
}
// MARK: - UIContextMenuInteractionDelegate
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
onWillPreviewOpen()
return UIContextMenuConfiguration(
identifier: nil,
previewProvider: { [weak self] in
self?.createPreviewViewController()
},
actionProvider: { [weak self] _ in
self?.createContextMenu()
})
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configuration: UIContextMenuConfiguration,
highlightPreviewForItemWithIdentifier identifier: any NSCopying
) -> UITargetedPreview? {
if let superview, let directChild {
let triggerView: UIView =
(directChild as? LinkPreviewIndirectTriggerProtocol)?.indirectTrigger ?? directChild
let target = UIPreviewTarget(
container: superview, center: self.convert(triggerView.center, to: superview))
let parameters = UIPreviewParameters()
parameters.backgroundColor = triggerView.backgroundColor ?? .clear
return UITargetedPreview(view: triggerView, parameters: parameters, target: target)
}
return nil
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willDisplayMenuFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?
) {
// This happens when preview starts to become visible.
// It is not yet fully extended at this moment though
self.onDidPreviewOpen()
animator?.addCompletion {
// This happens around a second after the preview is opened and thus gives us no real value
// User could have already interacted with preview beforehand
}
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?
) {
onPreviewWillClose()
animator?.addCompletion {
self.onPreviewDidClose()
}
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionCommitAnimating
) {
if preview != nil {
self.onPreviewTapped()
animator.addCompletion { [weak self] in
self?.linkPreviewNativeNavigation.pushPreloadedView()
self?.onPreviewTappedAnimationCompleted()
}
}
}
// MARK: - Context Menu Helpers
private func createPreviewViewController() -> UIViewController? {
guard let preview = preview else {
return nil
}
let vc = PreviewViewController(linkPreviewNativePreview: preview)
let preferredSize = preview.preferredContentSize
vc.preferredContentSize.width = preferredSize.width
vc.preferredContentSize.height = preferredSize.height
return vc
}
func updateMenu() {
self.interaction?.updateVisibleMenu { _ in
self.createContextMenu()
}
}
private func createContextMenu() -> UIMenu {
if actions.count == 1, let menu = actions[0].uiAction as? UIMenu {
return menu
}
return UIMenu(
title: "",
children: actions.map { action in
action.uiAction
}
)
}
}
class PreviewViewController: UIViewController {
private let linkPreviewNativePreview: NativeLinkPreviewContentView
init(linkPreviewNativePreview: NativeLinkPreviewContentView) {
self.linkPreviewNativePreview = linkPreviewNativePreview
super.init(nibName: nil, bundle: nil)
}
override func loadView() {
self.view = linkPreviewNativePreview
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// TODO: Consider using setViewSize from ExpoFabricView
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
linkPreviewNativePreview.setInitialSize(bounds: self.view.bounds)
}
}
protocol LinkPreviewIndirectTriggerProtocol {
var indirectTrigger: UIView? { get }
}

View File

@@ -0,0 +1,434 @@
import ExpoModulesCore
import RNScreens
import UIKit
class LinkSourceInfo {
var alignment: CGRect?
var animateAspectRatioChange: Bool
weak var view: UIView?
init(view: UIView, alignment: CGRect?, animateAspectRatioChange: Bool) {
self.view = view
self.alignment = alignment
self.animateAspectRatioChange = animateAspectRatioChange
}
}
class LinkZoomTransitionsSourceRepository {
private var sources: [String: LinkSourceInfo] = [:]
private let lock = NSLock()
private weak var logger: ExpoModulesCore.Logger?
init(logger: ExpoModulesCore.Logger?) {
self.logger = logger
}
func registerSource(
identifier: String,
source: LinkSourceInfo
) {
lock.lock()
defer { lock.unlock() }
if sources[identifier] != nil {
logger?.warn(
"[expo-router] Link.AppleZoom with identifier \(identifier) is already registered. This means that you used two sources for the same target, which is not supported and may lead to unexpected behavior."
)
}
if !identifier.isEmpty {
sources[identifier] = source
}
}
func unregisterSource(identifier: String) {
lock.lock()
defer { lock.unlock() }
sources.removeValue(forKey: identifier)
}
func getSource(identifier: String) -> LinkSourceInfo? {
lock.lock()
defer { lock.unlock() }
return sources[identifier]
}
func updateIdentifier(
oldIdentifier: String,
newIdentifier: String
) {
lock.lock()
defer { lock.unlock() }
if let source = sources[oldIdentifier] {
if !newIdentifier.isEmpty {
sources[newIdentifier] = source
}
sources.removeValue(forKey: oldIdentifier)
}
}
func updateAlignment(
identifier: String,
alignment: CGRect?
) {
lock.lock()
defer { lock.unlock() }
if let source = sources[identifier], !identifier.isEmpty {
source.alignment = alignment
}
}
func updateAnimateAspectRatioChange(
identifier: String,
animateAspectRatioChange: Bool
) {
lock.lock()
defer { lock.unlock() }
if let source: LinkSourceInfo = sources[identifier], !identifier.isEmpty {
source.animateAspectRatioChange = animateAspectRatioChange
}
}
}
class LinkZoomTransitionsAlignmentViewRepository {
private var alignmentViews: [String: WeakUIView] = [:]
private let lock = NSLock()
init() {}
func addIfNotExists(
identifier: String,
alignmentView: UIView
) {
lock.lock()
defer { lock.unlock() }
if alignmentViews[identifier] == nil && !identifier.isEmpty {
alignmentViews[identifier] = WeakUIView(view: alignmentView)
}
}
func removeIfSame(
identifier: String,
alignmentView: UIView
) {
lock.lock()
defer { lock.unlock() }
if let existing = alignmentViews[identifier], existing.view === alignmentView {
alignmentViews.removeValue(forKey: identifier)
}
}
func get(identifier: String) -> UIView? {
lock.lock()
defer { lock.unlock() }
return alignmentViews[identifier]?.view
}
private class WeakUIView {
weak var view: UIView?
init(view: UIView) {
self.view = view
}
}
}
class LinkZoomTransitionSource: LinkZoomExpoView, LinkPreviewIndirectTriggerProtocol {
var child: UIView?
var indirectTrigger: UIView? {
return child
}
var alignment: CGRect? {
didSet {
if child != nil {
sourceRepository?.updateAlignment(
identifier: identifier,
alignment: alignment
)
}
}
}
var animateAspectRatioChange: Bool = false {
didSet {
if child != nil {
sourceRepository?.updateAnimateAspectRatioChange(
identifier: identifier,
animateAspectRatioChange: animateAspectRatioChange
)
}
}
}
var identifier: String = "" {
didSet {
guard identifier != oldValue else { return }
if let child {
if oldValue.isEmpty {
sourceRepository?.registerSource(
identifier: identifier,
source: LinkSourceInfo(
view: child, alignment: alignment,
animateAspectRatioChange: animateAspectRatioChange)
)
} else {
sourceRepository?.updateIdentifier(
oldIdentifier: oldValue,
newIdentifier: identifier
)
}
} else {
sourceRepository?.unregisterSource(
identifier: oldValue
)
}
}
}
override func mountChildComponentView(
_ childComponentView: UIView,
index: Int
) {
guard child == nil else {
logger?.warn(
"[expo-router] Link.AppleZoom can only have a single native child. If you passed a single child, consider adding collapsible={false} to your component"
)
return
}
child = childComponentView
sourceRepository?.registerSource(
identifier: identifier,
source: LinkSourceInfo(
view: childComponentView, alignment: alignment,
animateAspectRatioChange: animateAspectRatioChange)
)
super.mountChildComponentView(childComponentView, index: index)
}
override func unmountChildComponentView(_ child: UIView, index: Int) {
guard child == self.child else {
return
}
self.child = nil
sourceRepository?.unregisterSource(
identifier: identifier
)
super.unmountChildComponentView(child, index: index)
}
}
class LinkZoomTransitionAlignmentRectDetector: LinkZoomExpoView {
private var child: UIView?
var identifier: String = "" {
didSet {
if oldValue != identifier && !oldValue.isEmpty {
logger?.warn(
"[expo-router] LinkZoomTransitionAlignmentRectDetector does not support changing the identifier after it has been set. This is most likely an internal bug in expo-router."
)
return
}
if let child = child {
alignmentViewRepository?.addIfNotExists(
identifier: identifier,
alignmentView: child
)
}
}
}
override func mountChildComponentView(
_ childComponentView: UIView,
index: Int
) {
guard child == nil else {
logger?.warn(
"[expo-router] Link.AppleZoomTarget can only have a single native child. If you passed a single child, consider adding collapsible={false} to your component"
)
return
}
if !identifier.isEmpty {
alignmentViewRepository?.addIfNotExists(
identifier: identifier,
alignmentView: childComponentView
)
}
self.child = childComponentView
super.mountChildComponentView(childComponentView, index: index)
}
override func unmountChildComponentView(_ child: UIView, index: Int) {
guard child == self.child else {
return
}
self.child = nil
alignmentViewRepository?.removeIfSame(
identifier: identifier,
alignmentView: child
)
super.unmountChildComponentView(child, index: index)
}
}
class LinkZoomTransitionEnabler: LinkZoomExpoView {
var zoomTransitionSourceIdentifier: String = ""
var dismissalBoundsRect: DismissalBoundsRect? {
didSet {
// When dismissalBoundsRect changes, re-setup the zoom transition
// to include/exclude interactiveDismissShouldBegin callback
if superview != nil {
DispatchQueue.main.async {
self.setupZoomTransition()
}
}
}
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview != nil {
// Need to run this async. Otherwise the view has no view controller yet
DispatchQueue.main.async {
self.setupZoomTransition()
}
}
}
private func setupZoomTransition() {
if self.zoomTransitionSourceIdentifier.isEmpty {
logger?.warn("[expo-router] No zoomTransitionSourceIdentifier passed to LinkZoomTransitionEnabler. This is most likely a bug in expo-router.")
return
}
if let controller = self.findViewController() {
if #available(iOS 18.0, *) {
let options = UIViewController.Transition.ZoomOptions()
options.alignmentRectProvider = { context in
guard
let sourceInfo = self.sourceRepository?.getSource(
identifier: self.zoomTransitionSourceIdentifier)
else {
return nil
}
guard
let alignmentView = self.alignmentViewRepository?.get(
identifier: self.zoomTransitionSourceIdentifier)
else {
return sourceInfo.alignment
}
let rect = alignmentView.convert(
alignmentView.bounds,
to: context.zoomedViewController.view
)
if sourceInfo.animateAspectRatioChange,
let sourceView = sourceInfo.view {
return self.calculateAdjustedRect(rect, toMatch: sourceView.bounds.size)
}
return rect
}
// Only set up interactiveDismissShouldBegin when dismissalBoundsRect is set
// If dismissalBoundsRect is nil, don't set the callback - iOS uses default behavior
if let rect = self.dismissalBoundsRect {
options.interactiveDismissShouldBegin = { context in
let location = context.location
// Check each optional bound independently
if let minX = rect.minX, location.x < minX { return false }
if let maxX = rect.maxX, location.x > maxX { return false }
if let minY = rect.minY, location.y < minY { return false }
if let maxY = rect.maxY, location.y > maxY { return false }
return true
}
}
controller.preferredTransition = .zoom(options: options) { _ in
let sourceInfo = self.sourceRepository?.getSource(
identifier: self.zoomTransitionSourceIdentifier)
var view: UIView? = sourceInfo?.view
if let linkPreviewView = view as? NativeLinkPreviewView {
view = linkPreviewView.directChild
}
guard let view else {
self.logger?.warn(
"[expo-router] No source view found for identifier \(self.zoomTransitionSourceIdentifier) to enable zoom transition. This is most likely a bug in expo-router."
)
return nil
}
return view
}
return
}
} else {
logger?.warn("[expo-router] No navigation controller found to enable zoom transition. This is most likely a bug in expo-router.")
}
}
private func calculateAdjustedRect(
_ rect: CGRect, toMatch sourceSize: CGSize
) -> CGRect {
guard sourceSize.width > 0, sourceSize.height > 0,
rect.width > 0, rect.height > 0
else {
return rect
}
let sourceAspectRatio = sourceSize.width / sourceSize.height
let rectAspectRatio = rect.width / rect.height
if abs(sourceAspectRatio - rectAspectRatio) < 0.001 {
return rect // Aspect ratios are essentially equal
}
if rectAspectRatio > sourceAspectRatio {
// Rect is wider - adjust width
let adjustedWidth = rect.height * sourceAspectRatio
return CGRect(
x: rect.midX - (adjustedWidth / 2),
y: rect.origin.y,
width: adjustedWidth,
height: rect.height
)
}
// Rect is taller - adjust height
let adjustedHeight = rect.width / sourceAspectRatio
return CGRect(
x: rect.origin.x,
y: rect.midY - (adjustedHeight / 2),
width: rect.width,
height: adjustedHeight
)
}
private func findViewController() -> RNSScreen? {
var responder: UIResponder? = self
while let r = responder {
if let r = r as? RNSScreen {
return r
}
responder = r.next
}
return nil
}
}
class LinkZoomExpoView: RouterViewWithLogger {
var module: LinkPreviewNativeModule? {
return appContext?.moduleRegistry.get(moduleWithName: LinkPreviewNativeModule.moduleName)
as? LinkPreviewNativeModule
}
var sourceRepository: LinkZoomTransitionsSourceRepository? {
guard let module else {
logger?.warn("[expo-router] LinkPreviewNativeModule not loaded. Make sure expo-router is properly configured.")
return nil
}
return module.zoomSourceRepository
}
var alignmentViewRepository: LinkZoomTransitionsAlignmentViewRepository? {
guard let module else {
logger?.warn("[expo-router] LinkPreviewNativeModule not loaded. Make sure expo-router is properly configured.")
return nil
}
return module.zoomAlignmentViewRepository
}
}

View File

@@ -0,0 +1,42 @@
import UIKit
/// Instead of casting to concrete class names (which break on renames),
/// we detect tab views by checking `responds(to:)` for expected selectors
/// and read properties via KVC.
enum RNScreensTabCompat {
private static let tabKeyName = "tabKey"
private static let controllerName = "controller"
private static let reactViewControllerName = "reactViewController"
private static let tabKeySelector = NSSelectorFromString(tabKeyName)
private static let controllerSelector = NSSelectorFromString(controllerName)
private static let reactViewControllerSelector = NSSelectorFromString(reactViewControllerName)
// MARK: - Type check
/// A view is a tab screen if it has a `tabKey` property specific to RNScreens tab views.
static func isTabScreen(_ view: UIView) -> Bool {
view.responds(to: tabKeySelector)
}
// MARK: - Property access via KVC
static func tabKey(from view: UIView) -> String? {
guard view.responds(to: tabKeySelector) else { return nil }
return view.value(forKey: tabKeyName) as? String
}
/// Calls `reactViewController()` dynamically via `perform(_:)`, then returns `.tabBarController`.
static func tabBarController(fromTabScreen view: UIView) -> UITabBarController? {
guard isTabScreen(view),
view.responds(to: reactViewControllerSelector)
else { return nil }
let vc = view.perform(reactViewControllerSelector)?.takeUnretainedValue() as? UIViewController
return vc?.tabBarController
}
static func tabBarController(fromTabHost view: UIView) -> UITabBarController? {
guard view.responds(to: controllerSelector) else { return nil }
return view.value(forKey: controllerName) as? UITabBarController
}
}

View File

@@ -0,0 +1,5 @@
import ExpoModulesCore
class RouterViewWithLogger: ExpoView {
lazy var logger = appContext?.jsLogger
}

View File

@@ -0,0 +1,218 @@
import Testing
import UIKit
@testable import ExpoRouter
// MARK: - Mock views
/// Mock tab screen: has @objc tabKey property, mimicking RNScreens tab screen views.
private class MockTabScreenView: UIView {
@objc var tabKey: String?
}
/// Mock tab host: has @objc controller property, mimicking RNScreens tab host views.
private class MockTabHostView: UIView {
@objc var controller: UIViewController?
}
/// Mock tab host with a non-UIViewController controller property.
private class MockTabHostWithBadController: UIView {
@objc var controller: NSObject? = NSObject()
}
/// Mock view with a reactViewController() method that returns a UIViewController.
private class MockTabScreenWithReactVC: UIView {
@objc var tabKey: String?
private var _reactViewController: UIViewController?
func configure(reactViewController: UIViewController) {
_reactViewController = reactViewController
}
@objc override func reactViewController() -> UIViewController? {
return _reactViewController
}
}
// MARK: - Unit tests
@Suite("RNScreensTabCompat unit tests")
struct RNScreensTabCompatUnitTests {
@Suite("isTabScreen")
struct IsTabScreen {
@Test
func `detects mock tab screen`() {
let tabScreen = MockTabScreenView()
#expect(RNScreensTabCompat.isTabScreen(tabScreen))
}
@Test
func `rejects plain UIView`() {
let plainView = UIView()
#expect(!RNScreensTabCompat.isTabScreen(plainView))
}
@Test
func `rejects mock tab host`() {
let tabHost = MockTabHostView()
#expect(!RNScreensTabCompat.isTabScreen(tabHost))
}
}
@Suite("tabKey")
struct TabKey {
@Test
func `reads value`() {
let tabScreen = MockTabScreenView()
tabScreen.tabKey = "home"
#expect(RNScreensTabCompat.tabKey(from: tabScreen) == "home")
}
@Test
func `returns nil for nil tab key`() {
let tabScreen = MockTabScreenView()
tabScreen.tabKey = nil
#expect(RNScreensTabCompat.tabKey(from: tabScreen) == nil)
}
@Test
func `returns nil for plain UIView`() {
let plainView = UIView()
#expect(RNScreensTabCompat.tabKey(from: plainView) == nil)
}
}
@Suite("tabBarController(fromTabHost:)")
struct TabBarControllerFromTabHost {
@Test
func `returns tab bar controller`() {
let tabHost = MockTabHostView()
let tabBarController = UITabBarController()
tabHost.controller = tabBarController
#expect(RNScreensTabCompat.tabBarController(fromTabHost: tabHost) === tabBarController)
}
@Test
func `returns nil for non-tab bar controller`() {
let tabHost = MockTabHostView()
tabHost.controller = UIViewController()
#expect(RNScreensTabCompat.tabBarController(fromTabHost: tabHost) == nil)
}
@Test
func `returns nil for nil controller`() {
let tabHost = MockTabHostView()
tabHost.controller = nil
#expect(RNScreensTabCompat.tabBarController(fromTabHost: tabHost) == nil)
}
@Test
func `returns nil for non-UIViewController type`() {
let tabHost = MockTabHostWithBadController()
#expect(RNScreensTabCompat.tabBarController(fromTabHost: tabHost) == nil)
}
@Test
func `returns nil for plain UIView`() {
let plainView = UIView()
#expect(RNScreensTabCompat.tabBarController(fromTabHost: plainView) == nil)
}
}
@Suite("tabBarController(fromTabScreen:)")
struct TabBarControllerFromTabScreen {
@Test
func `returns tab bar controller via reactViewController`() {
let tabBarController = UITabBarController()
let childVC = UIViewController()
tabBarController.viewControllers = [childVC]
let mockView = MockTabScreenWithReactVC()
mockView.tabKey = "tab1"
mockView.configure(reactViewController: childVC)
childVC.view.addSubview(mockView)
let result = RNScreensTabCompat.tabBarController(fromTabScreen: mockView)
#expect(result === tabBarController)
}
@Test
func `returns nil when reactViewController returns nil`() {
let mockView = MockTabScreenWithReactVC()
mockView.tabKey = "tab1"
// Don't configure reactViewController() returns nil
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: mockView) == nil)
}
@Test
func `returns nil when no reactViewController method`() {
// MockTabScreenView has tabKey but no reactViewController() method
let mockView = MockTabScreenView()
mockView.tabKey = "tab1"
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: mockView) == nil)
}
@Test
func `returns nil for plain UIView`() {
let plainView = UIView()
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: plainView) == nil)
}
@Test
func `returns nil when not in tab bar controller`() {
let navController = UINavigationController()
let childVC = UIViewController()
navController.viewControllers = [childVC]
let mockView = MockTabScreenWithReactVC()
mockView.tabKey = "tab1"
mockView.configure(reactViewController: childVC)
childVC.view.addSubview(mockView)
#expect(RNScreensTabCompat.tabBarController(fromTabScreen: mockView) == nil)
}
}
}
// MARK: - Integration tests (RNScreens API contract)
@Suite("RNScreens API contract")
struct RNScreensAPIContractTests {
@Test
func `tab screen class responds to tabKey`() throws {
let cls = NSClassFromString("RNSTabsScreenComponentView")
?? NSClassFromString("RNSBottomTabsScreenComponentView")
guard let cls else {
Issue.record("No tab screen class found — neither RNSTabsScreenComponentView nor RNSBottomTabsScreenComponentView")
return
}
let view = try #require((cls as? UIView.Type)?.init(), "Failed to instantiate tab screen class")
#expect(view.responds(to: NSSelectorFromString("tabKey")))
}
@Test
func `tab host class responds to controller`() throws {
let cls = NSClassFromString("RNSTabsHostComponentView")
?? NSClassFromString("RNSBottomTabsHostComponentView")
guard let cls else {
Issue.record("No tab host class found — neither RNSTabsHostComponentView nor RNSBottomTabsHostComponentView")
return
}
let view = try #require((cls as? UIView.Type)?.init(), "Failed to instantiate tab host class")
#expect(view.responds(to: NSSelectorFromString("controller")))
}
@Test
func `tab screen class responds to reactViewController`() throws {
let cls = NSClassFromString("RNSTabsScreenComponentView")
?? NSClassFromString("RNSBottomTabsScreenComponentView")
guard let cls else {
Issue.record("No tab screen class found")
return
}
let view = try #require((cls as? UIView.Type)?.init(), "Failed to instantiate tab screen class")
#expect(view.responds(to: NSSelectorFromString("reactViewController")))
}
}

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
)
}
}