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.
- package/README.md +177 -42
- package/ios/Core/Tinyui.mm +3 -0
- package/ios/Core/TinyuiComponentClasses.mm +18 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
- package/ios/TipKit/TinyuiTip.swift +16 -0
- package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
- package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
- package/ios/TipKit/TinyuiTipView.h +8 -0
- package/ios/TipKit/TinyuiTipView.mm +93 -0
- package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
- package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
- package/ios/TipKit/TinyuiTipsRuntime.swift +2 -74
- package/lib/module/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
- package/lib/module/SwipeActions/actions.js +21 -0
- package/lib/module/SwipeActions/actions.js.map +1 -0
- package/lib/module/SwipeActions/index.js +43 -0
- package/lib/module/SwipeActions/index.js.map +1 -0
- package/lib/module/SwipeActions/types.js +4 -0
- package/lib/module/SwipeActions/types.js.map +1 -0
- package/lib/module/TipKit/Popover.js +0 -22
- package/lib/module/TipKit/Popover.js.map +1 -1
- package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
- package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
- package/lib/module/TipKit/View.js +62 -0
- package/lib/module/TipKit/View.js.map +1 -0
- package/lib/module/TipKit/index.js +2 -0
- package/lib/module/TipKit/index.js.map +1 -1
- package/lib/module/TipKit/validation.js +4 -4
- package/lib/module/TipKit/validation.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/getNativeTinyui.js.map +1 -1
- package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts +15 -0
- package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/actions.d.ts +9 -0
- package/lib/typescript/src/SwipeActions/actions.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/index.d.ts +4 -0
- package/lib/typescript/src/SwipeActions/index.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/types.d.ts +37 -0
- package/lib/typescript/src/SwipeActions/types.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/View.d.ts +3 -0
- package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/index.d.ts +2 -1
- package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/types.d.ts +17 -15
- package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/validation.d.ts +2 -2
- package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
- package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
- package/package.json +13 -2
- package/react-native-tinyui.podspec +6 -1
- package/src/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
- package/src/SwipeActions/actions.ts +26 -0
- package/src/SwipeActions/index.tsx +54 -0
- package/src/SwipeActions/types.ts +37 -0
- package/src/TipKit/Popover.tsx +1 -21
- package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
- package/src/TipKit/TipViewNativeComponent.ts +28 -0
- package/src/TipKit/View.tsx +64 -0
- package/src/TipKit/index.ts +3 -1
- package/src/TipKit/types.ts +19 -20
- package/src/TipKit/validation.ts +8 -5
- package/src/index.tsx +8 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
197
|
-
|
|
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) { [
|
|
230
|
-
guard
|
|
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
|
-
|
|
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,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
|