react-native-tinyui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +172 -0
  3. package/Tinyui.podspec +85 -0
  4. package/ios/Core/Tinyui.h +8 -0
  5. package/ios/Core/Tinyui.mm +30 -0
  6. package/ios/Core/TinyuiComponentClasses.mm +27 -0
  7. package/ios/Core/TinyuiHostingHelpers.swift +74 -0
  8. package/ios/Menu/TinyuiMenuProvider.swift +110 -0
  9. package/ios/Menu/TinyuiMenuView.h +14 -0
  10. package/ios/Menu/TinyuiMenuView.mm +120 -0
  11. package/ios/Menu/TinyuiMenuViewSwift.swift +368 -0
  12. package/ios/Popover/TinyuiPopoverProvider.swift +111 -0
  13. package/ios/Popover/TinyuiPopoverView.h +14 -0
  14. package/ios/Popover/TinyuiPopoverView.mm +124 -0
  15. package/ios/Popover/TinyuiPopoverViewSwift.swift +88 -0
  16. package/lib/module/Menu.js +43 -0
  17. package/lib/module/Menu.js.map +1 -0
  18. package/lib/module/Menu.types.js +4 -0
  19. package/lib/module/Menu.types.js.map +1 -0
  20. package/lib/module/MenuNativeComponent.ts +14 -0
  21. package/lib/module/MenuOptions.js +73 -0
  22. package/lib/module/MenuOptions.js.map +1 -0
  23. package/lib/module/NativeTinyui.js +5 -0
  24. package/lib/module/NativeTinyui.js.map +1 -0
  25. package/lib/module/Popover.js +71 -0
  26. package/lib/module/Popover.js.map +1 -0
  27. package/lib/module/Popover.types.js +4 -0
  28. package/lib/module/Popover.types.js.map +1 -0
  29. package/lib/module/PopoverNativeComponent.ts +13 -0
  30. package/lib/module/PopoverPrimitives.js +33 -0
  31. package/lib/module/PopoverPrimitives.js.map +1 -0
  32. package/lib/module/getNativeTinyui.js +30 -0
  33. package/lib/module/getNativeTinyui.js.map +1 -0
  34. package/lib/module/index.js +6 -0
  35. package/lib/module/index.js.map +1 -0
  36. package/lib/module/package.json +1 -0
  37. package/lib/module/react-native.d.js +2 -0
  38. package/lib/module/react-native.d.js.map +1 -0
  39. package/lib/typescript/package.json +1 -0
  40. package/lib/typescript/src/Menu.d.ts +4 -0
  41. package/lib/typescript/src/Menu.d.ts.map +1 -0
  42. package/lib/typescript/src/Menu.types.d.ts +63 -0
  43. package/lib/typescript/src/Menu.types.d.ts.map +1 -0
  44. package/lib/typescript/src/MenuNativeComponent.d.ts +14 -0
  45. package/lib/typescript/src/MenuNativeComponent.d.ts.map +1 -0
  46. package/lib/typescript/src/MenuOptions.d.ts +7 -0
  47. package/lib/typescript/src/MenuOptions.d.ts.map +1 -0
  48. package/lib/typescript/src/NativeTinyui.d.ts +11 -0
  49. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -0
  50. package/lib/typescript/src/Popover.d.ts +7 -0
  51. package/lib/typescript/src/Popover.d.ts.map +1 -0
  52. package/lib/typescript/src/Popover.types.d.ts +35 -0
  53. package/lib/typescript/src/Popover.types.d.ts.map +1 -0
  54. package/lib/typescript/src/PopoverNativeComponent.d.ts +13 -0
  55. package/lib/typescript/src/PopoverNativeComponent.d.ts.map +1 -0
  56. package/lib/typescript/src/PopoverPrimitives.d.ts +21 -0
  57. package/lib/typescript/src/PopoverPrimitives.d.ts.map +1 -0
  58. package/lib/typescript/src/getNativeTinyui.d.ts +13 -0
  59. package/lib/typescript/src/getNativeTinyui.d.ts.map +1 -0
  60. package/lib/typescript/src/index.d.ts +7 -0
  61. package/lib/typescript/src/index.d.ts.map +1 -0
  62. package/package.json +175 -0
  63. package/src/Menu.tsx +52 -0
  64. package/src/Menu.types.ts +77 -0
  65. package/src/MenuNativeComponent.ts +14 -0
  66. package/src/MenuOptions.ts +146 -0
  67. package/src/NativeTinyui.ts +12 -0
  68. package/src/Popover.tsx +92 -0
  69. package/src/Popover.types.ts +49 -0
  70. package/src/PopoverNativeComponent.ts +13 -0
  71. package/src/PopoverPrimitives.tsx +36 -0
  72. package/src/getNativeTinyui.ts +38 -0
  73. package/src/index.tsx +23 -0
  74. package/src/react-native.d.ts +5 -0
