react-native-tinyui 0.5.0 → 1.1.0

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 (140) hide show
  1. package/README.md +468 -28
  2. package/ios/ConcentricView/TinyuiConcentricView.h +12 -0
  3. package/ios/ConcentricView/TinyuiConcentricView.mm +60 -0
  4. package/ios/Core/Tinyui.mm +54 -0
  5. package/ios/Core/TinyuiComponentClasses.mm +44 -0
  6. package/ios/Menu/TinyuiMenuBuilder.swift +238 -0
  7. package/ios/Menu/TinyuiMenuProvider.swift +141 -75
  8. package/ios/Menu/TinyuiMenuView.mm +24 -35
  9. package/ios/Popover/TinyuiPopoverProvider.swift +198 -44
  10. package/ios/Popover/TinyuiPopoverView.mm +26 -1
  11. package/ios/SFSymbol/TinyuiSFSymbolEffects.h +9 -0
  12. package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +180 -0
  13. package/ios/SFSymbol/TinyuiSFSymbolImage.h +8 -0
  14. package/ios/SFSymbol/TinyuiSFSymbolImage.mm +141 -0
  15. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.h +31 -0
  16. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +30 -0
  17. package/ios/SFSymbol/TinyuiSFSymbolView.h +4 -0
  18. package/ios/SFSymbol/TinyuiSFSymbolView.mm +173 -0
  19. package/ios/Stepper/TinyuiStepperShadowNode.h +32 -0
  20. package/ios/Stepper/TinyuiStepperShadowNode.mm +28 -0
  21. package/ios/Stepper/TinyuiStepperView.h +12 -0
  22. package/ios/Stepper/TinyuiStepperView.mm +128 -0
  23. package/ios/TipKit/TinyuiTip.swift +31 -0
  24. package/ios/TipKit/TinyuiTipPopoverProvider.swift +276 -0
  25. package/ios/TipKit/TinyuiTipPopoverView.h +8 -0
  26. package/ios/TipKit/TinyuiTipPopoverView.mm +114 -0
  27. package/ios/TipKit/TinyuiTipsRuntime.swift +148 -0
  28. package/lib/module/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
  29. package/lib/module/ConcentricView/index.js +29 -0
  30. package/lib/module/ConcentricView/index.js.map +1 -0
  31. package/lib/module/ConcentricView/types.js +4 -0
  32. package/lib/module/ConcentricView/types.js.map +1 -0
  33. package/lib/module/Menu/MenuNativeComponent.ts +12 -3
  34. package/lib/module/Menu/index.js +17 -12
  35. package/lib/module/Menu/index.js.map +1 -1
  36. package/lib/module/Menu/options.js +4 -12
  37. package/lib/module/Menu/options.js.map +1 -1
  38. package/lib/module/NativeTinyui.js.map +1 -1
  39. package/lib/module/Popover/index.js +4 -2
  40. package/lib/module/Popover/index.js.map +1 -1
  41. package/lib/module/SFSymbol/SFSymbolNativeComponent.ts +20 -0
  42. package/lib/module/SFSymbol/index.js +73 -0
  43. package/lib/module/SFSymbol/index.js.map +1 -0
  44. package/lib/module/SFSymbol/options.js +79 -0
  45. package/lib/module/SFSymbol/options.js.map +1 -0
  46. package/lib/module/SFSymbol/types.js +4 -0
  47. package/lib/module/SFSymbol/types.js.map +1 -0
  48. package/lib/module/Stepper/StepperNativeComponent.ts +29 -0
  49. package/lib/module/Stepper/index.js +75 -0
  50. package/lib/module/Stepper/index.js.map +1 -0
  51. package/lib/module/Stepper/types.js +4 -0
  52. package/lib/module/Stepper/types.js.map +1 -0
  53. package/lib/module/TipKit/Popover.js +89 -0
  54. package/lib/module/TipKit/Popover.js.map +1 -0
  55. package/lib/module/TipKit/TipPopoverNativeComponent.ts +37 -0
  56. package/lib/module/TipKit/index.js +28 -0
  57. package/lib/module/TipKit/index.js.map +1 -0
  58. package/lib/module/TipKit/types.js +4 -0
  59. package/lib/module/TipKit/types.js.map +1 -0
  60. package/lib/module/TipKit/validation.js +24 -0
  61. package/lib/module/TipKit/validation.js.map +1 -0
  62. package/lib/module/index.js +4 -0
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/utils/getNativeTinyui.js.map +1 -1
  65. package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts +7 -0
  66. package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts.map +1 -0
  67. package/lib/typescript/src/ConcentricView/index.d.ts +4 -0
  68. package/lib/typescript/src/ConcentricView/index.d.ts.map +1 -0
  69. package/lib/typescript/src/ConcentricView/types.d.ts +6 -0
  70. package/lib/typescript/src/ConcentricView/types.d.ts.map +1 -0
  71. package/lib/typescript/src/Menu/MenuNativeComponent.d.ts +8 -4
  72. package/lib/typescript/src/Menu/MenuNativeComponent.d.ts.map +1 -1
  73. package/lib/typescript/src/Menu/index.d.ts +1 -1
  74. package/lib/typescript/src/Menu/index.d.ts.map +1 -1
  75. package/lib/typescript/src/Menu/options.d.ts +1 -5
  76. package/lib/typescript/src/Menu/options.d.ts.map +1 -1
  77. package/lib/typescript/src/Menu/types.d.ts +26 -7
  78. package/lib/typescript/src/Menu/types.d.ts.map +1 -1
  79. package/lib/typescript/src/NativeTinyui.d.ts +2 -0
  80. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
  81. package/lib/typescript/src/Popover/index.d.ts.map +1 -1
  82. package/lib/typescript/src/Popover/types.d.ts +2 -0
  83. package/lib/typescript/src/Popover/types.d.ts.map +1 -1
  84. package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts +11 -0
  85. package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts.map +1 -0
  86. package/lib/typescript/src/SFSymbol/index.d.ts +4 -0
  87. package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -0
  88. package/lib/typescript/src/SFSymbol/options.d.ts +86 -0
  89. package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -0
  90. package/lib/typescript/src/SFSymbol/types.d.ts +113 -0
  91. package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -0
  92. package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts +21 -0
  93. package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts.map +1 -0
  94. package/lib/typescript/src/Stepper/index.d.ts +4 -0
  95. package/lib/typescript/src/Stepper/index.d.ts.map +1 -0
  96. package/lib/typescript/src/Stepper/types.d.ts +24 -0
  97. package/lib/typescript/src/Stepper/types.d.ts.map +1 -0
  98. package/lib/typescript/src/TipKit/Popover.d.ts +3 -0
  99. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -0
  100. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +38 -0
  101. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -0
  102. package/lib/typescript/src/TipKit/index.d.ts +10 -0
  103. package/lib/typescript/src/TipKit/index.d.ts.map +1 -0
  104. package/lib/typescript/src/TipKit/types.d.ts +46 -0
  105. package/lib/typescript/src/TipKit/types.d.ts.map +1 -0
  106. package/lib/typescript/src/TipKit/validation.d.ts +4 -0
  107. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -0
  108. package/lib/typescript/src/index.d.ts +9 -1
  109. package/lib/typescript/src/index.d.ts.map +1 -1
  110. package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
  111. package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
  112. package/package.json +37 -2
  113. package/{Tinyui.podspec → react-native-tinyui.podspec} +23 -2
  114. package/src/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
  115. package/src/ConcentricView/index.tsx +34 -0
  116. package/src/ConcentricView/types.ts +6 -0
  117. package/src/Menu/MenuNativeComponent.ts +12 -3
  118. package/src/Menu/index.tsx +35 -12
  119. package/src/Menu/options.ts +2 -20
  120. package/src/Menu/types.ts +28 -7
  121. package/src/NativeTinyui.ts +2 -0
  122. package/src/Popover/index.tsx +8 -3
  123. package/src/Popover/types.ts +2 -0
  124. package/src/SFSymbol/SFSymbolNativeComponent.ts +20 -0
  125. package/src/SFSymbol/index.tsx +89 -0
  126. package/src/SFSymbol/options.ts +116 -0
  127. package/src/SFSymbol/types.ts +158 -0
  128. package/src/Stepper/StepperNativeComponent.ts +29 -0
  129. package/src/Stepper/index.tsx +91 -0
  130. package/src/Stepper/types.ts +24 -0
  131. package/src/TipKit/Popover.tsx +87 -0
  132. package/src/TipKit/TipPopoverNativeComponent.ts +37 -0
  133. package/src/TipKit/index.ts +47 -0
  134. package/src/TipKit/types.ts +57 -0
  135. package/src/TipKit/validation.ts +39 -0
  136. package/src/index.tsx +31 -0
  137. package/src/utils/getNativeTinyui.ts +8 -1
  138. package/ios/Core/TinyuiHostingHelpers.swift +0 -74
  139. package/ios/Menu/TinyuiMenuViewSwift.swift +0 -326
  140. package/ios/Popover/TinyuiPopoverViewSwift.swift +0 -88
