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
@@ -3,7 +3,6 @@
3
3
  #import "TinyuiMenuView.h"
4
4
 
5
5
  #import <React/RCTConversions.h>
6
- #import <React/UIView+React.h>
7
6
  #import <react/utils/FollyConvert.h>
8
7
 
9
8
  #import <react/renderer/components/TinyuiSpec/ComponentDescriptors.h>
@@ -44,24 +43,15 @@ using namespace facebook::react;
44
43
  return self;
45
44
  }
46
45
 
47
- - (void)didMoveToWindow
48
- {
49
- [super didMoveToWindow];
50
- if (self.window != nil) {
51
- _menuProvider.parentViewController = [self reactViewController];
52
- }
53
- }
54
-
55
46
  #pragma mark - React lifecycle
56
47
 
57
48
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
58
49
  index:(NSInteger)index
59
50
  {
60
- // TinyuiMenuView renders its trigger as a child; we hand it to the
61
- // provider, which uses it as the SwiftUI `Menu` label.
51
+ // TinyuiMenuView renders its trigger as a child; the UIButton-backed
52
+ // provider uses it as the menu's visual content.
62
53
  _triggerView = childComponentView;
63
54
  _menuProvider.triggerView = childComponentView;
64
- [_menuProvider addSubview:childComponentView];
65
55
  }
66
56
 
67
57
  - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
@@ -74,6 +64,18 @@ using namespace facebook::react;
74
64
  }
75
65
  }
76
66
 
67
+ #pragma mark - Commands
68
+
69
+ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
70
+ {
71
+ RCTTinyuiMenuViewHandleCommand(self, commandName, args);
72
+ }
73
+
74
+ - (void)open
75
+ {
76
+ [_menuProvider openMenu];
77
+ }
78
+
77
79
  #pragma mark - Props
78
80
 
79
81
  - (void)updateProps:(Props::Shared const &)props
@@ -82,17 +84,12 @@ using namespace facebook::react;
82
84
  const auto &oldMenuProps = *std::static_pointer_cast<TinyuiMenuViewProps const>(_props);
83
85
  const auto &newMenuProps = *std::static_pointer_cast<TinyuiMenuViewProps const>(props);
84
86
 
