1import ExpoModulesCore
2import UIKit
3
4final class T3PresentationSources {
5 private class Entry {
6 weak var view: UIView?
7 init(_ view: UIView) { self.view = view }
8 }
9
10 private var entries: [String: Entry] = [:]
11
12 func register(_ view: UIView, identifier: String) {
13 entries[identifier] = Entry(view)
14 }
15
16 func remove(_ view: UIView, identifier: String) {
17 if entries[identifier]?.view == nil || entries[identifier]?.view === view {
18 entries.removeValue(forKey: identifier)
19 }
20 }
21
22 func view(for identifier: String) -> UIView? {
23 // Use the child bounds, not the wrapper's potentially stretched layout bounds.
24 entries[identifier]?.view?.subviews.first
25 }
26}
27
28final class T3PresentationSourceView: ExpoView {
29 weak var sources: T3PresentationSources?
30 var identifier = "" {
31 didSet {
32 sources?.remove(self, identifier: oldValue)
33 if !identifier.isEmpty { sources?.register(self, identifier: identifier) }
34 }
35 }
36
37 deinit {
38 sources?.remove(self, identifier: identifier)
39 }
40}
41
42/// Sizes the containing React Native modal without replacing UIKit's sheet interaction.
43final class T3ContextSheetSizeView: ExpoView {
44 var contentHeight: CGFloat = 0 {
45 didSet { updateSheet() }
46 }
47 private weak var configuredSheet: UISheetPresentationController?
48 private var appliedHeight: CGFloat = 0
49
50 override func didMoveToWindow() {
51 super.didMoveToWindow()
52 // The modal's presentation controller is attached after the content view.
53 DispatchQueue.main.async { [weak self] in self?.updateSheet() }
54 }
55
56 override func layoutSubviews() {
57 super.layoutSubviews()
58 updateSheet()
59 }
60
61 private func updateSheet() {
62 guard window != nil, contentHeight > 0 else { return }
63 var responder: UIResponder? = self
64 while let current = responder {
65 if let controller = current as? UIViewController,
66 controller.presentingViewController != nil,
67 let sheet = controller.sheetPresentationController {
68 guard configuredSheet !== sheet || abs(appliedHeight - contentHeight) > 1 else { return }
69 configuredSheet = sheet
70 appliedHeight = contentHeight
71 let height = contentHeight
72 let identifier = UISheetPresentationController.Detent.Identifier("t3-context-content")
73 sheet.animateChanges {
74 sheet.detents = [.custom(identifier: identifier) { context in
75 min(height, context.maximumDetentValue * 0.92)
76 }]
77 sheet.selectedDetentIdentifier = identifier
78 sheet.prefersGrabberVisible = true
79 sheet.prefersScrollingExpandsWhenScrolledToEdge = false
80 }
81 return
82 }
83 responder = current.next
84 }
85 }
86}
87
88func presentFileShare(
89 url: URL,
90 title: String,
91 source: UIView?,
92 presenter: UIViewController,
93 promise: Promise
94) throws {
95 guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else {
96 throw NSError(
97 domain: "T3NativePresentation",
98 code: 1,
99 userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."]
100 )
101 }
102
103 guard let origin = source ?? presenter.view else {
104 throw NSError(
105 domain: "T3NativePresentation",
106 code: 2,
107 userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."]
108 )
109 }
110
111 let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
112 activity.title = title
113 activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle
114 ?? presenter.traitCollection.userInterfaceStyle
115 activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) }
116 if presenter.traitCollection.userInterfaceIdiom == .pad {
117 activity.popoverPresentationController?.sourceView = origin
118 activity.popoverPresentationController?.sourceRect = source?.bounds
119 ?? CGRect(x: origin.bounds.midX, y: origin.bounds.midY, width: 1, height: 1)
120 } else {
121 // Let UIKit adapt the remote share scene to the phone, not an anchored popover.
122 activity.modalPresentationStyle = .automatic
123 }
124 presenter.present(activity, animated: true)
125}