Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/one/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OneLinkPreviewModule"]
}
}
195 changes: 195 additions & 0 deletions packages/one/ios/LinkPreview/LinkPreviewNativeActionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import ExpoModulesCore

class LinkPreviewNativeActionView: RouterViewWithLogger, LinkPreviewMenuUpdatable {
var identifier: String = ""
// MARK: - Shared props
@NativeActionProp(updateAction: true, updateMenu: true) var title: String = ""
@NativeActionProp(updateAction: true, updateMenu: true) var icon: String?
@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
}

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: icon.flatMap { UIImage(systemName: $0) },
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 = icon.flatMap { UIImage(systemName: $0) }
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(
"[one-router] Unknown child component view (\(childComponentView)) mounted to NativeLinkPreviewActionView. This is most likely a bug in one-router."
)
}
}

override func unmountChildComponentView(_ child: UIView, index: Int) {
if let childActionView = child as? LinkPreviewNativeActionView {
subActions.removeAll(where: { $0 == childActionView })
} else {
logger?.warn(
"[one-router] Unknown child component view (\(child)) unmounted from NativeLinkPreviewActionView. This is most likely a bug in one-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
}
}
192 changes: 192 additions & 0 deletions packages/one/ios/LinkPreview/LinkPreviewNativeNavigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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: Logger?

init(logger: 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(
"[one-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 let tabView = stackOrTabView as? RNSBottomTabsScreenComponentView {
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: tabView), 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
// 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.
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 tabView = view as? RNSBottomTabsScreenComponentView, let tabKey = tabView.tabKey
else {
return false
}
return tabKeys.contains(tabKey)
}) {
return (result.offset, result.element)
}
return nil
}

private func getTabBarControllerFromTabView(view: UIView) -> UITabBarController? {
if let tabScreenView = view as? RNSBottomTabsScreenComponentView {
return tabScreenView.reactViewController()?.tabBarController as? UITabBarController
}
if let tabHostView = view as? RNSBottomTabsHostComponentView {
return tabHostView.controller as? UITabBarController
}
return nil
}

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 tabView = nextResponder as? RNSBottomTabsScreenComponentView {
if let tabKey = tabView.tabKey, tabKeys.contains(tabKey) {
return tabView
}
}
currentResponder = nextResponder
}
return nil
}
}
Loading