react-native-tinyui 1.1.1 → 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 (76) hide show
  1. package/README.md +140 -32
  2. package/ios/Core/Tinyui.mm +18 -0
  3. package/ios/Core/TinyuiComponentClasses.mm +7 -0
  4. package/ios/LiquidGlassText/TinyuiLiquidGlassTextProvider.swift +0 -1
  5. package/ios/SFSymbol/TinyuiSFSymbolEffects.swift +184 -0
  6. package/ios/SFSymbol/TinyuiSFSymbolImage.swift +103 -0
  7. package/ios/SFSymbol/TinyuiSFSymbolProvider.swift +135 -0
  8. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +4 -2
  9. package/ios/SFSymbol/TinyuiSFSymbolView.mm +14 -122
  10. package/ios/TipKit/TinyuiTip.swift +16 -0
  11. package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
  12. package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
  13. package/ios/TipKit/TinyuiTipView.h +8 -0
  14. package/ios/TipKit/TinyuiTipView.mm +93 -0
  15. package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
  16. package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
  17. package/ios/TipKit/TinyuiTipsRuntime.swift +16 -74
  18. package/lib/module/NativeTinyui.js.map +1 -1
  19. package/lib/module/SFSymbol/index.js +0 -4
  20. package/lib/module/SFSymbol/index.js.map +1 -1
  21. package/lib/module/SFSymbol/options.js +0 -2
  22. package/lib/module/SFSymbol/options.js.map +1 -1
  23. package/lib/module/TipKit/Popover.js +0 -22
  24. package/lib/module/TipKit/Popover.js.map +1 -1
  25. package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
  26. package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
  27. package/lib/module/TipKit/View.js +62 -0
  28. package/lib/module/TipKit/View.js.map +1 -0
  29. package/lib/module/TipKit/index.js +11 -0
  30. package/lib/module/TipKit/index.js.map +1 -1
  31. package/lib/module/TipKit/validation.js +4 -4
  32. package/lib/module/TipKit/validation.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/typescript/src/LiquidGlassText/types.d.ts +1 -1
  35. package/lib/typescript/src/LiquidGlassText/types.d.ts.map +1 -1
  36. package/lib/typescript/src/NativeTinyui.d.ts +1 -0
  37. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
  38. package/lib/typescript/src/SFSymbol/index.d.ts +1 -1
  39. package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -1
  40. package/lib/typescript/src/SFSymbol/options.d.ts +0 -2
  41. package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -1
  42. package/lib/typescript/src/SFSymbol/types.d.ts +1 -5
  43. package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -1
  44. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  45. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
  46. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  47. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
  48. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
  49. package/lib/typescript/src/TipKit/View.d.ts +3 -0
  50. package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
  51. package/lib/typescript/src/TipKit/index.d.ts +8 -1
  52. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  53. package/lib/typescript/src/TipKit/types.d.ts +17 -15
  54. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  55. package/lib/typescript/src/TipKit/validation.d.ts +2 -2
  56. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
  57. package/lib/typescript/src/index.d.ts +2 -2
  58. package/lib/typescript/src/index.d.ts.map +1 -1
  59. package/package.json +4 -1
  60. package/src/LiquidGlassText/types.ts +1 -1
  61. package/src/NativeTinyui.ts +1 -0
  62. package/src/SFSymbol/index.tsx +0 -5
  63. package/src/SFSymbol/options.ts +0 -2
  64. package/src/SFSymbol/types.ts +1 -18
  65. package/src/TipKit/Popover.tsx +1 -21
  66. package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
  67. package/src/TipKit/TipViewNativeComponent.ts +28 -0
  68. package/src/TipKit/View.tsx +64 -0
  69. package/src/TipKit/index.ts +13 -1
  70. package/src/TipKit/types.ts +19 -20
  71. package/src/TipKit/validation.ts +8 -5
  72. package/src/index.tsx +1 -2
  73. package/ios/SFSymbol/TinyuiSFSymbolEffects.h +0 -9
  74. package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +0 -180
  75. package/ios/SFSymbol/TinyuiSFSymbolImage.h +0 -8
  76. package/ios/SFSymbol/TinyuiSFSymbolImage.mm +0 -141
