react-native-tinyui 2.0.0 → 3.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 (73) hide show
  1. package/README.md +177 -42
  2. package/ios/Core/Tinyui.mm +3 -0
  3. package/ios/Core/TinyuiComponentClasses.mm +18 -0
  4. package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
  5. package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
  6. package/ios/TipKit/TinyuiTip.swift +16 -0
  7. package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
  8. package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
  9. package/ios/TipKit/TinyuiTipView.h +8 -0
  10. package/ios/TipKit/TinyuiTipView.mm +93 -0
  11. package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
  12. package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
  13. package/ios/TipKit/TinyuiTipsRuntime.swift +2 -74
  14. package/lib/module/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  15. package/lib/module/SwipeActions/actions.js +21 -0
  16. package/lib/module/SwipeActions/actions.js.map +1 -0
  17. package/lib/module/SwipeActions/index.js +43 -0
  18. package/lib/module/SwipeActions/index.js.map +1 -0
  19. package/lib/module/SwipeActions/types.js +4 -0
  20. package/lib/module/SwipeActions/types.js.map +1 -0
  21. package/lib/module/TipKit/Popover.js +0 -22
  22. package/lib/module/TipKit/Popover.js.map +1 -1
  23. package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
  24. package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
  25. package/lib/module/TipKit/View.js +62 -0
  26. package/lib/module/TipKit/View.js.map +1 -0
  27. package/lib/module/TipKit/index.js +2 -0
  28. package/lib/module/TipKit/index.js.map +1 -1
  29. package/lib/module/TipKit/validation.js +4 -4
  30. package/lib/module/TipKit/validation.js.map +1 -1
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/utils/getNativeTinyui.js.map +1 -1
  34. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts +15 -0
  35. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts.map +1 -0
  36. package/lib/typescript/src/SwipeActions/actions.d.ts +9 -0
  37. package/lib/typescript/src/SwipeActions/actions.d.ts.map +1 -0
  38. package/lib/typescript/src/SwipeActions/index.d.ts +4 -0
  39. package/lib/typescript/src/SwipeActions/index.d.ts.map +1 -0
  40. package/lib/typescript/src/SwipeActions/types.d.ts +37 -0
  41. package/lib/typescript/src/SwipeActions/types.d.ts.map +1 -0
  42. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  43. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
  44. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  45. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
  46. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
  47. package/lib/typescript/src/TipKit/View.d.ts +3 -0
  48. package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
  49. package/lib/typescript/src/TipKit/index.d.ts +2 -1
  50. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  51. package/lib/typescript/src/TipKit/types.d.ts +17 -15
  52. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  53. package/lib/typescript/src/TipKit/validation.d.ts +2 -2
  54. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
  55. package/lib/typescript/src/index.d.ts +3 -1
  56. package/lib/typescript/src/index.d.ts.map +1 -1
  57. package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
  58. package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
  59. package/package.json +13 -2
  60. package/react-native-tinyui.podspec +6 -1
  61. package/src/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  62. package/src/SwipeActions/actions.ts +26 -0
  63. package/src/SwipeActions/index.tsx +54 -0
  64. package/src/SwipeActions/types.ts +37 -0
  65. package/src/TipKit/Popover.tsx +1 -21
  66. package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
  67. package/src/TipKit/TipViewNativeComponent.ts +28 -0
  68. package/src/TipKit/View.tsx +64 -0
  69. package/src/TipKit/index.ts +3 -1
  70. package/src/TipKit/types.ts +19 -20
  71. package/src/TipKit/validation.ts +8 -5
  72. package/src/index.tsx +8 -1
  73. package/src/utils/getNativeTinyui.ts +1 -0
@@ -29,3 +29,19 @@ struct TinyuiTip: Tip, Sendable {
29
29
  return result
30
30
  }
31
31
  }
