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