@@ -0,0 +1,135 @@
1
+ import React
2
+ import Symbols
3
+ import UIKit
4
+
5
+ @objc(TinyuiSFSymbolProvider)
6
+ public final class TinyuiSFSymbolProvider: UIImageView {
7
+ private var configuration: NSDictionary?
8
+ private var effects: [[String: Any]] = []
9
+ private var appliedEffects: [String: [String: Any]] = [:]
10
+ private var respectReduceMotion = true
11
+
12
+ public override init(frame: CGRect) {
13
+ super.init(frame: frame)
14
+ contentMode = .center
15
+ isAccessibilityElement = false
16
+ NotificationCenter.default.addObserver(
17
+ self,
18
+ selector: #selector(reduceMotionChanged),
19
+ name: UIAccessibility.reduceMotionStatusDidChangeNotification,
20
+ object: nil
21
+ )
22
+ }
23
+
24
+ @available(*, unavailable)
25
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
26
+
27
+ private var reduceMotion: Bool {
28
+ respectReduceMotion && UIAccessibility.isReduceMotionEnabled
29
+ }
30
+
31
+ @objc(configure:effects:contentTransition:respectReduceMotion:resizeMode:)
32
+ public func configure(
33
+ _ value: Any?,
34
+ effects: Any?,
35
+ contentTransition: Any?,
36
+ respectReduceMotion: Bool,
37
+ resizeMode: String
38
+ ) {
39
+ let changedMotion = self.respectReduceMotion != respectReduceMotion
40
+ self.respectReduceMotion = respectReduceMotion
41
+ if changedMotion { clearEffects() }
42
+
43
+ let next = value as? [String: Any] ?? [:]
44
+ if configuration?.isEqual(to: next) != true {
45
+ let nextImage = TinyuiSFSymbolImage.image(configuration: next, includeColors: true)
46
+ tintColor = next["color"].flatMap(RCTConvert.uiColor) ?? .label
47
+ let transitionConfiguration = contentTransition as? [String: Any] ?? [:]
48
+ if let nextImage, image != nil, window != nil, !reduceMotion,
49
+ let transition = tinyuiSFSymbolTransition(transitionConfiguration)
50
+ {
51
+ setSymbolImage(
52
+ nextImage,
53
+ contentTransition: transition,
54
+ options: tinyuiSFSymbolOptions(transitionConfiguration)
55
+ )
56
+ } else {
57
+ image = nextImage
58
+ }
59
+ configuration = next as NSDictionary
60
+ if nextImage == nil { clearEffects() }
61
+ }
62
+
63
+ switch resizeMode {
64
+ case "contain": contentMode = .scaleAspectFit
65
+ case "cover": contentMode = .scaleAspectFill
66
+ case "stretch": contentMode = .scaleToFill
67
+ default: contentMode = .center
68
+ }
69
+ self.effects = effects as? [[String: Any]] ?? []
70
+ }
71
+
72
+ @objc public func clearEffects() {
73
+ removeAllSymbolEffects(options: .default, animated: false)
74
+ appliedEffects.removeAll()
75
+ }
76
+
77
+ @objc public func applyEffects() {
78
+ guard window != nil, image != nil else { return }
79
+ var requested: [String: [String: Any]] = [:]
80
+ for configuration in effects {
81
+ guard let type = configuration["type"] as? String else { continue }
82
+ requested[type] = configuration
83
+ }
84
+ for (type, old) in appliedEffects {
85
+ let next = requested[type]
86
+ if next.map({ NSDictionary(dictionary: old).isEqual(to: $0) }) != true
87
+ || (next?["active"] as? NSNumber)?.boolValue == false
88
+ {
89
+ // Reset immediately when replaying/reconfiguring so removal cannot race
90
+ // the replacement effect. Only animate an actual deactivation.
91
+ let reapplying = next != nil && (next?["active"] as? NSNumber)?.boolValue != false
92
+ let animated = !reapplying && !reduceMotion
93
+ && ((next ?? old)["animated"] as? NSNumber)?.boolValue != false
94
+ TinyuiSFSymbolEffect.make(old)?.remove(
95
+ from: self,
96
+ options: tinyuiSFSymbolOptions((next ?? old)["options"] as? [String: Any]),
97
+ animated: animated
98
+ )
99
+ appliedEffects.removeValue(forKey: type)
100
+ }
101
+ }
102
+ for (type, configuration) in requested {
103
+ if (configuration["active"] as? NSNumber)?.boolValue == false
104
+ || appliedEffects[type].map({ NSDictionary(dictionary: $0).isEqual(to: configuration) }) == true
105
+ {
106
+ continue
107
+ }
108
+ let stateful = ["scale", "appear", "disappear", "drawOn", "drawOff"].contains(type)
109
+ // Keep visibility/scale state under Reduce Motion without animating it.
110
+ if reduceMotion && !stateful { continue }
111
+ guard let effect = TinyuiSFSymbolEffect.make(configuration) else { continue }
112
+ effect.add(
113
+ to: self,
114
+ options: tinyuiSFSymbolOptions(configuration["options"] as? [String: Any]),
115
+ animated: !reduceMotion && (configuration["animated"] as? NSNumber)?.boolValue != false
116
+ )
117
+ appliedEffects[type] = configuration
118
+ }
119
+ }
120
+
121
+ public override func didMoveToWindow() {
122
+ super.didMoveToWindow()
123
+ if window == nil { clearEffects() }
124
+ else { applyEffects() }
125
+ }
126
+
127
+ @objc private func reduceMotionChanged() {
128
+ clearEffects()
129
+ applyEffects()
130
+ }
131
+
132
+ deinit {
133
+ NotificationCenter.default.removeObserver(self)
134
+ }
135
+ }
@@ -1,7 +1,7 @@
1
1
  #if TINYUI_FEATURE_SF_SYMBOL
