react-native-tinyui 2.0.0 → 3.0.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 +117 -41
- package/ios/Core/TinyuiComponentClasses.mm +7 -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/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/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 +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +4 -1
- 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 +1 -1
package/README.md
CHANGED
|
@@ -80,7 +80,23 @@ Available components:
|
|
|
80
80
|
- `Stepper`
|
|
81
81
|
- `ConcentricView`
|
|
82
82
|
- `SFSymbol`
|
|
83
|
-
- `TipKit` (enables `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
|
|
83
|
+
- `TipKit` (enables `TipKit.View`, `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
|
|
84
|
+
|
|
85
|
+
`TipKit` is one optional module containing both native views,
|
|
86
|
+
`TinyuiTipPopoverView` and `TinyuiTipView`. Select it once in your app's
|
|
87
|
+
`package.json` to include both:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"react-native-tinyui": {
|
|
92
|
+
"components": ["TipKit"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The JavaScript API is also `TipKit`, imported from `react-native-tinyui` or
|
|
98
|
+
`react-native-tinyui/tip-kit`. The library's Codegen configuration registers the
|
|
99
|
+
two native view classes individually; it is not the optional-module selection.
|
|
84
100
|
|
|
85
101
|
All components are enabled by default. Use an empty `components` array to
|
|
86
102
|
compile only the TurboModule core. Run `pod install` again whenever this list
|
|
@@ -256,12 +272,16 @@ outer container, not the presented content.
|
|
|
256
272
|
|
|
257
273
|
<img src="docs/screenshots/tip-kit.png" alt="TipKit popover with a save action anchored to the favorite button" width="320" />
|
|
258
274
|
|
|
275
|
+
`TipKit.View` embeds a native `TipUIView` directly in your layout.
|
|
259
276
|
`TipKit.Popover` anchors a native TipKit `TipUIPopoverViewController` to ordinary
|
|
260
277
|
React Native children on iOS 17+. The children retain their own touch handlers;
|
|
261
278
|
the tip appears automatically when both `enabled` and TipKit's eligibility allow
|
|
262
279
|
it. Its text, SF Symbol, action buttons, display history and invalidation use
|
|
263
280
|
Apple's TipKit framework. No additional package is required.
|
|
264
281
|
|
|
282
|
+
Both components support the same tip content, action callbacks, display options
|
|
283
|
+
and persistent history on iOS 17+.
|
|
284
|
+
|
|
265
285
|
Configure TipKit once, **before mounting tips** (for example, during app startup):
|
|
266
286
|
|
|
267
287
|
```tsx
|
|
@@ -278,12 +298,18 @@ await TipKit.configure({ displayFrequency: 'daily' });
|
|
|
278
298
|
systemImage="star"
|
|
279
299
|
enabled={isScreenFocused && !hasFavorites}
|
|
280
300
|
maxDisplayCount={3}
|
|
281
|
-
actions={[
|
|
282
|
-
|
|
283
|
-
|
|
301
|
+
actions={[
|
|
302
|
+
{ id: 'save', title: 'Save this item' },
|
|
303
|
+
{ id: 'learn-more', title: 'Learn more' },
|
|
304
|
+
]}
|
|
305
|
+
onActionPress={async ({ id }) => {
|
|
306
|
+
if (id === 'save') {
|
|
307
|
+
await saveFavorite();
|
|
308
|
+
await TipKit.invalidate('favorite-feature-v1', 'actionPerformed');
|
|
309
|
+
} else if (id === 'learn-more') {
|
|
310
|
+
openHelp();
|
|
311
|
+
}
|
|
284
312
|
}}
|
|
285
|
-
onStatusChange={(status) => console.log(status)}
|
|
286
|
-
onVisibleChange={(visible) => console.log({ visible })}
|
|
287
313
|
onError={(error) => console.warn(error.message)}
|
|
288
314
|
>
|
|
289
315
|
<Pressable onPress={async () => {
|
|
@@ -295,28 +321,73 @@ await TipKit.configure({ displayFrequency: 'daily' });
|
|
|
295
321
|
</TipKit.Popover>;
|
|
296
322
|
```
|
|
297
323
|
|
|
324
|
+
For an inline tip, use `TipKit.View` without anchor children (after the same
|
|
325
|
+
startup configuration):
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
<TipKit.View
|
|
329
|
+
tipId="favorite-inline-v1"
|
|
330
|
+
title="Keep your favorites close"
|
|
331
|
+
message="Save an item to find it quickly next time."
|
|
332
|
+
systemImage="star"
|
|
333
|
+
enabled={isScreenFocused && !hasFavorites}
|
|
334
|
+
actions={[
|
|
335
|
+
{ id: 'save', title: 'Save this item' },
|
|
336
|
+
{ id: 'learn-more', title: 'Learn more' },
|
|
337
|
+
]}
|
|
338
|
+
onActionPress={async ({ id }) => {
|
|
339
|
+
if (id === 'save') {
|
|
340
|
+
await saveFavorite();
|
|
341
|
+
await TipKit.invalidate('favorite-inline-v1', 'actionPerformed');
|
|
342
|
+
} else if (id === 'learn-more') {
|
|
343
|
+
openHelp();
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
/>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`TipKit.View` fills the available width and measures its native content height.
|
|
350
|
+
The card keeps TipKit's preferred width within that space and aligns to the
|
|
351
|
+
leading edge; the system also chooses the action buttons' layout.
|
|
352
|
+
Give it a bounded width (for example, a stretched child of a column or an
|
|
353
|
+
explicit `style.width`); no fixed height is needed. When disabled or ineligible,
|
|
354
|
+
its content height becomes zero. Explicit height/min-height, padding, margins
|
|
355
|
+
and a parent's `gap` still follow normal React Native layout rules. Native
|
|
356
|
+
measurement updates Fabric directly, including after width and Dynamic Type
|
|
357
|
+
changes; there is no JavaScript size or status callback. The inline arrow is
|
|
358
|
+
optional: `arrowEdge` accepts `top`, `bottom`, `leading`, `trailing` or `none`
|
|
359
|
+
(default). `children` is only supported by `TipKit.Popover`.
|
|
360
|
+
|
|
361
|
+
The remaining props are shared by both components:
|
|
362
|
+
|
|
298
363
|
| Prop | Description | Default |
|
|
299
364
|
| --- | --- | --- |
|
|
300
365
|
| `tipId` | Required stable identity for persistent history | — |
|
|
301
366
|
| `title` | Required nonempty title, already localized by the app | — |
|
|
302
367
|
| `message` | Supporting plain text | — |
|
|
303
368
|
| `systemImage` | SF Symbol name | — |
|
|
304
|
-
| `actions` |
|
|
369
|
+
| `actions` | Native buttons with unique nonempty `id` and nonempty `title`; TipKit chooses their layout | `[]` |
|
|
305
370
|
| `enabled` | Allows presentation; does not override TipKit eligibility | `true` |
|
|
306
371
|
| `maxDisplayCount` | Positive integer; automatically invalidates after this many displays | Unlimited |
|
|
307
372
|
| `ignoresDisplayFrequency` | Exempts this tip from the app-wide display interval | `false` |
|
|
308
|
-
| `arrowEdge` |
|
|
309
|
-
| `onStatusChange` | `{ status: 'pending' \| 'available' }` or `{ status: 'invalidated', reason }` | — |
|
|
310
|
-
| `onVisibleChange` | Whether the native bubble is actually visible | — |
|
|
373
|
+
| `arrowEdge` | `top`, `bottom`, `leading`, `trailing`; Popover also accepts `auto`, View accepts `none` | Popover: `auto`; View: `none` |
|
|
311
374
|
| `onActionPress` | Receives the selected `{ id, title }`; does not automatically invalidate | — |
|
|
312
375
|
| `onError` | Receives `{ code, message }`; otherwise a warning is logged | — |
|
|
313
376
|
|
|
314
|
-
|
|
315
|
-
out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
|
|
377
|
+
Both components accept standard `ViewProps` and a native view ref. For Popover,
|
|
378
|
+
`style` lays out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
|
|
316
379
|
the arrow to available space; leading/trailing follow layout direction. Bubble
|
|
317
380
|
content is native text/images/actions; `children` supplies only the anchor.
|
|
318
|
-
Content edits apply
|
|
319
|
-
|
|
381
|
+
Content and arrow edits on an inline tip apply the next time it is shown.
|
|
382
|
+
Content edits on a popover apply to the next presentation. A currently visible
|
|
383
|
+
tip retains the content and action labels it was shown with.
|
|
384
|
+
|
|
385
|
+
To show two action buttons, provide two entries in `actions`, as above. Each
|
|
386
|
+
`title` controls that button's label. `onActionPress` receives the clicked
|
|
387
|
+
button's `{ id, title }`; use `id` to handle the buttons separately. Titles may
|
|
388
|
+
be translated without changing action IDs. To temporarily hide a tip in response
|
|
389
|
+
to an action, set `enabled` to `false`; to permanently complete it, call
|
|
390
|
+
`TipKit.invalidate`.
|
|
320
391
|
|
|
321
392
|
### Configuration and lifetime
|
|
322
393
|
|
|
@@ -326,7 +397,9 @@ app-wide TipKit setting. Repeating the same configuration is safe, including
|
|
|
326
397
|
after Fast Refresh; changing it later rejects with `E_TIPS_ALREADY_CONFIGURED`.
|
|
327
398
|
Coordinate this startup configuration with any other native TipKit integration.
|
|
328
399
|
Initialization errors reject the promise; an enabled view mounted too early
|
|
329
|
-
reports `E_TIPS_NOT_CONFIGURED
|
|
400
|
+
reports `E_TIPS_NOT_CONFIGURED`. Await configuration before mounting tips. If a
|
|
401
|
+
tip was already mounted, remount it or toggle `enabled` after configuration to
|
|
402
|
+
start observing it.
|
|
330
403
|
|
|
331
404
|
`TipKit.resetDatastore()` returns a promise and clears all persisted tip, event
|
|
332
405
|
and parameter records, making previously invalidated tips eligible again when
|
|
@@ -348,25 +421,14 @@ launch will erase users' tip history on every launch.
|
|
|
348
421
|
`TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
|
|
349
422
|
requires configuration first. It persistently invalidates the ID even when no
|
|
350
423
|
view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
|
|
351
|
-
System invalidations can additionally report `displayCountExceeded`,
|
|
352
|
-
`displayDurationExceeded`, or `unknown` through `onStatusChange`.
|
|
353
|
-
|
|
354
|
-
`onStatusChange` reports eligibility, independently of actual bubble visibility:
|
|
355
424
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
Invalidation reasons are `actionPerformed` (explicitly call `invalidate` after
|
|
363
|
-
the feature is used), `tipClosed` (the system close button or explicit
|
|
364
|
-
invalidation), `displayCountExceeded` (the `maxDisplayCount` limit), and
|
|
365
|
-
`displayDurationExceeded` (a native duration limit; this wrapper does not expose
|
|
366
|
-
a duration option). `unknown` is a fallback for future native reasons.
|
|
425
|
+
Both native views observe TipKit's `shouldDisplayUpdates` to show or remove
|
|
426
|
+
the tip. Eligibility and visibility changes are not sent to JavaScript.
|
|
427
|
+
Call `invalidate` with `actionPerformed` after the feature is used, or with
|
|
428
|
+
`tipClosed` to permanently dismiss the tip. TipKit also handles the system
|
|
429
|
+
close button and display limits.
|
|
367
430
|
Action buttons only emit `onActionPress`; they do not automatically invalidate.
|
|
368
431
|
Setting `enabled={false}` or unmounting a tip does not itself invalidate it.
|
|
369
|
-
Use `onVisibleChange` to observe whether the bubble is actually on screen.
|
|
370
432
|
|
|
371
433
|
Keep IDs stable across renders, app launches and translations. All instances
|
|
372
434
|
with the same ID share their TipKit history. Keep `maxDisplayCount` and
|
|
@@ -375,16 +437,30 @@ with the same ID share their TipKit history. Keep `maxDisplayCount` and
|
|
|
375
437
|
tip. Hiding a tip, unmounting it or reconfiguring TinyUI never resets history.
|
|
376
438
|
|
|
377
439
|
Use `enabled={isScreenFocused && businessCondition}` with your navigation
|
|
378
|
-
library.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
440
|
+
library. Each mounted, enabled Popover anchor starts its own `shouldDisplayUpdates`
|
|
441
|
+
observation once it has a size and a parent view controller. Disabling,
|
|
442
|
+
detaching or recycling the component stops observation and dismisses its
|
|
443
|
+
bubble; changing `tipId` starts a new observation. This follows the independent
|
|
444
|
+
`start()` / `stop()` structure of
|
|
445
|
+
[Tipster's TipPresenter](https://github.com/chockenberry/Tipster/blob/081922aa9421d764c436cbe16ad9e2601cd52740/Tipster/TipKitHelper.swift).
|
|
446
|
+
|
|
447
|
+
Popover presentation is skipped when the host view controller or an ancestor already
|
|
448
|
+
presents another controller. There is no global anchor registry, presentation
|
|
449
|
+
queue, polling timer, app-state observer or scroll visibility tracking.
|
|
450
|
+
Closing another tip or modal does not automatically retry a skipped
|
|
451
|
+
presentation. A later eligibility update, remount or re-enabling the component
|
|
452
|
+
can trigger another attempt. An outside dismissal only cleans up the current
|
|
453
|
+
presentation; there is no additional per-mount suppression policy. The system
|
|
454
|
+
close button can permanently invalidate a tip.
|
|
455
|
+
|
|
456
|
+
Inline tips start observing when attached to a window and stop when disabled,
|
|
457
|
+
detached or recycled. They do not present a view controller or block interaction
|
|
458
|
+
with the rest of the screen. Both presentations use the same datastore; an
|
|
459
|
+
invalidation affects every instance of the same `tipId`.
|
|
460
|
+
|
|
461
|
+
Native rule/event builders and TipGroup are not exposed. The example's
|
|
462
|
+
**Inline view** switch toggles between both presentations. Its **New demo tip**
|
|
463
|
+
button deliberately creates another
|
|
388
464
|
ID for trying the behavior again without clearing the app's TipKit datastore.
|
|
389
465
|
|
|
390
466
|
## LiquidGlassText
|
|
@@ -55,8 +55,10 @@ TINYUI_DISABLED_COMPONENT(TinyuiSFSymbolView, TinyuiSFSymbolViewComponentDescrip
|
|
|
55
55
|
|
|
56
56
|
#if TINYUI_FEATURE_TIP_KIT
|
|
57
57
|
#import "TinyuiTipPopoverView.h"
|
|
58
|
+
#import "TinyuiTipView.h"
|
|
58
59
|
#else
|
|
59
60
|
TINYUI_DISABLED_COMPONENT(TinyuiTipPopoverView, TinyuiTipPopoverViewComponentDescriptor)
|
|
61
|
+
TINYUI_DISABLED_COMPONENT(TinyuiTipView, TinyuiTipViewComponentDescriptor)
|
|
60
62
|
#endif
|
|
61
63
|
|
|
62
64
|
#undef TINYUI_DISABLED_COMPONENT
|
|
@@ -95,3 +97,8 @@ Class<RCTComponentViewProtocol> TinyuiTipPopoverViewCls(void)
|
|
|
95
97
|
{
|
|
96
98
|
return TinyuiTipPopoverView.class;
|
|
97
99
|
}
|
|
100
|
+
|
|
101
|
+
Class<RCTComponentViewProtocol> TinyuiTipViewCls(void)
|
|
102
|
+
{
|
|
103
|
+
return TinyuiTipView.class;
|
|
104
|
+
}
|
|
@@ -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;
|