@@ -1,4 +1,7 @@
1
1
  #import "Tinyui.h"
2
+ #if TINYUI_FEATURE_TIP_KIT
3
+ #import "Tinyui-Swift.h"
4
+ #endif
2
5
 
3
6
  @implementation Tinyui
4
7
 
@@ -16,10 +19,61 @@
16
19
  #if TINYUI_FEATURE_LIQUID_GLASS_TEXT
17
20
  [components addObject:@"LiquidGlassText"];
18
21
  #endif
22
+ #if TINYUI_FEATURE_STEPPER
23
+ [components addObject:@"Stepper"];
24
+ #endif
25
+ #if TINYUI_FEATURE_CONCENTRIC_VIEW
26
+ [components addObject:@"ConcentricView"];
27
+ #endif
28
+ #if TINYUI_FEATURE_SF_SYMBOL
29
+ [components addObject:@"SFSymbol"];
30
+ #endif
31
+ #if TINYUI_FEATURE_TIP_KIT
32
+ [components addObject:@"TipKit"];
33
+ #endif
19
34
 
20
35
  return components;
21
36
  }
22
37
 
38
+ - (void)configureTips:(NSString *)displayFrequency
39
+ resolve:(RCTPromiseResolveBlock)resolve
40
+ reject:(RCTPromiseRejectBlock)reject
41
+ {
42
+ #if TINYUI_FEATURE_TIP_KIT
43
+ dispatch_async(dispatch_get_main_queue(), ^{
44
+ [TinyuiTipsRuntime configure:displayFrequency completion:^(NSError *error) {
45
+ if (error != nil) {
46
+ reject(error.userInfo[@"code"] ?: @"E_TIPS_CONFIGURATION", error.localizedDescription, error);
47
+ } else {
48
+ resolve(nil);
49
+ }
50
+ }];
51
+ });
52
+ #else
53
+ reject(@"E_TIP_NOT_INSTALLED", @"Enable TipKit in react-native-tinyui.components and run pod install.", nil);
54
+ #endif
55
+ }
56
+
57
+ - (void)invalidateTip:(NSString *)tipId
58
+ reason:(NSString *)reason
59
+ resolve:(RCTPromiseResolveBlock)resolve
60
+ reject:(RCTPromiseRejectBlock)reject
61
+ {
62
+ #if TINYUI_FEATURE_TIP_KIT
63
+ dispatch_async(dispatch_get_main_queue(), ^{
64
+ [TinyuiTipsRuntime invalidate:tipId reason:reason completion:^(NSError *error) {
65
+ if (error != nil) {
66
+ reject(error.userInfo[@"code"] ?: @"E_TIP_INVALIDATION", error.localizedDescription, error);
67
+ } else {
68
+ resolve(nil);
69
+ }
70
+ }];
71
+ });
72
+ #else
73
+ reject(@"E_TIP_NOT_INSTALLED", @"Enable TipKit in react-native-tinyui.components and run pod install.", nil);
74
+ #endif
75
+ }
76
+
23
77
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
24
78
  (const facebook::react::ObjCTurboModule::InitParams &)params