@@ -0,0 +1,368 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ /**
5
+ * Observable props shared between the Objective-C++ component view and the
6
+ * SwiftUI `Menu` implementation. Mirror of `TinyuiMenuProps` in codegen.
7
+ */
8
+ class TinyuiMenuProps: ObservableObject {
9
+ @Published var menuConfig: String = "{}"
10
+ @Published var disabled: Bool = false
11
+ @Published var hasPrimaryAction: Bool = false
12
+ @Published var triggerView: UIView?
13
+ }
14
+
15
+ /**
16
+ * SwiftUI `Menu` backed by UIKit's UIMenu, matching expo-ui's MenuView.
17
+ *
18
+ * The React Native trigger (a UIView) is embedded as the menu label through
19
+ * `RepresentableView`, so the tap target remains the native RN subtree while
20
+ * the menu content is built natively from the JSON config.
21
+ *
22
+ * Named distinctly from the Fabric component view `TinyuiMenuView`.
23
+ */
24
+ struct TinyuiMenuSwiftUIView: View {
25
+ @ObservedObject var props: TinyuiMenuProps
26
+ weak var delegate: TinyuiMenuViewDelegate?
27
+
28
+ var body: some View {
29
+ Group {
30
+ if let triggerView = props.triggerView {
31
+ if props.hasPrimaryAction {
32
+ Menu {
33
+ menuContent
34
+ } label: {
35
+ RepresentableView(view: triggerView)
36
+ } primaryAction: {
37
+ delegate?.onPrimaryAction()
38
+ }
39
+ .disabled(props.disabled)
40
+ } else {
41
+ Menu {
42
+ menuContent
43
+ } label: {
44
+ RepresentableView(view: triggerView)
45
+ }
46
+ .disabled(props.disabled)
47
+ }
48
+ } else {
49
+ // No trigger mounted yet; render an empty view until the RN child
50
+ // is attached to the provider.
51
+ Color.clear
52
+ }
53
+ }
54
+ }
55
+
56
+ @ViewBuilder
57
+ private var menuContent: some View {
58
+ ForEach(buildElements(from: props.menuConfig)) { element in
59
+ element.buildView(delegate: delegate)
60
+ }
61
+ }
62
+
63
+ /// Parses the JSON menu config into a list of SwiftUI element builders.
64
+ private func buildElements(from json: String) -> [TinyuiMenuElement] {
65
+ guard
66
+ let data = json.data(using: .utf8),
67
+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
68
+ let items = root["items"] as? [[String: Any]]
69
+ else {
70
+ return []
71
+ }
72
+ return items.map { TinyuiMenuElement(dictionary: $0) }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Lightweight representation of a single UIMenu element parsed from the JSON
78
+ * config. Used to build SwiftUI menu content without resolving all elements
79
+ * eagerly. Returns `AnyView` to avoid recursive opaque return type errors.
80
+ */
81
+ struct TinyuiMenuElement: Identifiable {
82
+ let id = UUID()
83
+ private let dictionary: [String: Any]
84
+
85
+ init(dictionary: [String: Any]) {
86
+ self.dictionary = dictionary
87
+ }
88
+
89
+ func buildView(
90
+ delegate: TinyuiMenuViewDelegate?,
91
+ inheritedDestructive: Bool = false
92
+ ) -> AnyView {
93
+ if dictionary["hidden"] as? Bool ?? false {
94
+ return AnyView(EmptyView())
95
+ }
96
+
97
+ switch dictionary["type"] as? String {
98
+ case "action":
99
+ let destructive = inheritedDestructive || (dictionary["destructive"] as? Bool ?? false)
100
+ let keepOpen = dictionary["keepOpen"] as? Bool ?? false
101
+ return AnyView(
102
+ Button(role: destructive ? .destructive : nil) {
103
+ if let id = dictionary["id"] as? String {
104
+ delegate?.onItemPress(id: id)
105
+ }
106
+ } label: {
107
+ TinyuiMenuLabel(
108
+ dictionary: dictionary,
109
+ destructive: destructive,
110
+ showsState: true
111
+ )
112
+ }
113
+ .disabled(dictionary["disabled"] as? Bool ?? false)
114
+ .menuActionDismissBehavior(keepOpen ? .disabled : .automatic)
115
+ )
116
+
117
+ case "submenu":
118
+ let children = (dictionary["children"] as? [[String: Any]] ?? [])
119
+ .map { TinyuiMenuElement(dictionary: $0) }
120
+ let destructive = inheritedDestructive || (dictionary["destructive"] as? Bool ?? false)
121
+ let disabled = dictionary["disabled"] as? Bool ?? false
122
+
123
+ if dictionary["displayInline"] as? Bool ?? false {
124
+ return AnyView(
125
+ Section {
126
+ ForEach(children) { child in
127
+ child.buildView(
128
+ delegate: delegate,
129
+ inheritedDestructive: destructive
130
+ )
131
+ }
132
+ } header: {
133
+ TinyuiMenuLabel(
134
+ dictionary: dictionary,
135
+ destructive: destructive,
136
+ showsState: false
137
+ )
138
+ }
139
+ .disabled(disabled)
140
+ )
141
+ }
142
+
143
+ return AnyView(
144
+ Menu {
145
+ ForEach(children) { child in
146
+ child.buildView(
147
+ delegate: delegate,
148
+ inheritedDestructive: destructive
149
+ )
150
+ }
151
+ } label: {
152
+ TinyuiMenuLabel(
153
+ dictionary: dictionary,
154
+ destructive: destructive,
155
+ showsState: false
156
+ )
157
+ }
158
+ .disabled(disabled)
159
+ )
160
+
161
+ case "section":
162
+ let children = (dictionary["children"] as? [[String: Any]] ?? [])
163
+ .map { TinyuiMenuElement(dictionary: $0) }
164
+ return AnyView(
165
+ Section {
166
+ ForEach(children) { child in
167
+ child.buildView(
168
+ delegate: delegate,
169
+ inheritedDestructive: inheritedDestructive
170
+ )
171
+ }
172
+ } header: {
173
+ if let title = dictionary["title"] as? String, !title.isEmpty {
174
+ Text(title)
175
+ }
176
+ }
177
+ )
178
+
179
+ case "separator":
180
+ return AnyView(Divider())
181
+
182
+ default:
183
+ return AnyView(EmptyView())
184
+ }
185
+ }
186
+ }
187
+
188
+ /** Label content shared by actions and submenus. */
189
+ private struct TinyuiMenuLabel: View {
190
+ let dictionary: [String: Any]
191
+ let destructive: Bool
192
+ let showsState: Bool
193
+
194
+ var body: some View {
195
+ HStack {
196
+ menuIcon
197
+
198
+ VStack(alignment: .leading, spacing: 1) {
199
+ title
200
+
201
+ if let subtitle = dictionary["subtitle"] as? String,
202
+ !subtitle.isEmpty {
203
+ Text(subtitle)
204
+ .font(.caption)
205
+ .foregroundStyle(.secondary)
206
+ }
207
+ }
208
+
209
+ if showsState {
210
+ switch dictionary["state"] as? String {
211
+ case "on":
212
+ Image(systemName: "checkmark")
213
+ case "mixed":
214
+ Image(systemName: "minus")
215
+ default:
216
+ EmptyView()
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ @ViewBuilder
223
+ private var title: some View {
224
+ let text = Text(dictionary["title"] as? String ?? "")
225
+ if let color = TinyuiMenuColor.uiColor(from: dictionary["titleColor"]) {
226
+ text.foregroundStyle(Color(uiColor: color))
227
+ } else if destructive {
228
+ text.foregroundStyle(Color.red)
229
+ } else {
230
+ text
231
+ }
232
+ }
233
+
234
+ @ViewBuilder
235
+ private var menuIcon: some View {
236
+ let iconColor = TinyuiMenuColor.uiColor(from: dictionary["iconColor"])
237
+
238
+ if let icon = dictionary["icon"] as? String, !icon.isEmpty {
239
+ if let image = UIImage(named: icon) {
240
+ if let iconColor {
241
+ Image(
242
+ uiImage: image.withTintColor(
243
+ iconColor,
244
+ renderingMode: .alwaysOriginal
245
+ )
246
+ )
247
+ } else {
248
+ Image(uiImage: image.withRenderingMode(.alwaysTemplate))
249
+ }
250
+ }
251
+ } else if let systemImage = dictionary["systemImage"] as? String,
252
+ !systemImage.isEmpty {
253
+ if let iconColor {
254
+ Image(systemName: systemImage)
255
+ .foregroundStyle(Color(uiColor: iconColor))
256
+ } else {
257
+ Image(systemName: systemImage)
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ /** Converts React Native's processed ColorValue JSON into a dynamic UIColor. */
264
+ private enum TinyuiMenuColor {
265
+ static func uiColor(from value: Any?) -> UIColor? {
266
+ if let number = value as? NSNumber {
267
+ let argb = number.uint32Value
268
+ return UIColor(
269
+ red: CGFloat((argb >> 16) & 0xff) / 255,
270
+ green: CGFloat((argb >> 8) & 0xff) / 255,
271
+ blue: CGFloat(argb & 0xff) / 255,
272
+ alpha: CGFloat((argb >> 24) & 0xff) / 255
273
+ )
274
+ }
275
+
276
+ guard let object = value as? [String: Any] else {
277
+ return nil
278
+ }
279
+
280
+ if let semanticNames = object["semantic"] as? [String] {
281
+ for name in semanticNames {
282
+ if let color = semanticColor(named: name) {
283
+ return color
284
+ }
285
+ }
286
+ }
287
+
288
+ if let dynamic = object["dynamic"] as? [String: Any] {
289
+ return UIColor { traits in
290
+ let highContrast = traits.accessibilityContrast == .high
291
+ let dark = traits.userInterfaceStyle == .dark
292
+ let preferredKey: String
293
+
294
+ switch (dark, highContrast) {
295
+ case (true, true):
296
+ preferredKey = "highContrastDark"
297
+ case (false, true):
298
+ preferredKey = "highContrastLight"
299
+ case (true, false):
300
+ preferredKey = "dark"
301
+ case (false, false):
302
+ preferredKey = "light"
303
+ }
304
+
305
+ return uiColor(from: dynamic[preferredKey])
306
+ ?? uiColor(from: dynamic[dark ? "dark" : "light"])
307
+ ?? UIColor.clear
308
+ }
309
+ }
310
+
311
+ return nil
312
+ }
313
+
314
+ private static func semanticColor(named name: String) -> UIColor? {
315
+ if let color = UIColor(named: name) {
316
+ return color
317
+ }
318
+
319
+ let normalizedName = name.hasSuffix("Color")
320
+ ? String(name.dropLast("Color".count))
321
+ : name
322
+
323
+ switch normalizedName {
324
+ case "label": return .label
325
+ case "secondaryLabel": return .secondaryLabel
326
+ case "tertiaryLabel": return .tertiaryLabel
327
+ case "quaternaryLabel": return .quaternaryLabel
328
+ case "placeholderText": return .placeholderText
329
+ case "separator": return .separator
330
+ case "opaqueSeparator": return .opaqueSeparator
331
+ case "link": return .link
332
+ case "systemBackground": return .systemBackground
333
+ case "secondarySystemBackground": return .secondarySystemBackground
334
+ case "tertiarySystemBackground": return .tertiarySystemBackground
335
+ case "systemGroupedBackground": return .systemGroupedBackground
336
+ case "secondarySystemGroupedBackground": return .secondarySystemGroupedBackground
337
+ case "tertiarySystemGroupedBackground": return .tertiarySystemGroupedBackground
338
+ case "systemFill": return .systemFill
339
+ case "secondarySystemFill": return .secondarySystemFill
340
+ case "tertiarySystemFill": return .tertiarySystemFill
341
+ case "quaternarySystemFill": return .quaternarySystemFill
342
+ case "systemRed": return .systemRed
343
+ case "systemOrange": return .systemOrange
344
+ case "systemYellow": return .systemYellow
345
+ case "systemGreen": return .systemGreen
346
+ case "systemMint": return .systemMint
347
+ case "systemTeal": return .systemTeal
348
+ case "systemCyan": return .systemCyan
349
+ case "systemBlue": return .systemBlue
350
+ case "systemIndigo": return .systemIndigo
351
+ case "systemPurple": return .systemPurple
352
+ case "systemPink": return .systemPink
353
+ case "systemBrown": return .systemBrown
354
+ case "systemGray": return .systemGray
355
+ case "systemGray2": return .systemGray2
356
+ case "systemGray3": return .systemGray3
357
+ case "systemGray4": return .systemGray4
358
+ case "systemGray5": return .systemGray5
359
+ case "systemGray6": return .systemGray6
360
+ default: return nil
361
+ }
362
+ }
363
+ }
364
+
365
+ @objc public protocol TinyuiMenuViewDelegate {
366
+ func onItemPress(id: String)
367
+ func onPrimaryAction()
368
+ }
@@ -0,0 +1,111 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ /**
5
+ * UIKit bridge between the Fabric `TinyuiPopoverView` component and the
6
+ * SwiftUI `TinyuiPopoverSwiftUIView`. Hosts the SwiftUI view through a
7
+ * `UIHostingController`, mirroring react-native-pager-view's bridge.
8
+ */
9
+ @objc public class TinyuiPopoverProvider: UIView {
10
+ private weak var delegate: TinyuiPopoverViewDelegate?
11
+ private var hostingController: UIHostingController<TinyuiPopoverSwiftUIView>?
12
+ private var props = TinyuiPopoverProps()
13
+
14
+ /// Presenting view controller, resolved by the component view from its
15
+ /// React view controller hierarchy.
16
+ @objc public weak var parentViewController: UIViewController? {
17
+ didSet {
18
+ if window != nil {
19
+ setupView()
20
+ }
21
+ }
22
+ }
23
+
24
+ @objc public var triggerView: UIView? {
25
+ didSet {
26
+ props.triggerView = triggerView
27
+ }
28
+ }
29
+
30
+ @objc public var contentView: UIView? {
31
+ didSet {
32
+ props.contentView = contentView
33
+ }
34
+ }
35
+
36
+ @objc public var isPresented: Bool = false {
37
+ didSet {
38
+ props.isPresented = isPresented
39
+ }
40
+ }
41
+
42
+ @objc public var attachmentAnchor: String = "center" {
43
+ didSet {
44
+ props.attachmentAnchor = attachmentAnchor
45
+ }
46
+ }
47
+
48
+ @objc public var arrowEdge: String = "none" {
49
+ didSet {
50
+ props.arrowEdge = arrowEdge
51
+ }
52
+ }
53
+
54
+ @objc public convenience init(delegate: TinyuiPopoverViewDelegate) {
55
+ self.init()
56
+ self.delegate = delegate
57
+ }
58
+
59
+ override public func didMoveToWindow() {
60
+ super.didMoveToWindow()
61
+ if window != nil {
62
+ setupView()
63
+ }
64
+ }
65
+
66
+ override public func layoutSubviews() {
67
+ super.layoutSubviews()
68
+ if window != nil {
69
+ setupView()
70
+ }
71
+ }
72
+
73
+ private func setupView() {
74
+ if hostingController != nil {
75
+ syncParentViewController()
76
+ return
77
+ }
78
+
79
+ guard let parentViewController else {
80
+ return
81
+ }
82
+
83
+ let hostingController = UIHostingController(
84
+ rootView: TinyuiPopoverSwiftUIView(props: props, delegate: delegate)
85
+ )
86
+ self.hostingController = hostingController
87
+
88
+ parentViewController.addChild(hostingController)
89
+ hostingController.view.backgroundColor = .clear
90
+ addSubview(hostingController.view)
91
+ hostingController.view.translatesAutoresizingMaskIntoConstraints = false
92
+ hostingController.view.pinEdges(to: self)
93
+ hostingController.didMove(toParent: parentViewController)
94
+ }
95
+
96
+ /// Keeps the hosting controller attached to the current view controller.
97
+ private func syncParentViewController(to parentViewController: UIViewController? = nil) {
98
+ guard
99
+ let hostingController,
100
+ let parentViewController = parentViewController ?? self.parentViewController,
101
+ hostingController.parent !== parentViewController
102
+ else {
103
+ return
104
+ }
105
+
106
+ hostingController.willMove(toParent: nil)
107
+ hostingController.removeFromParent()
108
+ parentViewController.addChild(hostingController)
109
+ hostingController.didMove(toParent: parentViewController)
110
+ }
111
+ }
@@ -0,0 +1,14 @@
1
+ #if TINYUI_FEATURE_POPOVER
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+ #import <UIKit/UIKit.h>
5
+
6
+ NS_ASSUME_NONNULL_BEGIN
7
+
8
+ @interface TinyuiPopoverView : RCTViewComponentView
9
+ @end
10
+
11
+ NS_ASSUME_NONNULL_END
12
+
13
+ #endif // TINYUI_FEATURE_POPOVER
14
+
@@ -0,0 +1,124 @@
1
+ #if TINYUI_FEATURE_POPOVER
2
+
3
+ #import "TinyuiPopoverView.h"
4
+
5
+ #import <React/RCTConversions.h>
6
+ #import <React/UIView+React.h>
7
+
8
+ #import <react/renderer/components/TinyuiSpec/ComponentDescriptors.h>
9
+ #import <react/renderer/components/TinyuiSpec/EventEmitters.h>
10
+ #import <react/renderer/components/TinyuiSpec/Props.h>
11
+ #import <react/renderer/components/TinyuiSpec/RCTComponentViewHelpers.h>
12
+
13
+ #import "RCTFabricComponentsPlugins.h"
14
+
15
+ #if __has_include("Tinyui-Swift.h")
16
+ #import "Tinyui-Swift.h"
17
+ #endif
18
+
19
+ using namespace facebook::react;
20
+
21
+ @interface TinyuiPopoverView () <RCTTinyuiPopoverViewViewProtocol, TinyuiPopoverViewDelegate>
22
+ @end
23
+
24
+ @implementation TinyuiPopoverView {
25
+ TinyuiPopoverProvider *_popoverProvider;
26
+ UIView *_triggerView;
27
+ UIView<RCTComponentViewProtocol> *_contentComponentView;
28
+ }
29
+
30
+ + (ComponentDescriptorProvider)componentDescriptorProvider
31
+ {
32
+ return concreteComponentDescriptorProvider<TinyuiPopoverViewComponentDescriptor>();
33
+ }
34
+
35
+ - (instancetype)initWithFrame:(CGRect)frame
36
+ {
37
+ if (self = [super initWithFrame:frame]) {
38
+ static const auto defaultProps = std::make_shared<const TinyuiPopoverViewProps>();
39
+ _props = defaultProps;
40
+
41
+ _popoverProvider = [[TinyuiPopoverProvider alloc] initWithDelegate:self];
42
+ self.contentView = _popoverProvider;
43
+ }
44
+ return self;
45
+ }
46
+
47
+ - (void)didMoveToWindow
48
+ {
49
+ [super didMoveToWindow];
50
+ if (self.window != nil) {
51
+ _popoverProvider.parentViewController = [self reactViewController];
52
+ }
53
+ }
54
+
55
+ #pragma mark - React lifecycle
56
+
57
+ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
58
+ index:(NSInteger)index
59
+ {
60
+ if (_triggerView == nil) {
61
+ // First child is the trigger, used as the popover's anchor.
62
+ _triggerView = childComponentView;
63
+ _popoverProvider.triggerView = childComponentView;
64
+ [_popoverProvider addSubview:childComponentView];
65
+ return;
66
+ }
67
+
68
+ // Second child is the popover content, presented inside the popover.
69
+ NSAssert(_contentComponentView == nil, @"TinyuiPopoverView accepts one content child.");
70
+ _contentComponentView = childComponentView;
71
+ _popoverProvider.contentView = childComponentView;
72
+ }
73
+
74
+ - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
75
+ index:(NSInteger)index
76
+ {
77
+ [childComponentView removeFromSuperview];
78
+ if (_triggerView == childComponentView) {
79
+ _triggerView = nil;
80
+ _popoverProvider.triggerView = nil;
81
+ } else if (_contentComponentView == childComponentView) {
82
+ _contentComponentView = nil;
83
+ _popoverProvider.contentView = nil;
84
+ }
85
+ }
86
+
87
+ #pragma mark - Props
88
+
89
+ - (void)updateProps:(Props::Shared const &)props
90
+ oldProps:(Props::Shared const &)oldProps
91
+ {
92
+ const auto &newPopoverProps = *std::static_pointer_cast<TinyuiPopoverViewProps const>(props);
93
+
94
+ _popoverProvider.isPresented = newPopoverProps.isPresented;
95
+ _popoverProvider.attachmentAnchor = RCTNSStringFromString(newPopoverProps.attachmentAnchor);
96
+ _popoverProvider.arrowEdge = RCTNSStringFromString(newPopoverProps.arrowEdge);
97
+
98
+ [super updateProps:props oldProps:oldProps];
99
+ }
100
+
101
+ #pragma mark - TinyuiPopoverViewDelegate
102
+
103
+ - (void)onIsPresentedChangeWithIsPresented:(BOOL)isPresented
104
+ {
105
+ if (_eventEmitter == nullptr) {
106
+ return;
107
+ }
108
+ std::static_pointer_cast<const TinyuiPopoverViewEventEmitter>(_eventEmitter)
109
+ ->onIsPresentedChange(TinyuiPopoverViewEventEmitter::OnIsPresentedChange{
110
+ .isPresented = static_cast<bool>(isPresented)});
111
+ }
112
+
113
+ - (void)prepareForRecycle
114
+ {
115
+ _triggerView = nil;
116
+ _contentComponentView = nil;
117
+ _popoverProvider.triggerView = nil;
118
+ _popoverProvider.contentView = nil;
119
+ [super prepareForRecycle];
120
+ }
121
+
122
+ @end
123
+
124
+ #endif // TINYUI_FEATURE_POPOVER
@@ -0,0 +1,88 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ /**
5
+ * Observable props shared between the Objective-C++ component view and the
6
+ * SwiftUI `Popover` implementation. Mirror of `TinyuiPopoverProps` in codegen.
7
+ */
8
+ class TinyuiPopoverProps: ObservableObject {
9
+ @Published var isPresented: Bool = false
10
+ @Published var attachmentAnchor: String = "center"
11
+ @Published var arrowEdge: String = "none"
12
+ @Published var triggerView: UIView?
13
+ @Published var contentView: UIView?
14
+ }
15
+
16
+ /**
17
+ * SwiftUI `popover` backed by UIPopoverPresentationController, matching
18
+ * expo-ui's PopoverView. The RN trigger is embedded as the anchor view and the
19
+ * RN content view is presented inside the popover.
20
+ *
21
+ * Named distinctly from the Fabric component view `TinyuiPopoverView`.
22
+ */
23
+ struct TinyuiPopoverSwiftUIView: View {
24
+ @ObservedObject var props: TinyuiPopoverProps
25
+ weak var delegate: TinyuiPopoverViewDelegate?
26
+
27
+ @State private var isPresented: Bool = false
28
+
29
+ var body: some View {
30
+ if let triggerView = props.triggerView {
31
+ RepresentableView(view: triggerView)
32
+ .popover(
33
+ isPresented: $isPresented,
34
+ attachmentAnchor: attachmentAnchorValue,
35
+ arrowEdge: arrowEdgeValue
36
+ ) {
37
+ if #available(iOS 16.4, *) {
38
+ popoverContent
39
+ .presentationCompactAdaptation(.popover)
40
+ } else {
41
+ popoverContent
42
+ }
43
+ }
44
+ .onChange(of: isPresented) { newValue in
45
+ delegate?.onIsPresentedChange(isPresented: newValue)
46
+ }
47
+ .onChange(of: props.isPresented) { newValue in
48
+ isPresented = newValue
49
+ }
50
+ .onAppear {
51
+ isPresented = props.isPresented
52
+ }
53
+ } else {
54
+ Color.clear
55
+ }
56
+ }
57
+
58
+ private var attachmentAnchorValue: PopoverAttachmentAnchor {
59
+ switch props.attachmentAnchor {
60
+ case "top": return .point(.top)
61
+ case "bottom": return .point(.bottom)
62
+ case "leading": return .point(.leading)
63
+ case "trailing": return .point(.trailing)
64
+ default: return .point(.center)
65
+ }
66
+ }
67
+
68
+ private var arrowEdgeValue: Edge? {
69
+ switch props.arrowEdge {
70
+ case "top": return .top
71
+ case "bottom": return .bottom
72
+ case "leading": return .leading
73
+ case "trailing": return .trailing
74
+ default: return nil
75
+ }
76
+ }
77
+
78
+ @ViewBuilder
79
+ private var popoverContent: some View {
80
+ if let contentView = props.contentView {
81
+ RepresentableView(view: contentView)
82
+ }
83
+ }
84
+ }
85
+
86
+ @objc public protocol TinyuiPopoverViewDelegate {
87
+ func onIsPresentedChange(isPresented: Bool)
88
+ }