2
2
 
3
3
  #import "TinyuiSFSymbolShadowNode.h"
4
- #import "TinyuiSFSymbolImage.h"
4
+ #import "Tinyui-Swift.h"
5
5
  #import <react/utils/FollyConvert.h>
6
6
  #include <react/renderer/core/LayoutConstraints.h>
7
7
  #include <react/renderer/core/LayoutContext.h>
@@ -16,7 +16,9 @@ Size TinyuiSFSymbolShadowNode::measureContent(
16
16
  @autoreleasepool {
17
17
  // UIImage and UIFont creation are safe off the main thread. No UIView or
18
18
  // main-queue sync is needed during concurrent Fabric layout.
19
- UIImage *image = TinyuiSFSymbolImage(convertFollyDynamicToId(getConcreteProps().configuration), NO);
19
+ id configuration = convertFollyDynamicToId(getConcreteProps().configuration);
20
+ if (![configuration isKindOfClass:NSDictionary.class]) configuration = @{};
21
+ UIImage *image = [TinyuiSFSymbolImage imageWithConfiguration:configuration includeColors:NO];
20
22
  CGSize size = image == nil ? CGSizeZero : image.size;
21
23
  auto scale = layoutContext.pointScaleFactor > 0 ? layoutContext.pointScaleFactor : 1;
22
24
  return layoutConstraints.clamp({
@@ -1,10 +1,8 @@
1
1
  #if TINYUI_FEATURE_SF_SYMBOL
2
2
 
3
3
  #import "TinyuiSFSymbolView.h"
4
- #import "TinyuiSFSymbolEffects.h"
5
- #import "TinyuiSFSymbolImage.h"
6
4
  #import "TinyuiSFSymbolShadowNode.h"
7
- #import <React/RCTConvert.h>
5
+ #import "Tinyui-Swift.h"
8
6
  #import <react/utils/FollyConvert.h>
9
7
  #import <react/renderer/components/TinyuiSpec/Props.h>
10
8
  #import <react/renderer/components/TinyuiSpec/RCTComponentViewHelpers.h>
@@ -15,11 +13,7 @@ using namespace facebook::react;
15
13
  @end
16
14
 
17
15
  @implementation TinyuiSFSymbolView {
18
- UIImageView *_imageView;
19
- NSDictionary *_configuration;
20
- NSArray<NSDictionary *> *_effects;
21
- NSMutableDictionary<NSString *, NSDictionary *> *_appliedEffects;
22
- BOOL _respectReduceMotion;
16
+ TinyuiSFSymbolProvider *_provider;
23
17
  }
24
18
 
25
19
  + (ComponentDescriptorProvider)componentDescriptorProvider
@@ -31,141 +25,39 @@ using namespace facebook::react;
31
25
  {
32
26
  if (self = [super initWithFrame:frame]) {
33
27
  _props = std::make_shared<const TinyuiSFSymbolViewProps>();
34
- [self createImageView];
35
- [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(reduceMotionChanged:)
36
- name:UIAccessibilityReduceMotionStatusDidChangeNotification object:nil];
28
+ [self createProvider];
37
29
  }
38
30
  return self;
39
31
  }
40
32
 
41
- - (void)createImageView
33
+ - (void)createProvider
42
34
  {
43
- _imageView = [UIImageView new];
44
- _imageView.contentMode = UIViewContentModeCenter;
45
- _imageView.isAccessibilityElement = NO;
46
- self.contentView = _imageView;
47
- _configuration = nil;
48
- _effects = @[];
49
- _appliedEffects = [NSMutableDictionary dictionary];
50
- _respectReduceMotion = YES;
51
- }
52
-
53
- - (BOOL)reduceMotion
54
- {
55
- return _respectReduceMotion && UIAccessibilityIsReduceMotionEnabled();
35
+ _provider = [[TinyuiSFSymbolProvider alloc] initWithFrame:CGRectZero];
36
+ self.contentView = _provider;
56
37
  }
57
38
 
58
39
  - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
59
40
  {
60
41
  const auto &next = *std::static_pointer_cast<const TinyuiSFSymbolViewProps>(props);
61
- BOOL changedMotion = _respectReduceMotion != next.respectReduceMotion;
62
- _respectReduceMotion = next.respectReduceMotion;
63
- if (changedMotion) [self clearEffects];
64
-
65
- NSDictionary *configuration = convertFollyDynamicToId(next.configuration);
66
- if (![configuration isKindOfClass:NSDictionary.class]) configuration = @{};
67
- if (![_configuration isEqual:configuration]) {
68
- UIImage *image = TinyuiSFSymbolImage(configuration, YES);
69
- _imageView.tintColor = [RCTConvert UIColor:configuration[@"color"]] ?: UIColor.labelColor;
70
- NSDictionary *transitionConfig = convertFollyDynamicToId(next.contentTransition);
71
- if (![transitionConfig isKindOfClass:NSDictionary.class]) transitionConfig = @{};
72
- NSSymbolContentTransition *transition = TinyuiSFSymbolTransition(transitionConfig);
73
- if (image != nil && _imageView.image != nil && self.window != nil && transition != nil && !self.reduceMotion) {
74
- [_imageView setSymbolImage:image withContentTransition:transition
75
- options:TinyuiSFSymbolOptions(transitionConfig)];
76
- } else {
77
- _imageView.image = image;
78
- }
79
- _configuration = configuration;
80
- if (image == nil) [self clearEffects];
81
- }
82
-
83
- if (next.resizeMode == "contain") _imageView.contentMode = UIViewContentModeScaleAspectFit;
84
- else if (next.resizeMode == "cover") _imageView.contentMode = UIViewContentModeScaleAspectFill;
85
- else if (next.resizeMode == "stretch") _imageView.contentMode = UIViewContentModeScaleToFill;
86
- else _imageView.contentMode = UIViewContentModeCenter;
87
-
88
- id effects = convertFollyDynamicToId(next.effects);
89
- _effects = [effects isKindOfClass:NSArray.class] ? effects : @[];
42
+ [_provider configure:convertFollyDynamicToId(next.configuration)
43
+ effects:convertFollyDynamicToId(next.effects)
44
+ contentTransition:convertFollyDynamicToId(next.contentTransition)
45
+ respectReduceMotion:next.respectReduceMotion
46
+ resizeMode:[NSString stringWithUTF8String:next.resizeMode.c_str()]];
90
47
  [super updateProps:props oldProps:oldProps];
91
48
  }
92
49
 
93
50
  - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
94
51
  {
95
52
  [super finalizeUpdates:updateMask];
96
- [self applyEffects];
97
- }
98
-
99
- - (void)clearEffects
100
- {
101
- [_imageView removeAllSymbolEffectsWithOptions:NSSymbolEffectOptions.options animated:NO];
102
- [_appliedEffects removeAllObjects];
103
- }
104
-
105
- - (void)applyEffects
106
- {
107
- if (self.window == nil || _imageView.image == nil) return;
108
- NSMutableDictionary<NSString *, NSDictionary *> *requested = [NSMutableDictionary dictionary];
109
- for (NSDictionary *configuration in _effects) {
110
- if (![configuration isKindOfClass:NSDictionary.class]) continue;
111
- NSString *type = configuration[@"type"];
112
- if (![type isKindOfClass:NSString.class]) continue;
113
- requested[type] = configuration;
114
- }
115
- for (NSString *type in _appliedEffects.allKeys) {
116
- NSDictionary *old = _appliedEffects[type];
117
- NSDictionary *next = requested[type];
118
- if (![old isEqual:next] || [next[@"active"] isEqual:@NO]) {
119
- NSSymbolEffect *effect = TinyuiSFSymbolEffect(old);
120
- // Reset immediately when replaying/reconfiguring, so an animated removal
121
- // cannot race the replacement effect. Animate only actual deactivation.
122
- BOOL reapplying = next != nil && ![next[@"active"] isEqual:@NO];
123
- BOOL animated = !reapplying && !self.reduceMotion && ![(next ?: old)[@"animated"] isEqual:@NO];
124
- if (effect != nil) {
125
- [_imageView removeSymbolEffectOfType:effect
126
- options:TinyuiSFSymbolOptions((next ?: old)[@"options"]) animated:animated];
127
- }
128
- [_appliedEffects removeObjectForKey:type];
129
- }
130
- }
131
- for (NSString *type in requested) {
132
- NSDictionary *configuration = requested[type];
133
- if ([configuration[@"active"] isEqual:@NO] || [_appliedEffects[type] isEqual:configuration]) continue;
134
- BOOL stateful = [@[@"scale", @"appear", @"disappear", @"drawOn", @"drawOff"] containsObject:type];
135
- // Keep visibility/scale state under Reduce Motion, without continuous or
136
- // discrete animation. Clearing those effects must still restore the state.
137
- if (self.reduceMotion && !stateful) continue;
138
- NSSymbolEffect *effect = TinyuiSFSymbolEffect(configuration);
139
- if (effect == nil) continue;
140
- [_imageView addSymbolEffect:effect options:TinyuiSFSymbolOptions(configuration[@"options"])
141
- animated:!self.reduceMotion && ![configuration[@"animated"] isEqual:@NO]];
142
- _appliedEffects[type] = configuration;
143
- }
144
- }
145
-
146
- - (void)didMoveToWindow
147
- {
148
- [super didMoveToWindow];
149
- if (self.window == nil) [self clearEffects];
150
- else [self applyEffects];
151
- }
152
-
153
- - (void)reduceMotionChanged:(NSNotification *)notification
154
- {
155
- [self clearEffects];
156
- [self applyEffects];
53
+ [_provider applyEffects];
157
54
  }
158
55
 
159
56
  - (void)prepareForRecycle
160
57
  {
161
- [self clearEffects];
58
+ [_provider clearEffects];
162
59
  [super prepareForRecycle];
163
- [self createImageView];
164
- }
165
-
166
- - (void)dealloc
167
- {
168
- [NSNotificationCenter.defaultCenter removeObserver:self];
60
+ [self createProvider];
169
61
  }
170
62
 
171
63
  @end
@@ -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() }