25
79
  {
@@ -35,6 +35,30 @@ TINYUI_DISABLED_COMPONENT(TinyuiPopoverView, TinyuiPopoverViewComponentDescripto
35
35
  TINYUI_DISABLED_COMPONENT(TinyuiLiquidGlassTextView, TinyuiLiquidGlassTextViewComponentDescriptor)
36
36
  #endif
37
37
 
38
+ #if TINYUI_FEATURE_STEPPER
39
+ #import "TinyuiStepperView.h"
40
+ #else
41
+ TINYUI_DISABLED_COMPONENT(TinyuiStepperView, TinyuiStepperViewComponentDescriptor)
42
+ #endif
43
+
44
+ #if TINYUI_FEATURE_CONCENTRIC_VIEW
45
+ #import "TinyuiConcentricView.h"
46
+ #else
47
+ TINYUI_DISABLED_COMPONENT(TinyuiConcentricView, TinyuiConcentricViewComponentDescriptor)
48
+ #endif
49
+
50
+ #if TINYUI_FEATURE_SF_SYMBOL
51
+ #import "TinyuiSFSymbolView.h"
52
+ #else
53
+ TINYUI_DISABLED_COMPONENT(TinyuiSFSymbolView, TinyuiSFSymbolViewComponentDescriptor)
54
+ #endif
55
+
56
+ #if TINYUI_FEATURE_TIP_KIT
57
+ #import "TinyuiTipPopoverView.h"
58
+ #else
59
+ TINYUI_DISABLED_COMPONENT(TinyuiTipPopoverView, TinyuiTipPopoverViewComponentDescriptor)
60
+ #endif
61
+
38
62
  #undef TINYUI_DISABLED_COMPONENT
39
63
 
40
64
  Class<RCTComponentViewProtocol> TinyuiMenuViewCls(void)
@@ -51,3 +75,23 @@ Class<RCTComponentViewProtocol> TinyuiLiquidGlassTextViewCls(void)
51
75
  {
52
76
  return TinyuiLiquidGlassTextView.class;
53
77
  }
78
+
79
+ Class<RCTComponentViewProtocol> TinyuiStepperViewCls(void)
80
+ {
81
+ return TinyuiStepperView.class;
82
+ }
83
+
84
+ Class<RCTComponentViewProtocol> TinyuiConcentricViewCls(void)
85
+ {
86
+ return TinyuiConcentricView.class;
87
+ }
88
+
89
+ Class<RCTComponentViewProtocol> TinyuiSFSymbolViewCls(void)
90
+ {
91
+ return TinyuiSFSymbolView.class;
92
+ }
93
+
94
+ Class<RCTComponentViewProtocol> TinyuiTipPopoverViewCls(void)
95
+ {
96
+ return TinyuiTipPopoverView.class;
97
+ }
@@ -0,0 +1,238 @@
1
+ import React
2
+ import UIKit
3
+
4
+ @objc public protocol TinyuiMenuViewDelegate: AnyObject {
5
+ func onActionPress(id: String, title: String)
6
+ }
7
+
8
+ /** Builds UIKit menu elements from the configuration generated in JavaScript. */
9
+ final class TinyuiMenuBuilder {
10
+ private weak var delegate: TinyuiMenuViewDelegate?
11
+
12
+ init(delegate: TinyuiMenuViewDelegate?) {
13
+ self.delegate = delegate
14
+ }
15
+
16
+ func buildMenu(title: String, config: [String: Any]) -> UIMenu {
17
+ print("[TinyUI][Menu] config:", config)
18
+
19
+ return UIMenu(
20
+ title: title,
21
+ children: buildChildren(from: dictionaries(in: config, key: "items"))
22
+ )
23
+ }
24
+
25
+ private func buildChildren(
26
+ from dictionaries: [[String: Any]],
27
+ inheritedDestructive: Bool = false,
28
+ inheritedDisabled: Bool = false
29
+ ) -> [UIMenuElement] {
30
+ // UIKit represents separators by placing adjacent groups in inline menus.
31
+ // Partitioning here also makes consecutive/leading/trailing dividers no-ops.
32
+ let groups = dictionaries.split {
33
+ ($0["type"] as? String) == "separator"
34
+ }
35
+
36
+ if groups.count <= 1 {
37
+ return dictionaries.compactMap {
38
+ buildElement(
39
+ from: $0,
40
+ inheritedDestructive: inheritedDestructive,
41
+ inheritedDisabled: inheritedDisabled
42
+ )
43
+ }
44
+ }
45
+
46
+ return groups.compactMap { group in
47
+ let children = group.compactMap {
48
+ buildElement(
49
+ from: $0,
50
+ inheritedDestructive: inheritedDestructive,
51
+ inheritedDisabled: inheritedDisabled
52
+ )
53
+ }
54
+ guard !children.isEmpty else {
55
+ return nil
56
+ }
57
+ return UIMenu(title: "", options: .displayInline, children: children)
58
+ }
59
+ }
60
+
61
+ private func buildElement(
62
+ from dictionary: [String: Any],
63
+ inheritedDestructive: Bool,
64
+ inheritedDisabled: Bool
65
+ ) -> UIMenuElement? {
66
+ guard !(dictionary["hidden"] as? Bool ?? false) else {
67
+ return nil
68
+ }
69
+
70
+ switch dictionary["type"] as? String {
71
+ case "action":
72
+ return buildAction(
73
+ from: dictionary,
74
+ inheritedDestructive: inheritedDestructive,
75
+ inheritedDisabled: inheritedDisabled
76
+ )
77
+ case "submenu":
78
+ return buildSubmenu(
79
+ from: dictionary,
80
+ inheritedDestructive: inheritedDestructive,
81
+ inheritedDisabled: inheritedDisabled
82
+ )
83
+ case "section":
84
+ let children = buildChildren(
85
+ from: dictionaries(in: dictionary, key: "children"),
86
+ inheritedDestructive: inheritedDestructive,
87
+ inheritedDisabled: inheritedDisabled
88
+ )
89
+ guard !children.isEmpty else {
90
+ return nil
91
+ }
92
+ return UIMenu(
93
+ title: dictionary["title"] as? String ?? "",
94
+ options: .displayInline,
95
+ children: children
96
+ )
97
+ default:
98
+ return nil
99
+ }
100
+ }
101
+
102
+ private func buildAction(
103
+ from dictionary: [String: Any],
104
+ inheritedDestructive: Bool,
105
+ inheritedDisabled: Bool
106
+ ) -> UIAction {
107
+ let identifier = dictionary["id"] as? String ?? ""
108
+ let title = dictionary["title"] as? String ?? ""
109
+ var attributes: UIMenuElement.Attributes = []
110
+
111
+ if inheritedDisabled || (dictionary["disabled"] as? Bool ?? false) {
112
+ attributes.insert(.disabled)
113
+ }
114
+ if inheritedDestructive || (dictionary["destructive"] as? Bool ?? false) {
115
+ attributes.insert(.destructive)
116
+ }
117
+ if dictionary["hidden"] as? Bool ?? false {
118
+ attributes.insert(.hidden)
119
+ }
120
+ if dictionary["keepOpen"] as? Bool ?? false {
121
+ attributes.insert(.keepsMenuPresented)
122
+ }
123
+
124
+ return UIAction(
125
+ title: title,
126
+ subtitle: nonEmptyString(dictionary["subtitle"]),
127
+ image: image(from: dictionary),
128
+ identifier: nil,
129
+ discoverabilityTitle: nil,
130
+ attributes: attributes,
131
+ state: state(from: dictionary)
132
+ ) { [weak delegate] _ in
133
+ delegate?.onActionPress(id: identifier, title: title)
134
+ }
135
+ }
136
+
137
+ private func buildSubmenu(
138
+ from dictionary: [String: Any],
139
+ inheritedDestructive: Bool,
140
+ inheritedDisabled: Bool
141
+ ) -> UIMenuElement? {
142
+ let isDestructive =
143
+ inheritedDestructive || (dictionary["destructive"] as? Bool ?? false)
144
+ let isDisabled =
145
+ inheritedDisabled || (dictionary["disabled"] as? Bool ?? false)
146
+ let isInline = dictionary["displayInline"] as? Bool ?? false
147
+
148
+ // UIKit doesn't expose disabled/hidden attributes on UIMenu. A disabled
149
+ // hierarchical submenu is therefore represented by a disabled UIAction;
150
+ // inline submenu children can be disabled individually.
151
+ if isDisabled && !isInline {
152
+ var attributes: UIMenuElement.Attributes = [.disabled]
153
+ if isDestructive {
154
+ attributes.insert(.destructive)
155
+ }
156
+ return UIAction(
157
+ title: dictionary["title"] as? String ?? "",
158
+ subtitle: nonEmptyString(dictionary["subtitle"]),
159
+ image: image(from: dictionary),
160
+ identifier: nil,
161
+ discoverabilityTitle: nil,
162
+ attributes: attributes,
163
+ state: .off
164
+ ) { _ in }
165
+ }
166
+
167
+ let children = buildChildren(
168
+ from: dictionaries(in: dictionary, key: "children"),
169
+ inheritedDestructive: isDestructive,
170
+ inheritedDisabled: isDisabled
171
+ )
172
+ guard !children.isEmpty else {
173
+ return nil
174
+ }
175
+
176
+ var options: UIMenu.Options = []
177
+ if isInline {
178
+ options.insert(.displayInline)
179
+ }
180
+ if isDestructive {
181
+ options.insert(.destructive)
182
+ }
183
+
184
+ return UIMenu(
185
+ title: dictionary["title"] as? String ?? "",
186
+ subtitle: nonEmptyString(dictionary["subtitle"]),
187
+ image: image(from: dictionary),
188
+ identifier: nil,
189
+ options: options,
190
+ children: children
191
+ )
192
+ }
193
+
194
+ private func image(from dictionary: [String: Any]) -> UIImage? {
195
+ let image: UIImage?
196
+ if let assetName = nonEmptyString(dictionary["icon"]),
197
+ let assetImage = UIImage(named: assetName) {
198
+ image = assetImage
199
+ } else if let systemName = nonEmptyString(dictionary["systemImage"]) {
200
+ image = UIImage(systemName: systemName)
201
+ } else {
202
+ image = nil
203
+ }
204
+
205
+ guard let image else {
206
+ return nil
207
+ }
208
+ guard let tintColor = dictionary["iconColor"].flatMap(RCTConvert.uiColor) else {
209
+ return image.withRenderingMode(.alwaysTemplate)
210
+ }
211
+ return image.withTintColor(tintColor, renderingMode: .alwaysOriginal)
212
+ }
213
+
214
+ private func state(from dictionary: [String: Any]) -> UIMenuElement.State {
215
+ switch dictionary["state"] as? String {
216
+ case "on":
217
+ return .on
218
+ case "mixed":
219
+ return .mixed
220
+ default:
221
+ return .off
222
+ }
223
+ }
224
+
225
+ private func dictionaries(
226
+ in dictionary: [String: Any],
227
+ key: String
228
+ ) -> [[String: Any]] {
229
+ dictionary[key] as? [[String: Any]] ?? []
230
+ }
231
+
232
+ private func nonEmptyString(_ value: Any?) -> String? {
233
+ guard let value = value as? String, !value.isEmpty else {
234
+ return nil
235
+ }
236
+ return value
237
+ }
238
+ }
@@ -1,116 +1,182 @@
1
- import SwiftUI
2
1
  import UIKit
2
+ import UIKit.UIGestureRecognizerSubclass
3
3
 
4
4
  /**
5
- * UIKit bridge between the Fabric `TinyuiMenuView` component and the SwiftUI
6
- * `TinyuiMenuSwiftUIView`. Hosts the SwiftUI view through a
7
- * `UIHostingController`, mirroring react-native-pager-view's `PagerViewProvider`.
8
- *
9
- * The presenting view controller is supplied by the Objective-C++ component
10
- * view so this file does not depend on React.
5
+ * UIButton-backed UIKit menu trigger used as the Fabric component's content
6
+ * view. UIButton owns the UIMenu presentation and its long-press interaction.
11
7
  */
12
- @objc public class TinyuiMenuProvider: UIView {
8
+ @objc public final class TinyuiMenuProvider: UIButton {
13
9
  private weak var delegate: TinyuiMenuViewDelegate?
14
- private var hostingController: UIHostingController<TinyuiMenuSwiftUIView>?
15
- private var props = TinyuiMenuProps()
10
+ private weak var menuTouchGate: TinyuiMenuTouchGate?
16
11
 
17
- /// Presenting view controller, resolved by the component view from its
18
- /// React view controller hierarchy.
19
- @objc public weak var parentViewController: UIViewController? {
12
+ @objc public var triggerView: UIView? {
20
13
  didSet {
21
- if window != nil {
22
- setupView()
14
+ guard oldValue !== triggerView else {
15
+ return
16
+ }
17
+ oldValue?.removeFromSuperview()
18
+ if let triggerView {
19
+ addSubview(triggerView)
23
20
  }
21
+ setNeedsLayout()
24
22
  }
25
23
  }
26
24
 
27
- /// The React Native trigger view rendered as the menu's label.
28
- @objc public var triggerView: UIView? {
29
- didSet {
30
- props.triggerView = triggerView
31
- }
25
+ @objc public convenience init(delegate: TinyuiMenuViewDelegate) {
26
+ self.init(frame: .zero)
27
+ self.delegate = delegate
32
28
  }
33
29
 
34
- /// Menu configuration passed to the SwiftUI view.
35
- @objc public var menuConfig: [String: Any] = [:] {
36
- didSet {
37
- props.menuConfig = menuConfig
38
- }
30
+ override public init(frame: CGRect) {
31
+ super.init(frame: frame)
32
+ configure()
39
33
  }
40
34
 
41
- @objc public var title: String = "" {
42
- didSet {
43
- props.title = title
44
- }
35
+ required init?(coder: NSCoder) {
36
+ super.init(coder: coder)
37
+ configure()
45
38
  }
46
39
 
47
- @objc public var disabled: Bool = false {
48
- didSet {
49
- props.disabled = disabled
50
- }
40
+ override public func didMoveToWindow() {
41
+ super.didMoveToWindow()
42
+ menuTouchGate?.presentedMenus.remove(self)
43
+ menuTouchGate = window.map { TinyuiMenuTouchGate.install(in: $0) }
51
44
  }
52
45
 
53
- @objc public var hasPrimaryAction: Bool = false {
54
- didSet {
55
- props.hasPrimaryAction = hasPrimaryAction
56
- }
46
+ override public func contextMenuInteraction(
47
+ _ interaction: UIContextMenuInteraction,
48
+ willDisplayMenuFor configuration: UIContextMenuConfiguration,
49
+ animator: UIContextMenuInteractionAnimating?
50
+ ) {
51
+ super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator)
52
+ menuTouchGate?.presentedMenus.add(self)
57
53
  }
58
54
 
59
- @objc public convenience init(delegate: TinyuiMenuViewDelegate) {
60
- self.init()
61
- self.delegate = delegate
55
+ override public func contextMenuInteraction(
56
+ _ interaction: UIContextMenuInteraction,
57
+ willEndFor configuration: UIContextMenuConfiguration,
58
+ animator: UIContextMenuInteractionAnimating?
59
+ ) {
60
+ super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
61
+ // The dismissal touch has already been filtered. New touches during the
62
+ // closing animation belong to the page again.
63
+ menuTouchGate?.presentedMenus.remove(self)
62
64
  }
63
65
 
64
- override public func didMoveToWindow() {
65
- super.didMoveToWindow()
66
- if window != nil {
67
- setupView()
66
+ override public func layoutSubviews() {
67
+ super.layoutSubviews()
68
+ triggerView?.frame = bounds
69
+ if let triggerView {
70
+ bringSubviewToFront(triggerView)
68
71
  }
69
72
  }
70
73
 
71
- override public func layoutSubviews() {
72
- super.layoutSubviews()
73
- if window != nil {
74
- setupView()
74
+ override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
75
+ guard !isHidden, alpha > 0.01, isEnabled, isUserInteractionEnabled,
76
+ self.point(inside: point, with: event) else {
77
+ return nil
75
78
  }
79
+ return self
80
+ }
81
+
82
+ private func configure() {
83
+ backgroundColor = .clear
84
+ showsMenuAsPrimaryAction = true
76
85
  }
77
86
 
78
- private func setupView() {
79
- if hostingController != nil {
80
- syncParentViewController()
87
+ @objc public func openMenu() {
88
+ guard isEnabled, isUserInteractionEnabled, !isHidden, alpha > 0.01,
89
+ window != nil, !bounds.isEmpty, menu != nil else {
81
90
  return
82
91
  }
92
+ if #available(iOS 17.4, *) {
93
+ performPrimaryAction()
94
+ }
95
+ }
83
96
 
84
- guard let parentViewController else {
97
+ /** Applies one complete Fabric props snapshot before rebuilding the menu. */
98
+ @objc public func update(
99
+ menuConfig: [String: Any],
100
+ title: String,
101
+ disabled: Bool
102
+ ) {
103
+ isEnabled = !disabled
104
+ isUserInteractionEnabled = !disabled
105
+
106
+ // UIButton enables its context menu interaction whenever a menu is assigned,
107
+ // independently of the control's enabled state. Remove it while disabled
108
+ // to block both primary-action and long-press presentation.
109
+ if disabled {
110
+ contextMenuInteraction?.dismissMenu()
111
+ menu = nil
85
112
  return
86
113
  }
87
114
 
88
- let hostingController = UIHostingController(
89
- rootView: TinyuiMenuSwiftUIView(props: props, delegate: delegate)
115
+ menu = TinyuiMenuBuilder(delegate: delegate).buildMenu(
116
+ title: title,
117
+ config: menuConfig
90
118
  )
91
- self.hostingController = hostingController
92
-
93
- parentViewController.addChild(hostingController)
94
- hostingController.view.backgroundColor = .clear
95
- addSubview(hostingController.view)
96
- hostingController.view.translatesAutoresizingMaskIntoConstraints = false
97
- hostingController.view.pinEdges(to: self)
98
- hostingController.didMove(toParent: parentViewController)
99
- }
100
-
101
- /// Keeps the hosting controller attached to the current view controller.
102
- private func syncParentViewController(to parentViewController: UIViewController? = nil) {
103
- guard
104
- let hostingController,
105
- let parentViewController = parentViewController ?? self.parentViewController,
106
- hostingController.parent !== parentViewController
107
- else {
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Uses the early-event filtering approach from Expo's SystemMenuTouchGate:
124
+ * https://github.com/expo/expo/pull/49775
125
+ * TinyUI tracks its own menus through public UIButton lifecycle callbacks.
126
+ */
127
+ final class TinyuiMenuTouchGate: UIGestureRecognizer, UIGestureRecognizerDelegate {
128
+ let presentedMenus = NSHashTable<TinyuiMenuProvider>.weakObjects()
129
+ private static let surfaceTouchHandlerClass = NSClassFromString("RCTSurfaceTouchHandler")
130
+
131
+ init() {
132
+ super.init(target: nil, action: nil)
133
+ cancelsTouchesInView = false
134
+ delaysTouchesBegan = false
135
+ delaysTouchesEnded = false
136
+ delegate = self
137
+ }
138
+
139
+ static func install(in window: UIWindow) -> TinyuiMenuTouchGate {
140
+ if let gate = window.gestureRecognizers?.first(where: { $0 is TinyuiMenuTouchGate })
141
+ as? TinyuiMenuTouchGate {
142
+ return gate
143
+ }
144
+ let gate = TinyuiMenuTouchGate()
145
+ window.addGestureRecognizer(gate)
146
+ return gate
147
+ }
148
+
149
+ private func ignoreReactTouches(_ touches: Set<UITouch>, event: UIEvent) {
150
+ guard let window = view as? UIWindow,
151
+ presentedMenus.allObjects.contains(where: { $0.window === window }),
152
+ let handlerClass = Self.surfaceTouchHandlerClass else {
108
153
  return
109
154
  }
110
155
 
111
- hostingController.willMove(toParent: nil)
112
- hostingController.removeFromParent()
113
- parentViewController.addChild(hostingController)
114
- hostingController.didMove(toParent: parentViewController)
156
+ for touch in touches where touch.phase == .began {
157
+ var ancestor = touch.view
158
+ while let current = ancestor {
159
+ for handler in current.gestureRecognizers ?? [] where handler.isKind(of: handlerClass) {
160
+ handler.ignore(touch, for: event)
161
+ }
162
+ ancestor = current.superview
163
+ }
164
+ }
165
+ }
166
+
167
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool {
168
+ // Filtering in touchesBegan alone is too late: RN may already have sent
169
+ // touchStart/onPressIn to JS. UIKit asks this delegate before delivery.
170
+ if let touches = event.allTouches {
171
+ ignoreReactTouches(touches, event: event)
172
+ }
173
+ return true
174
+ }
175
+
176
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
177
+ super.touchesBegan(touches, with: event)
178
+ // Fallback for delivery orders where RN sees the touch first.
179
+ ignoreReactTouches(touches, event: event)
180
+ state = .failed
115
181
  }
116
182
  }