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.
Files changed (44) hide show
  1. package/README.md +117 -41
  2. package/ios/Core/TinyuiComponentClasses.mm +7 -0
  3. package/ios/TipKit/TinyuiTip.swift +16 -0
  4. package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
  5. package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
  6. package/ios/TipKit/TinyuiTipView.h +8 -0
  7. package/ios/TipKit/TinyuiTipView.mm +93 -0
  8. package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
  9. package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
  10. package/ios/TipKit/TinyuiTipsRuntime.swift +2 -74
  11. package/lib/module/TipKit/Popover.js +0 -22
  12. package/lib/module/TipKit/Popover.js.map +1 -1
  13. package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
  14. package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
  15. package/lib/module/TipKit/View.js +62 -0
  16. package/lib/module/TipKit/View.js.map +1 -0
  17. package/lib/module/TipKit/index.js +2 -0
  18. package/lib/module/TipKit/index.js.map +1 -1
  19. package/lib/module/TipKit/validation.js +4 -4
  20. package/lib/module/TipKit/validation.js.map +1 -1
  21. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  22. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
  23. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  24. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
  25. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
  26. package/lib/typescript/src/TipKit/View.d.ts +3 -0
  27. package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
  28. package/lib/typescript/src/TipKit/index.d.ts +2 -1
  29. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  30. package/lib/typescript/src/TipKit/types.d.ts +17 -15
  31. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  32. package/lib/typescript/src/TipKit/validation.d.ts +2 -2
  33. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +1 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +4 -1
  37. package/src/TipKit/Popover.tsx +1 -21
  38. package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
  39. package/src/TipKit/TipViewNativeComponent.ts +28 -0
  40. package/src/TipKit/View.tsx +64 -0
  41. package/src/TipKit/index.ts +3 -1
  42. package/src/TipKit/types.ts +19 -20
  43. package/src/TipKit/validation.ts +8 -5
  44. 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={[{ id: 'learn-more', title: 'Learn more' }]}
282
- onActionPress={({ id }) => {
283
- if (id === 'learn-more') openHelp();
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` | Buttons with unique nonempty `{ id, title }` values | `[]` |
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` | Preferred bubble edge: `top`, `bottom`, `leading`, `trailing`, `auto` | `auto` |
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
- The component accepts standard `ViewProps` and a native view ref. `style` lays
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 to the next presentation; the currently visible bubble
319
- retains the content and action labels it was presented with.
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` and can recover after configuration succeeds.
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
- | Status | Meaning |
357
- | --- | --- |
358
- | `pending` | Currently ineligible, such as while waiting for the display frequency interval |
359
- | `available` | Eligible; presentation still requires `enabled`, a visible anchor and a free presenter |
360
- | `invalidated` | Persistently ineligible, with an invalidation `reason`; does not recover just by remounting or restarting |
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. TinyUI also waits for a visible anchor and a free presenter, and
379
- dismisses on detachment or when the anchor leaves the visible area. It presents
380
- at most one TinyUI tip at a time and waits while another modal occupies the
381
- presenter. An outside dismissal suppresses immediate reopening for that mount;
382
- toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
383
- The system close button can permanently invalidate a tip.
384
-
385
- This first version exposes popover tips and JS eligibility conditions. Inline
386
- `TipUIView`, native rule/event builders and TipGroup are
387
- not exposed. The example's **New demo tip** button deliberately creates another
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 { 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