32
+
33
+ extension TinyuiTip {
34
+ init(configuration: [String: Any]) {
35
+ self.init(
36
+ id: configuration["tipId"] as? String ?? "",
37
+ titleString: configuration["title"] as? String ?? "",
38
+ messageString: configuration["message"] as? String ?? "",
39
+ symbolName: configuration["systemImage"] as? String ?? "",
40
+ actionContents: (configuration["actions"] as? [[String: String]] ?? []).map {
41
+ ActionContent(id: $0["id"] ?? "", title: $0["title"] ?? "")
42
+ },
43
+ maximumDisplayCount: configuration["maxDisplayCount"] as? Int ?? 0,
44
+ ignoresFrequency: configuration["ignoresDisplayFrequency"] as? Bool ?? false
45
+ )
46
+ }
47
+ }
@@ -2,8 +2,6 @@ import TipKit
2
2
  import UIKit
3
3
 
4
4
  @objc public protocol TinyuiTipPopoverDelegate: AnyObject {
5
- func onTipStatus(tipId: String, status: String, reason: String)
6
- func onTipVisible(tipId: String, visible: Bool)
7
5
  func onTipAction(tipId: String, id: String, title: String)
8
6
  func onTipError(tipId: String, code: String, message: String)
9
7
  }
@@ -12,21 +10,22 @@ import UIKit
12
10
  @objc public final class TinyuiTipPopoverProvider: UIView, UIPopoverPresentationControllerDelegate {
13
11
  private weak var delegate: TinyuiTipPopoverDelegate?
14
12
  @objc public weak var parentViewController: UIViewController? {
15
- didSet { TinyuiTipsRuntime.shared.requestUpdate() }
13
+ didSet {
14
+ guard oldValue !== parentViewController else { return }
15
+ stop()
16
+ start()
17
+ }
16
18
  }
17
19
  private var tip: TinyuiTip?
18
20
  private var enabled = false
19
21
  private var arrowEdge = "auto"
20
22
  private var observation: Task<Void, Never>?
21
- private var observationGeneration = 0
22
- private var status: Tips.Status?
23
+ private var shouldDisplay = false
23
24
  private var lastError: String?
24
- private var suppressed = false
25
25
  private var controller: TipUIPopoverViewController?
26
26
  private var presentedTipId: String?
27
27
  private var isPresenting = false
28
28
  private var isDismissing = false
29
- private var reportedVisible = false
30
29
 
31
30
  @objc public convenience init(delegate: TinyuiTipPopoverDelegate) {
32
31
  self.init(frame: .zero)
@@ -34,60 +33,48 @@ import UIKit
34
33
  }
35
34
 
36
35
  @objc public func configure(_ configuration: [String: Any], enabled: Bool, arrowEdge: String) {
37
- let nextTip = TinyuiTip(
38
- id: configuration["tipId"] as? String ?? "",
39
- titleString: configuration["title"] as? String ?? "",
40
- messageString: configuration["message"] as? String ?? "",
41
- symbolName: configuration["systemImage"] as? String ?? "",
42
- actionContents: (configuration["actions"] as? [[String: String]] ?? []).map {
43
- TinyuiTip.ActionContent(id: $0["id"] ?? "", title: $0["title"] ?? "")
44
- },
45
- maximumDisplayCount: configuration["maxDisplayCount"] as? Int ?? 0,
46
- ignoresFrequency: configuration["ignoresDisplayFrequency"] as? Bool ?? false
47
- )
48
- if tip?.id != nextTip.id {
49
- stopObservation()
50
- status = nil
36
+ let nextTip = TinyuiTip(configuration: configuration)
37
+ if tip?.id != nextTip.id || self.enabled != enabled {
38
+ stop()
51
39
  lastError = nil
52
- suppressed = false
53
40
  }
54
- if !self.enabled && enabled { suppressed = false }
55
41
  tip = nextTip
56
42
  self.enabled = enabled
57
43
  self.arrowEdge = arrowEdge
58
- TinyuiTipsRuntime.shared.requestUpdate()
44
+ // Validate changed options even when an observation is already running.
45
+ do {
46
+ try TinyuiTipsRuntime.shared.remember(nextTip)
47
+ } catch {
48
+ stop()
49
+ let error = error as NSError
50
+ reportError(error.userInfo["code"] as? String ?? "E_TIP", error.localizedDescription)
51
+ return
52
+ }
53
+ lastError = nil
54
+ configureArrow()
55
+ start()
59
56
  }
60
57
 
61
58
  override public func didMoveToWindow() {
62
59
  super.didMoveToWindow()
63
- if window == nil {
64
- stopObservation()
65
- dismissTip()
66
- TinyuiTipsRuntime.shared.unregister(self)
67
- } else {
68
- TinyuiTipsRuntime.shared.register(self)
69
- }
60
+ if window == nil { stop() } else { start() }
70
61
  }
71
62
 
72
63
  override public func layoutSubviews() {
73
64
  super.layoutSubviews()
74
65
  configureArrow()
75
- TinyuiTipsRuntime.shared.requestUpdate()
66
+ // Start once Fabric has given the anchor a size. Layout changes alone do
67
+ // not request another presentation after a tip has been dismissed.
68
+ start()
76
69
  }
77
70
 
78
- var needsMonitoring: Bool {
79
- window?.windowScene?.activationState == .foregroundActive &&
80
- enabled && status == .available && !suppressed && lastError == nil
81
- }
82
-
83
- func updatePresentation() {
71
+ private func start() {
72
+ guard observation == nil, enabled, let tip, let window,
73
+ !bounds.isEmpty, let parent = parentViewController,
74
+ parent.viewIfLoaded?.window === window else { return }
84
75
  let runtime = TinyuiTipsRuntime.shared
85
- guard let tip else { dismissTip(); return }
86
- guard window != nil, runtime.isConfigured else {
87
- if window != nil && enabled && !runtime.isConfigured {
88
- reportError("E_TIPS_NOT_CONFIGURED", "Call and await TipKit.configure() before displaying tips.")
89
- }
90
- dismissTip()
76
+ guard runtime.isConfigured else {
77
+ reportError("E_TIPS_NOT_CONFIGURED", "Call and await TipKit.configure() before displaying tips.")
91
78
  return
92
79
  }
93
80
  do {
@@ -96,25 +83,38 @@ import UIKit
96
83
  } catch {
97
84
  let error = error as NSError
98
85
  reportError(error.userInfo["code"] as? String ?? "E_TIP", error.localizedDescription)
99
- dismissTip()
100
86
  return
101
87
  }
102
- startObservation(tip)
103
88
 
89
+ observation = Task { @MainActor [weak self] in
90
+ for await shouldDisplay in tip.shouldDisplayUpdates {
91
+ guard !Task.isCancelled, let self else { break }
92
+ self.shouldDisplay = shouldDisplay
93
+ self.updatePresentation()
94
+ }
95
+ }
96
+ }
97
+
98
+ private func stop() {
99
+ observation?.cancel()
100
+ observation = nil
101
+ shouldDisplay = false
102
+ dismissTip()
103
+ }
104
+
105
+ private func updatePresentation() {
104
106
  if let controller, !isPresenting, !isDismissing, controller.presentingViewController == nil {
105
107
  // Dismissal may originate inside TipKit rather than through the delegate.
106
- suppressed = true
107
108
  finishDismissal(controller)
108
109
  return
109
110
  }
110
- let presenter = eligiblePresenter()
111
- guard enabled, status == .available, !suppressed, presenter != nil,
111
+ guard enabled, window != nil, let tip, shouldDisplay,
112
112
  presentedTipId == nil || presentedTipId == tip.id else {
113
113
  dismissTip()
114
114
  return
115
115
  }
116
- guard controller == nil, runtime.activeProvider == nil, let presenter else { return }
117
- runtime.activeProvider = self
116
+ // Like Tipster's TipPresenter, only present when the host is free.
117
+ guard controller == nil, let presenter = availablePresenter() else { return }
118
118
  let tipController = TipUIPopoverViewController(tip, sourceItem: self) { [weak self] action in
119
119
  guard let self, self.tip?.id == tip.id,
120
120
  let content = tip.actionContents.first(where: { $0.id == action.id }) else { return }
@@ -125,84 +125,22 @@ import UIKit
125
125
  presentedTipId = tip.id
126
126
  configureArrow()
127
127
  isPresenting = true
128
- presenter.present(tipController, animated: true) { [weak self, weak tipController] in
129
- guard let self, let tipController, self.controller === tipController else { return }
128
+ // Retain the provider through the animation so a Fabric unmount can still
129
+ // dismiss this presentation in the completion, without a global owner.
130
+ presenter.present(tipController, animated: true) { [self] in
131
+ guard self.controller === tipController else { return }
130
132
  self.isPresenting = false
131
- if tipController.presentingViewController != nil {
132
- self.reportedVisible = true
133
- self.delegate?.onTipVisible(tipId: tip.id, visible: true)
134
- }
135
- TinyuiTipsRuntime.shared.requestUpdate()
136
- }
137
- }
138
-
139
- private func startObservation(_ tip: TinyuiTip) {
140
- guard observation == nil else { return }
141
- observationGeneration += 1
142
- let generation = observationGeneration
143
- observation = Task { @MainActor [weak self] in
144
- for await status in tip.statusUpdates {
145
- guard !Task.isCancelled, let self, self.observationGeneration == generation else { break }
146
- self.status = status
147
- let state: String
148
- let reason: String
149
- switch status {
150
- case .pending: state = "pending"; reason = ""
151
- case .available: state = "available"; reason = ""
152
- case .invalidated(let invalidation):
153
- state = "invalidated"
154
- switch invalidation {
155
- case .actionPerformed: reason = "actionPerformed"
156
- case .tipClosed: reason = "tipClosed"
157
- case .displayCountExceeded: reason = "displayCountExceeded"
158
- case .displayDurationExceeded: reason = "displayDurationExceeded"
159
- @unknown default: reason = "unknown"
160
- }
161
- @unknown default: state = "pending"; reason = ""
162
- }
163
- self.delegate?.onTipStatus(tipId: tip.id, status: state, reason: reason)
164
- TinyuiTipsRuntime.shared.requestUpdate()
165
- }
133
+ self.updatePresentation()
166
134
  }
167
135
  }
168
136
 
169
- private func stopObservation() {
170
- observationGeneration += 1
171
- observation?.cancel()
172
- observation = nil
173
- status = nil
174
- }
175
-
176
- /// Reject obscured/detached anchors and non-current navigation/tab screens.
177
- private func eligiblePresenter() -> UIViewController? {
178
- guard let window, window.windowScene?.activationState == .foregroundActive,
179
- !window.isHidden, let parent = parentViewController,
180
- parent.viewIfLoaded?.window === window, !bounds.isEmpty else { return nil }
181
-
182
- var visibleRect = convert(bounds, to: window)
183
- var ancestor: UIView? = self
184
- while let view = ancestor {
185
- guard !view.isHidden, view.alpha > 0.01 else { return nil }
186
- if view.clipsToBounds {
187
- visibleRect = visibleRect.intersection(view.convert(view.bounds, to: window))
188
- }
189
- ancestor = view.superview
190
- }
191
- guard !visibleRect.intersection(window.bounds).isEmpty else { return nil }
192
-
193
- var child = parent
137
+ private func availablePresenter() -> UIViewController? {
138
+ guard let window, let parent = parentViewController,
139
+ parent.viewIfLoaded?.window === window else { return nil }
194
140
  var ancestorController: UIViewController? = parent
195
141
  while let current = ancestorController {
196
- guard !current.isBeingDismissed, !current.isBeingPresented else { return nil }
197
- if let navigation = current as? UINavigationController,
198
- navigation.topViewController !== child { return nil }
199
- if let tabs = current as? UITabBarController,
200
- tabs.selectedViewController !== child { return nil }
201
- if let presented = current.presentedViewController, presented !== controller { return nil }
202
- // Wait for navigation animations before presenting. Do not treat our own
203
- // popover's presentation transition as the anchor leaving the screen.
204
- if controller == nil && current.transitionCoordinator != nil { return nil }
205
- child = current
142
+ guard !current.isBeingDismissed, !current.isBeingPresented,
143
+ current.presentedViewController == nil else { return nil }
206
144
  ancestorController = current.parent
207
145
  }
208
146
  return parent
@@ -226,28 +164,24 @@ import UIKit
226
164
  guard let controller, !isPresenting, !isDismissing else { return }
227
165
  guard controller.presentingViewController != nil else { finishDismissal(controller); return }
228
166
  isDismissing = true
229
- controller.dismiss(animated: window != nil) { [weak self, weak controller] in
230
- guard let self, let controller else { return }
167
+ controller.dismiss(animated: window != nil) { [self] in
168
+ guard self.controller === controller else { return }
231
169
  self.finishDismissal(controller)
170
+ // Props may have changed while the dismissal animation was running.
171
+ self.updatePresentation()
232
172
  }
233
173
  }
234
174
 
235
175
  private func finishDismissal(_ dismissed: TipUIPopoverViewController) {
236
176
  guard controller === dismissed else { return }
237
- if reportedVisible, let presentedTipId {
238
- delegate?.onTipVisible(tipId: presentedTipId, visible: false)
239
- }
240
177
  controller = nil
241
178
  presentedTipId = nil
242
179
  isPresenting = false
243
180
  isDismissing = false
244
- reportedVisible = false
245
- TinyuiTipsRuntime.shared.release(self)
246
181
  }
247
182
 
248
183
  public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
249
184
  guard let controller, presentationController.presentedViewController === controller else { return }
250
- suppressed = true
251
185
  finishDismissal(controller)
252
186
  }
253
187
 
@@ -264,12 +198,9 @@ import UIKit
264
198
  @objc public func reset() {
265
199
  enabled = false
266
200
  tip = nil
267
- suppressed = false
268
201
  lastError = nil
269
- stopObservation()
270
- dismissTip()
202
+ stop()
271
203
  parentViewController = nil
272
- TinyuiTipsRuntime.shared.unregister(self)
273
204
  }
274
205
 
275
206
  deinit { observation?.cancel() }
@@ -69,24 +69,6 @@ using namespace facebook::react;
69
69
  [super updateProps:props oldProps:oldProps];
70
70
  }
71
71
 
72
- - (void)onTipStatusWithTipId:(NSString *)tipId status:(NSString *)status reason:(NSString *)reason
73
- {
74
- if (_eventEmitter == nullptr) return;
75
- std::static_pointer_cast<const TinyuiTipPopoverViewEventEmitter>(_eventEmitter)->onTipStatusChange({
76
- .tipId = std::string(tipId.UTF8String ?: ""),
77
- .status = std::string(status.UTF8String ?: ""),
78
- .reason = std::string(reason.UTF8String ?: ""),
79
- });
80
- }
81
-
82
- - (void)onTipVisibleWithTipId:(NSString *)tipId visible:(BOOL)visible
83
- {
84
- if (_eventEmitter == nullptr) return;
85
- std::static_pointer_cast<const TinyuiTipPopoverViewEventEmitter>(_eventEmitter)->onTipVisibleChange({
86
- .tipId = std::string(tipId.UTF8String ?: ""), .visible = static_cast<bool>(visible),
87
- });
88
- }
89
-
90
72
  - (void)onTipActionWithTipId:(NSString *)tipId id:(NSString *)identifier title:(NSString *)title
91
73
  {
92
74
  if (_eventEmitter == nullptr) return;
@@ -0,0 +1,8 @@
1
+ #if TINYUI_FEATURE_TIP_KIT
2
+ #import <React/RCTViewComponentView.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+ @interface TinyuiTipView : RCTViewComponentView
6
+ @end
7
+ NS_ASSUME_NONNULL_END
8
+ #endif
@@ -0,0 +1,93 @@
1
+ #if TINYUI_FEATURE_TIP_KIT
2
+ #import "TinyuiTipView.h"
3
+ #import "TinyuiTipViewShadowNode.h"
4
+ #import "Tinyui-Swift.h"
5
+ #import <React/RCTConversions.h>
6
+ #import <react/renderer/components/TinyuiSpec/RCTComponentViewHelpers.h>
7
+
8
+ using namespace facebook::react;
9
+
10
+ @interface TinyuiTipView () <RCTTinyuiTipViewViewProtocol, TinyuiTipViewDelegate>
11
+ @end
12
+
13
+ @implementation TinyuiTipView {
14
+ TinyuiTipViewProvider *_provider;
15
+ TinyuiInlineTipShadowNode::ConcreteState::Shared _state;
16
+ }
17
+
18
+ + (ComponentDescriptorProvider)componentDescriptorProvider
19
+ {
20
+ return concreteComponentDescriptorProvider<TinyuiInlineTipComponentDescriptor>();
21
+ }
22
+
23
+ - (instancetype)initWithFrame:(CGRect)frame
24
+ {
25
+ if (self = [super initWithFrame:frame]) {
26
+ _props = TinyuiInlineTipShadowNode::defaultSharedProps();
27
+ _provider = [[TinyuiTipViewProvider alloc] initWithDelegate:self];
28
+ self.contentView = _provider;
29
+ }
30
+ return self;
31
+ }
32
+
33
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
34
+ {
35
+ const auto &next = *std::static_pointer_cast<const TinyuiTipViewProps>(props);
36
+ NSMutableArray *actions = [NSMutableArray new];
37
+ for (const auto &action : next.actions) {
38
+ [actions addObject:@{@"id": RCTNSStringFromString(action.id), @"title": RCTNSStringFromString(action.title)}];
39
+ }
40
+ [_provider configure:@{
41
+ @"tipId": RCTNSStringFromString(next.tipId),
42
+ @"title": RCTNSStringFromString(next.title),
43
+ @"message": RCTNSStringFromString(next.message),
44
+ @"systemImage": RCTNSStringFromString(next.systemImage),
45
+ @"actions": actions,
46
+ @"maxDisplayCount": @(next.maxDisplayCount),
47
+ @"ignoresDisplayFrequency": @(next.ignoresDisplayFrequency),
48
+ } enabled:next.enabled arrowEdge:RCTNSStringFromString(next.arrowEdge)];
49
+ [super updateProps:props oldProps:oldProps];
50
+ }
51
+
52
+ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState
53
+ {
54
+ _state = std::static_pointer_cast<const TinyuiInlineTipShadowNode::ConcreteState>(state);
55
+ [_provider setNeedsLayout];
56
+ }
57
+
58
+ - (void)onTipSizeChangeWithTipId:(NSString *)tipId width:(CGFloat)width height:(CGFloat)height
59
+ {
60
+ if (_state == nullptr) return;
61
+ const auto &previous = _state->getData();
62
+ std::string identifier(tipId.UTF8String ?: "");
63
+ facebook::react::Size size{static_cast<Float>(width), static_cast<Float>(height)};
64
+ if (previous.tipId == identifier && previous.contentSize == size) return;
65
+ _state->updateState(TinyuiInlineTipState{identifier, size});
66
+ }
67
+
68
+ - (void)onTipActionWithTipId:(NSString *)tipId id:(NSString *)identifier title:(NSString *)title
69
+ {
70
+ if (_eventEmitter == nullptr) return;
71
+ std::static_pointer_cast<const TinyuiTipViewEventEmitter>(_eventEmitter)->onTipAction({
72
+ .tipId = std::string(tipId.UTF8String ?: ""),
73
+ .id = std::string(identifier.UTF8String ?: ""), .title = std::string(title.UTF8String ?: ""),
74
+ });
75
+ }
76
+
77
+ - (void)onTipErrorWithTipId:(NSString *)tipId code:(NSString *)code message:(NSString *)message
78
+ {
79
+ if (_eventEmitter == nullptr) return;
80
+ std::static_pointer_cast<const TinyuiTipViewEventEmitter>(_eventEmitter)->onTipError({
81
+ .tipId = std::string(tipId.UTF8String ?: ""),
82
+ .code = std::string(code.UTF8String ?: ""), .message = std::string(message.UTF8String ?: ""),
83
+ });
84
+ }
85
+
86
+ - (void)prepareForRecycle
87
+ {
88
+ _state.reset();
89
+ [_provider reset];
90
+ [super prepareForRecycle];
91
+ }
92
+ @end
93
+ #endif
@@ -0,0 +1,140 @@
1
+ import SwiftUI
2
+ import TipKit
3
+ import UIKit
4
+
5
+ @objc public protocol TinyuiTipViewDelegate: AnyObject {
6
+ func onTipAction(tipId: String, id: String, title: String)
7
+ func onTipError(tipId: String, code: String, message: String)
8
+ // Fabric consumes this size directly; it is not a JavaScript event.
9
+ func onTipSizeChange(tipId: String, width: CGFloat, height: CGFloat)
10
+ }
11
+
12
+ @MainActor
13
+ @objc public final class TinyuiTipViewProvider: UIView {
14
+ private weak var delegate: TinyuiTipViewDelegate?
15
+ private var tip: TinyuiTip?
16
+ private var enabled = false
17
+ private var arrowEdge: Edge?
18
+ private var observation: Task<Void, Never>?
19
+ private var tipView: TipUIView?
20
+ private var lastError: String?
21
+
22
+ @objc public convenience init(delegate: TinyuiTipViewDelegate) {
23
+ self.init(frame: .zero)
24
+ self.delegate = delegate
25
+ clipsToBounds = true
26
+ registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { (view: TinyuiTipViewProvider, _: UITraitCollection) in
27
+ view.setNeedsLayout()
28
+ }
29
+ }
30
+
31
+ @objc public func configure(_ configuration: [String: Any], enabled: Bool, arrowEdge: String) {
32
+ let nextTip = TinyuiTip(configuration: configuration)
33
+ if tip?.id != nextTip.id || self.enabled != enabled {
34
+ stop()
35
+ lastError = nil
36
+ }
37
+ tip = nextTip
38
+ self.enabled = enabled
39
+ switch arrowEdge {
40
+ case "top": self.arrowEdge = .top
41
+ case "bottom": self.arrowEdge = .bottom
42
+ case "leading": self.arrowEdge = .leading
43
+ case "trailing": self.arrowEdge = .trailing
44
+ default: self.arrowEdge = nil
45
+ }
46
+ do {
47
+ try TinyuiTipsRuntime.shared.remember(nextTip)
48
+ } catch {
49
+ stop()
50
+ let error = error as NSError
51
+ reportError(error.userInfo["code"] as? String ?? "E_TIP", error.localizedDescription)
52
+ return
53
+ }
54
+ lastError = nil
55
+ start()
56
+ }
57
+
58
+ override public func didMoveToWindow() {
59
+ super.didMoveToWindow()
60
+ if window == nil { stop() } else { start() }
61
+ }
62
+
63
+ private func start() {
64
+ // A zero initial height is expected: the native view supplies its measured
65
+ // height to Fabric after the eligibility stream yields its first value.
66
+ guard observation == nil, enabled, let tip, window != nil else { return }
67
+ guard TinyuiTipsRuntime.shared.isConfigured else {
68
+ reportError("E_TIPS_NOT_CONFIGURED", "Call and await TipKit.configure() before displaying tips.")
69
+ return
70
+ }
71
+ observation = Task { @MainActor [weak self] in
72
+ for await shouldDisplay in tip.shouldDisplayUpdates {
73
+ guard !Task.isCancelled, let self else { break }
74
+ if shouldDisplay { self.showTip() } else { self.removeTip() }
75
+ }
76
+ }
77
+ }
78
+
79
+ private func showTip() {
80
+ guard tipView == nil, enabled, window != nil, let tip else { return }
81
+ let view = TipUIView(tip, arrowEdge: arrowEdge) { [weak self] action in
82
+ guard let self, self.tip?.id == tip.id,
83
+ let content = tip.actionContents.first(where: { $0.id == action.id }) else { return }
84
+ self.delegate?.onTipAction(tipId: tip.id, id: content.id, title: content.title)
85
+ }
86
+ tipView = view
87
+ addSubview(view)
88
+ setNeedsLayout()
89
+ }
90
+
91
+ private func removeTip() {
92
+ tipView?.removeFromSuperview()
93
+ tipView = nil
94
+ reportSize(height: 0)
95
+ }
96
+
97
+ private func stop() {
98
+ observation?.cancel()
99
+ observation = nil
100
+ removeTip()
101
+ }
102
+
103
+ override public func layoutSubviews() {
104
+ super.layoutSubviews()
105
+ guard let tipView else { reportSize(height: 0); return }
106
+ guard bounds.width > 0 else { return }
107
+ // TipUIView may cap its card width on iPad. Measure and lay it out at the
108
+ // same width so a wider proposal cannot underestimate wrapped text height.
109
+ let intrinsicSize = tipView.intrinsicContentSize
110
+ let width = intrinsicSize.width.isFinite && intrinsicSize.width > 0
111
+ ? min(bounds.width, intrinsicSize.width) : bounds.width
112
+ let fittingSize = tipView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
113
+ guard fittingSize.height.isFinite else { return }
114
+ let scale = traitCollection.displayScale > 0 ? traitCollection.displayScale : 1
115
+ let height = ceil(max(0, fittingSize.height) * scale) / scale
116
+ let x = effectiveUserInterfaceLayoutDirection == .rightToLeft ? bounds.width - width : 0
117
+ tipView.frame = CGRect(x: x, y: 0, width: width, height: height)
118
+ reportSize(height: height)
119
+ }
120
+
121
+ private func reportSize(height: CGFloat) {
122
+ delegate?.onTipSizeChange(tipId: tip?.id ?? "", width: bounds.width, height: height)
123
+ }
124
+
125
+ private func reportError(_ code: String, _ message: String) {
126
+ guard lastError != code else { return }
127
+ lastError = code
128
+ delegate?.onTipError(tipId: tip?.id ?? "", code: code, message: message)
129
+ }
130
+
131
+ @objc public func reset() {
132
+ stop()
133
+ tip = nil
134
+ enabled = false
135
+ arrowEdge = nil
136
+ lastError = nil
137
+ }
138
+
139
+ deinit { observation?.cancel() }
140
+ }
@@ -0,0 +1,48 @@
1
+ #pragma once
2
+
3
+ #include <react/renderer/components/TinyuiSpec/ShadowNodes.h>
4
+ #include <react/renderer/core/ConcreteComponentDescriptor.h>
5
+ #include <react/renderer/core/LayoutConstraints.h>
6
+ #include <cmath>
7
+ #include <string>
8
+
9
+ namespace facebook::react {
10
+
11
+ struct TinyuiInlineTipState final {
12
+ std::string tipId{};
13
+ Size contentSize{};
14
+ };
15
+
16
+ class TinyuiInlineTipShadowNode final
17
+ : public ConcreteViewShadowNode<
18
+ TinyuiTipViewComponentName,
19
+ TinyuiTipViewProps,
20
+ TinyuiTipViewEventEmitter,
21
+ TinyuiInlineTipState> {
22
+ public:
23
+ using ConcreteViewShadowNode::ConcreteViewShadowNode;
24
+
25
+ static ShadowNodeTraits BaseTraits()
26
+ {
27
+ auto traits = ConcreteViewShadowNode::BaseTraits();
28
+ traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
29
+ traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
30
+ return traits;
31
+ }
32
+
33
+ Size measureContent(const LayoutContext &, const LayoutConstraints &constraints) const override
34
+ {
35
+ const auto &props = getConcreteProps();
36
+ const auto &state = getStateData();
37
+ // Fill the available width, then let TipUIView measure wrapping on the main
38
+ // thread. Fabric state brings its height back without a JS layout roundtrip.
39
+ auto width = std::isfinite(constraints.maximumSize.width)
40
+ ? constraints.maximumSize.width : state.contentSize.width;
41
+ auto height = props.enabled && props.tipId == state.tipId ? state.contentSize.height : 0;
42
+ return constraints.clamp({width, height});
43
+ }
44
+ };
45
+
46
+ using TinyuiInlineTipComponentDescriptor = ConcreteComponentDescriptor<TinyuiInlineTipShadowNode>;
47
+
48
+ } // namespace facebook::react