85
- if (oldMenuProps.menuConfig != newMenuProps.menuConfig) {
86
- _menuProvider.menuConfig = convertFollyDynamicToId(newMenuProps.menuConfig);
87
- }
88
- if (oldMenuProps.title != newMenuProps.title) {
89
- _menuProvider.title = RCTNSStringFromString(newMenuProps.title);
90
- }
91
- if (oldMenuProps.hasPrimaryAction != newMenuProps.hasPrimaryAction) {
92
- _menuProvider.hasPrimaryAction = newMenuProps.hasPrimaryAction;
93
- }
94
- if (oldMenuProps.disabled != newMenuProps.disabled) {
95
- _menuProvider.disabled = newMenuProps.disabled;
87
+ if (oldMenuProps.menuConfig != newMenuProps.menuConfig ||
88
+ oldMenuProps.title != newMenuProps.title ||
89
+ oldMenuProps.disabled != newMenuProps.disabled) {
90
+ [_menuProvider updateWithMenuConfig:convertFollyDynamicToId(newMenuProps.menuConfig)
91
+ title:RCTNSStringFromString(newMenuProps.title)
92
+ disabled:newMenuProps.disabled];
96
93
  }
97
94
 
98
95
  [super updateProps:props oldProps:oldProps];
@@ -100,23 +97,15 @@ using namespace facebook::react;
100
97
 
101
98
  #pragma mark - TinyuiMenuViewDelegate
102
99
 
103
- - (void)onItemPressWithId:(NSString *)identifier
104
- {
105
- if (_eventEmitter == nullptr) {
106
- return;
107
- }
108
- std::static_pointer_cast<const TinyuiMenuViewEventEmitter>(_eventEmitter)
109
- ->onItemPress(TinyuiMenuViewEventEmitter::OnItemPress{
110
- .id = std::string(identifier.UTF8String ?: "")});
111
- }
112
-
113
- - (void)onPrimaryAction
100
+ - (void)onActionPressWithId:(NSString *)identifier title:(NSString *)title
114
101
  {
115
102
  if (_eventEmitter == nullptr) {
116
103
  return;
117
104
  }
118
105
  std::static_pointer_cast<const TinyuiMenuViewEventEmitter>(_eventEmitter)
119
- ->onPrimaryAction(TinyuiMenuViewEventEmitter::OnPrimaryAction{});
106
+ ->onActionPress(TinyuiMenuViewEventEmitter::OnActionPress{
107
+ .id = std::string(identifier.UTF8String ?: ""),
108
+ .title = std::string(title.UTF8String ?: "")});
120
109
  }
121
110
 
122
111
  @end
@@ -1,111 +1,265 @@
1
- import SwiftUI
2
1
  import UIKit
3
2
 
3
+ @objc public protocol TinyuiPopoverViewDelegate: AnyObject {
4
+ func onIsPresentedChange(isPresented: Bool)
5
+ }
6
+
4
7
  /**
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
+ * UIKit implementation used by the Fabric `TinyuiPopoverView` component.
9
+ * Presents the React Native content view with `UIPopoverPresentationController`
10
+ * and keeps the trigger view in the normal React Native view hierarchy.
8
11
  */
9
- @objc public class TinyuiPopoverProvider: UIView {
12
+ @objc public final class TinyuiPopoverProvider: UIView,
13
+ UIPopoverPresentationControllerDelegate
14
+ {
10
15
  private weak var delegate: TinyuiPopoverViewDelegate?
11
- private var hostingController: UIHostingController<TinyuiPopoverSwiftUIView>?
12
- private var props = TinyuiPopoverProps()
16
+ private var presentedViewController: UIViewController?
17
+ private var isDismissing = false
18
+ private var isPresentationUpdateScheduled = false
13
19
 
14
20
  /// Presenting view controller, resolved by the component view from its
15
21
  /// React view controller hierarchy.
16
22
  @objc public weak var parentViewController: UIViewController? {
17
23
  didSet {
18
- if window != nil {
19
- setupView()
20
- }
24
+ guard oldValue !== parentViewController else { return }
25
+ schedulePresentationUpdate()
21
26
  }
22
27
  }
23
28
 
24
29
  @objc public var triggerView: UIView? {
25
30
  didSet {
26
- props.triggerView = triggerView
31
+ guard oldValue !== triggerView else { return }
32
+ schedulePresentationUpdate()
27
33
  }
28
34
  }
29
35
 
30
36
  @objc public var contentView: UIView? {
31
37
  didSet {
32
- props.contentView = contentView
38
+ guard oldValue !== contentView else { return }
39
+ oldValue?.removeFromSuperview()
40
+ contentSize = .zero
41
+ schedulePresentationUpdate()
42
+ }
43
+ }
44
+
45
+ /// The content wrapper's Yoga size, synchronized after each Fabric commit.
46
+ /// Keep it separate from the presentation controller's UIKit layout.
47
+ @objc public var contentSize: CGSize = .zero {
48
+ didSet {
49
+ guard oldValue != contentSize else { return }
50
+ schedulePresentationUpdate()
33
51
  }
34
52
  }
35
53
 
36
54
  @objc public var isPresented: Bool = false {
37
55
  didSet {
38
- props.isPresented = isPresented
56
+ guard oldValue != isPresented else { return }
57
+ schedulePresentationUpdate()
39
58
  }
40
59
  }
41
60
 
42
61
  @objc public var attachmentAnchor: String = "center" {
43
62
  didSet {
44
- props.attachmentAnchor = attachmentAnchor
63
+ guard oldValue != attachmentAnchor else { return }
64
+ schedulePresentationUpdate()
45
65
  }
46
66
  }
47
67
 
48
68
  @objc public var arrowEdge: String = "none" {
49
69
  didSet {
50
- props.arrowEdge = arrowEdge
70
+ guard oldValue != arrowEdge else { return }
71
+ schedulePresentationUpdate()
51
72
  }
52
73
  }
53
74
 
54
75
  @objc public convenience init(delegate: TinyuiPopoverViewDelegate) {
55
- self.init()
76
+ self.init(frame: .zero)
56
77
  self.delegate = delegate
57
78
  }
58
79
 
59
80
  override public func didMoveToWindow() {
60
81
  super.didMoveToWindow()
61
- if window != nil {
62
- setupView()
63
- }
82
+ schedulePresentationUpdate()
64
83
  }
65
84
 
66
85
  override public func layoutSubviews() {
67
86
  super.layoutSubviews()
68
- if window != nil {
69
- setupView()
87
+ updatePresentedPopoverConfiguration()
88
+ }
89
+
90
+ private func schedulePresentationUpdate() {
91
+ guard !isPresentationUpdateScheduled else { return }
92
+ isPresentationUpdateScheduled = true
93
+
94
+ DispatchQueue.main.async { [weak self] in
95
+ guard let self else { return }
96
+ self.isPresentationUpdateScheduled = false
97
+ self.updatePresentation()
70
98
  }
71
99
  }
72
100
 
73
- private func setupView() {
74
- if hostingController != nil {
75
- syncParentViewController()
101
+ private func updatePresentation() {
102
+ guard isPresented, window != nil else {
103
+ dismissPopoverIfNeeded()
76
104
  return
77
105
  }
78
106
 
79
- guard let parentViewController else {
107
+ guard
108
+ presentedViewController == nil,
109
+ let parentViewController,
110
+ let triggerView,
111
+ triggerView.window != nil,
112
+ contentView != nil,
113
+ let preferredContentSize
114
+ else {
115
+ updatePresentedPopoverConfiguration()
80
116
  return
81
117
  }
82
118
 
83
- let hostingController = UIHostingController(
84
- rootView: TinyuiPopoverSwiftUIView(props: props, delegate: delegate)
85
- )
86
- self.hostingController = hostingController
119
+ let contentViewController = UIViewController()
120
+ // The system popover supplies its own material (Liquid Glass on iOS 26+).
121
+ // An opaque content background would cover it, including its adaptive tint.
122
+ contentViewController.view.backgroundColor = .clear
123
+ contentViewController.view.clipsToBounds = true
124
+ contentViewController.preferredContentSize = preferredContentSize
125
+ contentViewController.modalPresentationStyle = .popover
126
+ attachContent(to: contentViewController)
87
127
 
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)
128
+ guard let popoverPresentationController = contentViewController.popoverPresentationController
129
+ else {
130
+ return
131
+ }
132
+
133
+ configure(popoverPresentationController, from: triggerView)
134
+ popoverPresentationController.delegate = self
135
+ presentedViewController = contentViewController
136
+
137
+ parentViewController.present(contentViewController, animated: true) { [weak self] in
138
+ guard let self else { return }
139
+ if !self.isPresented || self.window == nil {
140
+ self.dismissPopoverIfNeeded()
141
+ }
142
+ }
143
+ }
144
+
145
+ private func dismissPopoverIfNeeded() {
146
+ guard let presentedViewController, !isDismissing else { return }
147
+
148
+ isDismissing = true
149
+ presentedViewController.dismiss(animated: true) { [weak self] in
150
+ guard let self else { return }
151
+ self.presentedViewController = nil
152
+ self.isDismissing = false
153
+ self.delegate?.onIsPresentedChange(isPresented: false)
154
+ if self.isPresented, self.window != nil {
155
+ self.schedulePresentationUpdate()
156
+ }
157
+ }
94
158
  }
95
159
 
96
- /// Keeps the hosting controller attached to the current view controller.
97
- private func syncParentViewController(to parentViewController: UIViewController? = nil) {
160
+ private func updatePresentedPopoverConfiguration() {
98
161
  guard
99
- let hostingController,
100
- let parentViewController = parentViewController ?? self.parentViewController,
101
- hostingController.parent !== parentViewController
162
+ let presentedViewController,
163
+ let triggerView,
164
+ let preferredContentSize,
165
+ let popoverPresentationController = presentedViewController.popoverPresentationController
102
166
  else {
103
167
  return
104
168
  }
105
169
 
106
- hostingController.willMove(toParent: nil)
107
- hostingController.removeFromParent()
108
- parentViewController.addChild(hostingController)
109
- hostingController.didMove(toParent: parentViewController)
170
+ attachContent(to: presentedViewController)
171
+ if presentedViewController.preferredContentSize != preferredContentSize {
172
+ presentedViewController.preferredContentSize = preferredContentSize
173
+ }
174
+ configure(popoverPresentationController, from: triggerView)
175
+ }
176
+
177
+ private func attachContent(to viewController: UIViewController) {
178
+ guard let contentView, contentView.superview !== viewController.view else { return }
179
+ contentView.removeFromSuperview()
180
+ // Fabric owns the content frame. Stretching it to the popover's bounds
181
+ // would overwrite the measured size and feed it back into presentation.
182
+ contentView.autoresizingMask = []
183
+ viewController.view.addSubview(contentView)
184
+ }
185
+
186
+ private func configure(
187
+ _ popoverPresentationController: UIPopoverPresentationController,
188
+ from triggerView: UIView
189
+ ) {
190
+ popoverPresentationController.sourceView = triggerView
191
+ popoverPresentationController.sourceRect = sourceRect(in: triggerView.bounds)
192
+ popoverPresentationController.permittedArrowDirections = permittedArrowDirections
193
+ }
194
+
195
+ private func sourceRect(in bounds: CGRect) -> CGRect {
196
+ let point: CGPoint
197
+ switch attachmentAnchor {
198
+ case "top":
199
+ point = CGPoint(x: bounds.midX, y: bounds.minY)
200
+ case "bottom":
201
+ point = CGPoint(x: bounds.midX, y: bounds.maxY)
202
+ case "leading":
203
+ point = CGPoint(x: bounds.minX, y: bounds.midY)
204
+ case "trailing":
205
+ point = CGPoint(x: bounds.maxX, y: bounds.midY)
206
+ default:
207
+ point = CGPoint(x: bounds.midX, y: bounds.midY)
208
+ }
209
+
210
+ return CGRect(x: point.x, y: point.y, width: 1, height: 1)
211
+ }
212
+
213
+ private var permittedArrowDirections: UIPopoverArrowDirection {
214
+ switch arrowEdge {
215
+ case "top":
216
+ return .up
217
+ case "bottom":
218
+ return .down
219
+ case "leading":
220
+ return .left
221
+ case "trailing":
222
+ return .right
223
+ default:
224
+ return .any
225
+ }
226
+ }
227
+
228
+ private var preferredContentSize: CGSize? {
229
+ guard contentSize.width.isFinite, contentSize.height.isFinite,
230
+ contentSize.width > 0, contentSize.height > 0
231
+ else {
232
+ // Wait for Fabric's first layout instead of presenting at an arbitrary
233
+ // fallback size that can become the content's next measured size.
234
+ return nil
235
+ }
236
+ return CGSize(width: ceil(contentSize.width), height: ceil(contentSize.height))
237
+ }
238
+
239
+ // Returning `.none` is UIKit's equivalent of keeping a SwiftUI popover as a
240
+ // popover in compact size classes (`presentationCompactAdaptation(.popover)`).
241
+ public func adaptivePresentationStyle(
242
+ for controller: UIPresentationController
243
+ ) -> UIModalPresentationStyle {
244
+ .none
245
+ }
246
+
247
+ public func adaptivePresentationStyle(
248
+ for controller: UIPresentationController,
249
+ traitCollection: UITraitCollection
250
+ ) -> UIModalPresentationStyle {
251
+ .none
252
+ }
253
+
254
+ public func presentationControllerDidDismiss(
255
+ _ presentationController: UIPresentationController
256
+ ) {
257
+ guard presentedViewController === presentationController.presentedViewController else {
258
+ return
259
+ }
260
+
261
+ presentedViewController = nil
262
+ isDismissing = false
263
+ delegate?.onIsPresentedChange(isPresented: false)
110
264
  }
111
265
  }
@@ -3,6 +3,8 @@
3
3
  #import "TinyuiPopoverView.h"
4
4
 
5
5
  #import <React/RCTConversions.h>
6
+ #import <React/RCTMountingTransactionObserving.h>
7
+ #import <React/RCTSurfaceTouchHandler.h>
6
8
  #import <React/UIView+React.h>
7
9
 
8
10
  #import <react/renderer/components/TinyuiSpec/ComponentDescriptors.h>
@@ -18,13 +20,16 @@
18
20
 
19
21
  using namespace facebook::react;
20
22
 
21
- @interface TinyuiPopoverView () <RCTTinyuiPopoverViewViewProtocol, TinyuiPopoverViewDelegate>
23
+ @interface TinyuiPopoverView () <RCTTinyuiPopoverViewViewProtocol,
24
+ TinyuiPopoverViewDelegate,
25
+ RCTMountingTransactionObserving>
22
26
  @end
23
27
 
24
28
  @implementation TinyuiPopoverView {
25
29
  TinyuiPopoverProvider *_popoverProvider;
26
30
  UIView *_triggerView;
27
31
  UIView<RCTComponentViewProtocol> *_contentComponentView;
32
+ RCTSurfaceTouchHandler *_contentTouchHandler;
28
33
  }
29
34
 
30
35
  + (ComponentDescriptorProvider)componentDescriptorProvider
@@ -39,6 +44,7 @@ using namespace facebook::react;
39
44
  _props = defaultProps;
40
45
 
41
46
  _popoverProvider = [[TinyuiPopoverProvider alloc] initWithDelegate:self];
47
+ _contentTouchHandler = [RCTSurfaceTouchHandler new];
42
48
  self.contentView = _popoverProvider;
43
49
  }
44
50
  return self;
@@ -68,6 +74,8 @@ using namespace facebook::react;
68
74
  // Second child is the popover content, presented inside the popover.
69
75
  NSAssert(_contentComponentView == nil, @"TinyuiPopoverView accepts one content child.");
70
76
  _contentComponentView = childComponentView;
77
+ // The popover is outside the React surface's native view hierarchy.
78
+ [_contentTouchHandler attachToView:childComponentView];
71
79
  _popoverProvider.contentView = childComponentView;
72
80
  }
73
81
 
@@ -79,11 +87,23 @@ using namespace facebook::react;
79
87
  _triggerView = nil;
80
88
  _popoverProvider.triggerView = nil;
81
89
  } else if (_contentComponentView == childComponentView) {
90
+ [_contentTouchHandler detachFromView:childComponentView];
82
91
  _contentComponentView = nil;
83
92
  _popoverProvider.contentView = nil;
84
93
  }
85
94
  }
86
95
 
96
+ - (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
97
+ withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
98
+ {
99
+ // Content can change size without changing the trigger's layout or props.
100
+ // Read only after Fabric has applied the entire transaction. UIKit must not
101
+ // resize this child, so its bounds remain the size computed by Yoga.
102
+ _popoverProvider.contentSize = _contentComponentView != nil
103
+ ? _contentComponentView.bounds.size
104
+ : CGSizeZero;
105
+ }
106
+
87
107
  #pragma mark - Props
88
108
 
89
109
  - (void)updateProps:(Props::Shared const &)props
@@ -112,10 +132,15 @@ using namespace facebook::react;
112
132
 
113
133
  - (void)prepareForRecycle
114
134
  {
135
+ _popoverProvider.isPresented = NO;
136
+ if (_contentComponentView != nil) {
137
+ [_contentTouchHandler detachFromView:_contentComponentView];
138
+ }
115
139
  _triggerView = nil;
116
140
  _contentComponentView = nil;
117
141
  _popoverProvider.triggerView = nil;
118
142
  _popoverProvider.contentView = nil;
143
+ _popoverProvider.parentViewController = nil;
119
144
  [super prepareForRecycle];
120
145
  }
121
146
 
@@ -0,0 +1,9 @@
1
+ #pragma once
2
+
3
+ #import <Symbols/Symbols.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+ NSSymbolEffect * _Nullable TinyuiSFSymbolEffect(NSDictionary *configuration);
7
+ NSSymbolEffectOptions *TinyuiSFSymbolOptions(NSDictionary * _Nullable configuration);
8
+ NSSymbolContentTransition * _Nullable TinyuiSFSymbolTransition(NSDictionary *configuration);
9
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,180 @@
1
+ #if TINYUI_FEATURE_SF_SYMBOL
2
+
3
+ #import "TinyuiSFSymbolEffects.h"
4
+ #include <cmath>
5
+
6
+ // Keep the concrete receiver types so the compiler checks every Symbols API.
7
+ #define TINYUI_LAYER_SCOPE(effect) \
8
+ if ([scope isEqual:@"byLayer"]) effect = [effect effectWithByLayer]; \
9
+ if ([scope isEqual:@"wholeSymbol"]) effect = [effect effectWithWholeSymbol];
10
+
11
+ NSSymbolEffect *TinyuiSFSymbolEffect(NSDictionary *configuration)
12
+ {
13
+ NSString *type = configuration[@"type"];
14
+ NSString *scope = configuration[@"scope"];
15
+ NSString *direction = configuration[@"direction"];
16
+ if ([type isEqual:@"pulse"]) {
17
+ NSSymbolPulseEffect *effect = NSSymbolPulseEffect.effect;
18
+ TINYUI_LAYER_SCOPE(effect);
19
+ return effect;
20
+ }
21
+ if ([type isEqual:@"bounce"]) {
22
+ NSSymbolBounceEffect *effect = NSSymbolBounceEffect.effect;
23
+ if ([direction isEqual:@"up"]) effect = NSSymbolBounceEffect.bounceUpEffect;
24
+ if ([direction isEqual:@"down"]) effect = NSSymbolBounceEffect.bounceDownEffect;
25
+ TINYUI_LAYER_SCOPE(effect);
26
+ return effect;
27
+ }
28
+ if ([type isEqual:@"scale"]) {
29
+ NSSymbolScaleEffect *effect = NSSymbolScaleEffect.effect;
30
+ if ([direction isEqual:@"up"]) effect = NSSymbolScaleEffect.scaleUpEffect;
31
+ if ([direction isEqual:@"down"]) effect = NSSymbolScaleEffect.scaleDownEffect;
32
+ TINYUI_LAYER_SCOPE(effect);
33
+ return effect;
34
+ }
35
+ if ([type isEqual:@"appear"]) {
36
+ NSSymbolAppearEffect *effect = NSSymbolAppearEffect.effect;
37
+ if ([direction isEqual:@"up"]) effect = NSSymbolAppearEffect.appearUpEffect;
38
+ if ([direction isEqual:@"down"]) effect = NSSymbolAppearEffect.appearDownEffect;
39
+ TINYUI_LAYER_SCOPE(effect);
40
+ return effect;
41
+ }
42
+ if ([type isEqual:@"disappear"]) {
43
+ NSSymbolDisappearEffect *effect = NSSymbolDisappearEffect.effect;
44
+ if ([direction isEqual:@"up"]) effect = NSSymbolDisappearEffect.disappearUpEffect;
45
+ if ([direction isEqual:@"down"]) effect = NSSymbolDisappearEffect.disappearDownEffect;
46
+ TINYUI_LAYER_SCOPE(effect);
47
+ return effect;
48
+ }
49
+ if ([type isEqual:@"variableColor"]) {
50
+ NSSymbolVariableColorEffect *effect = NSSymbolVariableColorEffect.effect;
51
+ if ([configuration[@"iteration"] isEqual:@"iterative"]) effect = [effect effectWithIterative];
52
+ if ([configuration[@"iteration"] isEqual:@"cumulative"]) effect = [effect effectWithCumulative];
53
+ if ([configuration[@"reversing"] isKindOfClass:NSNumber.class]) {
54
+ effect = [configuration[@"reversing"] boolValue]
55
+ ? [effect effectWithReversing] : [effect effectWithNonReversing];
56
+ }
57
+ if ([configuration[@"inactiveLayers"] isEqual:@"hide"]) effect = [effect effectWithHideInactiveLayers];
58
+ if ([configuration[@"inactiveLayers"] isEqual:@"dim"]) effect = [effect effectWithDimInactiveLayers];
59
+ return effect;
60
+ }
61
+ if (@available(iOS 18.0, *)) {
62
+ if ([type isEqual:@"wiggle"]) {
63
+ NSSymbolWiggleEffect *effect = NSSymbolWiggleEffect.effect;
64
+ if ([direction isEqual:@"up"]) effect = NSSymbolWiggleEffect.wiggleUpEffect;
65
+ if ([direction isEqual:@"down"]) effect = NSSymbolWiggleEffect.wiggleDownEffect;
66
+ if ([direction isEqual:@"left"]) effect = NSSymbolWiggleEffect.wiggleLeftEffect;
67
+ if ([direction isEqual:@"right"]) effect = NSSymbolWiggleEffect.wiggleRightEffect;
68
+ if ([direction isEqual:@"forward"]) effect = NSSymbolWiggleEffect.wiggleForwardEffect;
69
+ if ([direction isEqual:@"backward"]) effect = NSSymbolWiggleEffect.wiggleBackwardEffect;
70
+ if ([direction isEqual:@"clockwise"]) effect = NSSymbolWiggleEffect.wiggleClockwiseEffect;
71
+ if ([direction isEqual:@"counterClockwise"]) effect = NSSymbolWiggleEffect.wiggleCounterClockwiseEffect;
72
+ id angle = configuration[@"angle"];
73
+ if ([angle isKindOfClass:NSNumber.class] && std::isfinite([angle doubleValue])) {
74
+ effect = [NSSymbolWiggleEffect wiggleCustomAngleEffect:[angle doubleValue]];
75
+ }
76
+ TINYUI_LAYER_SCOPE(effect);
77
+ return effect;
78
+ }
79
+ if ([type isEqual:@"rotate"]) {
80
+ NSSymbolRotateEffect *effect = NSSymbolRotateEffect.effect;
81
+ if ([direction isEqual:@"clockwise"]) effect = NSSymbolRotateEffect.rotateClockwiseEffect;
82
+ if ([direction isEqual:@"counterClockwise"]) effect = NSSymbolRotateEffect.rotateCounterClockwiseEffect;
83
+ TINYUI_LAYER_SCOPE(effect);
84
+ return effect;
85
+ }
86
+ if ([type isEqual:@"breathe"]) {
87
+ NSSymbolBreatheEffect *effect = NSSymbolBreatheEffect.effect;
88
+ if ([configuration[@"style"] isEqual:@"plain"]) effect = NSSymbolBreatheEffect.breathePlainEffect;
89
+ if ([configuration[@"style"] isEqual:@"pulse"]) effect = NSSymbolBreatheEffect.breathePulseEffect;
90
+ TINYUI_LAYER_SCOPE(effect);
91
+ return effect;
92
+ }
93
+ }
94
+ if (@available(iOS 26.0, *)) {
95
+ if ([type isEqual:@"drawOn"]) {
96
+ NSSymbolDrawOnEffect *effect = NSSymbolDrawOnEffect.effect;
97
+ TINYUI_LAYER_SCOPE(effect);
98
+ if ([scope isEqual:@"individually"]) effect = [effect effectWithIndividually];
99
+ return effect;
100
+ }
101
+ if ([type isEqual:@"drawOff"]) {
102
+ NSSymbolDrawOffEffect *effect = NSSymbolDrawOffEffect.effect;
103
+ TINYUI_LAYER_SCOPE(effect);
104
+ if ([scope isEqual:@"individually"]) effect = [effect effectWithIndividually];
105
+ if ([configuration[@"reversed"] isKindOfClass:NSNumber.class]) {
106
+ effect = [configuration[@"reversed"] boolValue]
107
+ ? [effect effectWithReversed] : [effect effectWithNonReversed];
108
+ }
109
+ return effect;
110
+ }
111
+ }
112
+ return nil;
113
+ }
114
+
115
+ #undef TINYUI_LAYER_SCOPE
116
+
117
+ NSSymbolEffectOptions *TinyuiSFSymbolOptions(NSDictionary *configuration)
118
+ {
119
+ NSSymbolEffectOptions *options = NSSymbolEffectOptions.options;
120
+ if (![configuration isKindOfClass:NSDictionary.class]) return options;
121
+ id speedValue = configuration[@"speed"];
122
+ double speed = [speedValue isKindOfClass:NSNumber.class] ? [speedValue doubleValue] : 1;
123
+ if (std::isfinite(speed) && speed > 0) options = [options optionsWithSpeed:speed];
124
+ id repeat = configuration[@"repeat"];
125
+ if ([repeat isEqual:@NO]) return [options optionsWithNonRepeating];
126
+ BOOL countProvided = [repeat isKindOfClass:NSNumber.class];
127
+ // JS validates safe integers; cap the conversion at the native integer range.
128
+ NSInteger count = countProvided ? (NSInteger)fmin([repeat doubleValue], (double)NSIntegerMax / 2) : 1;
129
+ count = MAX(1, count);
130
+ id behaviorValue = configuration[@"repeatBehavior"];
131
+ NSString *behavior = [behaviorValue isKindOfClass:NSString.class] ? behaviorValue : nil;
132
+ id delayValue = configuration[@"repeatDelay"];
133
+ BOOL hasDelay = [delayValue isKindOfClass:NSNumber.class];
134
+ double delay = hasDelay ? [delayValue doubleValue] : 0;
135
+ if (!std::isfinite(delay) || delay < 0) delay = 0;
136
+ BOOL forever = [repeat isEqual:@"forever"] || behavior != nil || hasDelay;
137
+
138
+ if (@available(iOS 18.0, *)) {
139
+ NSSymbolEffectOptionsRepeatBehavior *repetition = nil;
140
+ if ([behavior isEqual:@"continuous"]) {
141
+ repetition = NSSymbolEffectOptionsRepeatBehavior.behaviorContinuous;
142
+ } else if (countProvided) {
143
+ repetition = hasDelay
144
+ ? [NSSymbolEffectOptionsRepeatBehavior behaviorPeriodicWithCount:count delay:delay]
145
+ : [NSSymbolEffectOptionsRepeatBehavior behaviorPeriodicWithCount:count];
146
+ } else if (forever) {
147
+ repetition = hasDelay
148
+ ? [NSSymbolEffectOptionsRepeatBehavior behaviorPeriodicWithDelay:delay]
149
+ : NSSymbolEffectOptionsRepeatBehavior.behaviorPeriodic;
150
+ }
151
+ if (repetition != nil) options = [options optionsWithRepeatBehavior:repetition];
152
+ } else {
153
+ if (countProvided) options = [options optionsWithRepeatCount:count];
154
+ else if (forever) options = [options optionsWithRepeating];
155
+ }
156
+ return options;
157
+ }
158
+
159
+ NSSymbolContentTransition *TinyuiSFSymbolTransition(NSDictionary *configuration)
160
+ {
161
+ NSString *type = configuration[@"type"];
162
+ if ([type isEqual:@"automatic"]) return NSSymbolAutomaticContentTransition.transition;
163
+ if (![type isEqual:@"replace"] && ![type isEqual:@"magicReplace"]) return nil;
164
+
165
+ NSSymbolReplaceContentTransition *transition = NSSymbolReplaceContentTransition.transition;
166
+ NSString *direction = configuration[@"direction"];
167
+ if ([direction isEqual:@"downUp"]) transition = NSSymbolReplaceContentTransition.replaceDownUpTransition;
168
+ if ([direction isEqual:@"upUp"]) transition = NSSymbolReplaceContentTransition.replaceUpUpTransition;
169
+ if ([direction isEqual:@"offUp"]) transition = NSSymbolReplaceContentTransition.replaceOffUpTransition;
170
+ if ([configuration[@"scope"] isEqual:@"byLayer"]) transition = [transition transitionWithByLayer];
171
+ if ([configuration[@"scope"] isEqual:@"wholeSymbol"]) transition = [transition transitionWithWholeSymbol];
172
+ if (@available(iOS 18.0, *)) {
173
+ if ([type isEqual:@"magicReplace"]) {
174
+ return [NSSymbolReplaceContentTransition magicTransitionWithFallback:transition];
175
+ }
176
+ }
177
+ return transition;
178
+ }
179
+
180
+ #endif