react-native-tinyui 1.1.1 → 2.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 (43) hide show
  1. package/README.md +39 -7
  2. package/ios/Core/Tinyui.mm +18 -0
  3. package/ios/LiquidGlassText/TinyuiLiquidGlassTextProvider.swift +0 -1
  4. package/ios/SFSymbol/TinyuiSFSymbolEffects.swift +184 -0
  5. package/ios/SFSymbol/TinyuiSFSymbolImage.swift +103 -0
  6. package/ios/SFSymbol/TinyuiSFSymbolProvider.swift +135 -0
  7. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +4 -2
  8. package/ios/SFSymbol/TinyuiSFSymbolView.mm +14 -122
  9. package/ios/TipKit/TinyuiTipsRuntime.swift +14 -0
  10. package/lib/module/NativeTinyui.js.map +1 -1
  11. package/lib/module/SFSymbol/index.js +0 -4
  12. package/lib/module/SFSymbol/index.js.map +1 -1
  13. package/lib/module/SFSymbol/options.js +0 -2
  14. package/lib/module/SFSymbol/options.js.map +1 -1
  15. package/lib/module/TipKit/index.js +9 -0
  16. package/lib/module/TipKit/index.js.map +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/typescript/src/LiquidGlassText/types.d.ts +1 -1
  19. package/lib/typescript/src/LiquidGlassText/types.d.ts.map +1 -1
  20. package/lib/typescript/src/NativeTinyui.d.ts +1 -0
  21. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
  22. package/lib/typescript/src/SFSymbol/index.d.ts +1 -1
  23. package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -1
  24. package/lib/typescript/src/SFSymbol/options.d.ts +0 -2
  25. package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -1
  26. package/lib/typescript/src/SFSymbol/types.d.ts +1 -5
  27. package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -1
  28. package/lib/typescript/src/TipKit/index.d.ts +6 -0
  29. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +1 -1
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/LiquidGlassText/types.ts +1 -1
  34. package/src/NativeTinyui.ts +1 -0
  35. package/src/SFSymbol/index.tsx +0 -5
  36. package/src/SFSymbol/options.ts +0 -2
  37. package/src/SFSymbol/types.ts +1 -18
  38. package/src/TipKit/index.ts +10 -0
  39. package/src/index.tsx +0 -1
  40. package/ios/SFSymbol/TinyuiSFSymbolEffects.h +0 -9
  41. package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +0 -180
  42. package/ios/SFSymbol/TinyuiSFSymbolImage.h +0 -8
  43. package/ios/SFSymbol/TinyuiSFSymbolImage.mm +0 -141
package/README.md CHANGED
@@ -80,7 +80,7 @@ Available components:
80
80
  - `Stepper`
81
81
  - `ConcentricView`
82
82
  - `SFSymbol`
83
- - `TipKit` (enables `TipKit.Popover`, `TipKit.configure` and `TipKit.invalidate`)
83
+ - `TipKit` (enables `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
84
84
 
85
85
  All components are enabled by default. Use an empty `components` array to
86
86
  compile only the TurboModule core. Run `pod install` again whenever this list
@@ -328,12 +328,46 @@ Coordinate this startup configuration with any other native TipKit integration.
328
328
  Initialization errors reject the promise; an enabled view mounted too early
329
329
  reports `E_TIPS_NOT_CONFIGURED` and can recover after configuration succeeds.
330
330
 
331
+ `TipKit.resetDatastore()` returns a promise and clears all persisted tip, event
332
+ and parameter records, making previously invalidated tips eligible again when
333
+ their display conditions allow. It is primarily for testing the first-launch
334
+ experience and **must be awaited before `TipKit.configure()`**:
335
+
336
+ ```ts
337
+ // At app startup, when deliberately resetting tip history for testing:
338
+ await TipKit.resetDatastore();
339
+ await TipKit.configure({ displayFrequency: 'immediate' });
340
+ ```
341
+
342
+ Once TinyUI has configured TipKit, resetting rejects with
343
+ `E_TIPS_ALREADY_CONFIGURED`; restart the app process to run this sequence again.
344
+ Fast Refresh does not reset native configuration. Other native reset failures
345
+ also reject the promise (fallback code `E_TIPS_RESET`). Calling reset on every
346
+ launch will erase users' tip history on every launch.
347
+
331
348
  `TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
332
349
  requires configuration first. It persistently invalidates the ID even when no
333
350
  view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
334
351
  System invalidations can additionally report `displayCountExceeded`,
335
352
  `displayDurationExceeded`, or `unknown` through `onStatusChange`.
336
353
 
354
+ `onStatusChange` reports eligibility, independently of actual bubble visibility:
355
+
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.
367
+ Action buttons only emit `onActionPress`; they do not automatically invalidate.
368
+ 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
+
337
371
  Keep IDs stable across renders, app launches and translations. All instances
338
372
  with the same ID share their TipKit history. Keep `maxDisplayCount` and
339
373
  `ignoresDisplayFrequency` consistent for each ID; conflicting values report
@@ -349,7 +383,7 @@ toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
349
383
  The system close button can permanently invalidate a tip.
350
384
 
351
385
  This first version exposes popover tips and JS eligibility conditions. Inline
352
- `TipUIView`, native rule/event builders, TipGroup and datastore reset APIs are
386
+ `TipUIView`, native rule/event builders and TipGroup are
353
387
  not exposed. The example's **New demo tip** button deliberately creates another
354
388
  ID for trying the behavior again without clearing the app's TipKit datastore.
355
389
 
@@ -389,7 +423,7 @@ import { LiquidGlassText } from 'react-native-tinyui';
389
423
  | Prop | Values | Default |
390
424
  | ------------------------ | ------------------------------------------------ | --------- |
391
425
  | `text` | String (including text translated in JavaScript) | Required |
392
- | `effect` | `clear`, `regular`, `identity` | `clear` |
426
+ | `effect` | `clear`, `regular` | `clear` |
393
427
  | `tint` | React Native `ColorValue` | — |
394
428
  | `interactive` | Whether the glass responds to interaction | `false` |
395
429
  | `fontDesign` | `default`, `serif`, `monospaced`, `rounded` | `default` |
@@ -403,7 +437,7 @@ precedence. `fontFamily` accepts the PostScript name of a font installed in the
403
437
  app, and `fontDesign` only applies to system fonts.
404
438
 
405
439
  `tint` accepts a React Native `ColorValue`, including `PlatformColor` and
406
- `DynamicColorIOS`. `identity` applies no glass effect.
440
+ `DynamicColorIOS`.
407
441
  The component also accepts standard `ViewProps`, including `style`, `testID`,
408
442
  accessibility props and a native view ref. The supplied text is used for
409
443
  the default accessibility label; pass `accessibilityLabel` to override it.
@@ -564,9 +598,7 @@ function Favorite() {
564
598
  | `size` | Positive point size; default `17`. |
565
599
  | `weight` | `unspecified` (default), `ultraLight`, `thin`, `light`, `regular`, `medium`, `semibold`, `bold`, `heavy`, `black`. |
566
600
  | `scale` | `default`, `unspecified`, `small`, `medium`, `large`. |
567
- | `textStyle` | `extraLargeTitle`, `extraLargeTitle2`, `largeTitle`, `title1`–`title3`, `headline`, `subheadline`, `body`, `callout`, `footnote`, `caption1`, `caption2`. Overrides `size` and supplies the style's weight unless explicitly set. |
568
- | `fontFamily` | Font/PostScript name used to derive symbol metrics; unknown fonts use system metrics. |
569
- | `allowFontScaling` | Default `true`. Scales the base point size by React Native's `fontScale`, including when using `textStyle`. |
601
+ | `allowFontScaling` | Default `true`. Scales the base point size by React Native's `fontScale`. |
570
602
  | `maxFontSizeMultiplier` | Cap on that scale, >= 1. Omitted or `0` means unlimited. |
571
603
  | `renderingMode` | `automatic` (default), `monochrome`, `hierarchical`, `palette`, `multicolor`. |
572
604
  | `color` | React Native `ColorValue`, including `PlatformColor` and `DynamicColorIOS`. Defaults to semantic label color. |
@@ -35,6 +35,24 @@
35
35
  return components;
36
36
  }
37
37
 
38
+ - (void)resetTipsDatastore:(RCTPromiseResolveBlock)resolve
39
+ reject:(RCTPromiseRejectBlock)reject
40
+ {
41
+ #if TINYUI_FEATURE_TIP_KIT
42
+ dispatch_async(dispatch_get_main_queue(), ^{
43
+ [TinyuiTipsRuntime resetDatastore:^(NSError *error) {
44
+ if (error != nil) {
45
+ reject(error.userInfo[@"code"] ?: @"E_TIPS_RESET", error.localizedDescription, error);
46
+ } else {
47
+ resolve(nil);
48
+ }
49
+ }];
50
+ });
51
+ #else
52
+ reject(@"E_TIP_NOT_INSTALLED", @"Enable TipKit in react-native-tinyui.components and run pod install.", nil);
53
+ #endif
54
+ }
55
+
38
56
  - (void)configureTips:(NSString *)displayFrequency
39
57
  resolve:(RCTPromiseResolveBlock)resolve
40
58
  reject:(RCTPromiseRejectBlock)reject
@@ -138,7 +138,6 @@ private struct LiquidGlassTextEffect: View {
138
138
  var result: Glass
139
139
  switch effect {
140
140
  case "regular": result = .regular
141
- case "identity": result = .identity
142
141
  default: result = .clear
143
142
  }
144
143
  if let tintColor {
@@ -0,0 +1,184 @@
1
+ import Symbols
2
+ import UIKit
3
+
4
+ // Preserve the native Swift effect protocols so UIKit checks supported effects
5
+ // at compile time, including the availability of newer presets.
6
+ enum TinyuiSFSymbolEffect {
7
+ case discrete(any SymbolEffect & DiscreteSymbolEffect)
8
+ case indefinite(any SymbolEffect & IndefiniteSymbolEffect)
9
+
10
+ static func make(_ configuration: [String: Any]) -> TinyuiSFSymbolEffect? {
11
+ let scope = configuration["scope"] as? String
12
+ let direction = configuration["direction"] as? String
13
+ switch configuration["type"] as? String {
14
+ case "pulse":
15
+ var effect: PulseSymbolEffect = .pulse
16
+ if scope == "byLayer" { effect = effect.byLayer }
17
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
18
+ return .discrete(effect)
19
+ case "bounce":
20
+ var effect: BounceSymbolEffect = .bounce
21
+ if direction == "up" { effect = effect.up }
22
+ if direction == "down" { effect = effect.down }
23
+ if scope == "byLayer" { effect = effect.byLayer }
24
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
25
+ return .discrete(effect)
26
+ case "scale":
27
+ var effect: ScaleSymbolEffect = .scale
28
+ if direction == "up" { effect = effect.up }
29
+ if direction == "down" { effect = effect.down }
30
+ if scope == "byLayer" { effect = effect.byLayer }
31
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
32
+ return .indefinite(effect)
33
+ case "appear":
34
+ var effect: AppearSymbolEffect = .appear
35
+ if direction == "up" { effect = effect.up }
36
+ if direction == "down" { effect = effect.down }
37
+ if scope == "byLayer" { effect = effect.byLayer }
38
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
39
+ return .indefinite(effect)
40
+ case "disappear":
41
+ var effect: DisappearSymbolEffect = .disappear
42
+ if direction == "up" { effect = effect.up }
43
+ if direction == "down" { effect = effect.down }
44
+ if scope == "byLayer" { effect = effect.byLayer }
45
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
46
+ return .indefinite(effect)
47
+ case "variableColor":
48
+ var effect: VariableColorSymbolEffect = .variableColor
49
+ if configuration["iteration"] as? String == "iterative" { effect = effect.iterative }
50
+ if configuration["iteration"] as? String == "cumulative" { effect = effect.cumulative }
51
+ if let reversing = configuration["reversing"] as? NSNumber {
52
+ effect = reversing.boolValue ? effect.reversing : effect.nonReversing
53
+ }
54
+ if configuration["inactiveLayers"] as? String == "hide" { effect = effect.hideInactiveLayers }
55
+ if configuration["inactiveLayers"] as? String == "dim" { effect = effect.dimInactiveLayers }
56
+ return .discrete(effect)
57
+ case "wiggle":
58
+ guard #available(iOS 18.0, *) else { return nil }
59
+ var effect: WiggleSymbolEffect = .wiggle
60
+ switch direction {
61
+ case "up": effect = effect.up
62
+ case "down": effect = effect.down
63
+ case "left": effect = effect.left
64
+ case "right": effect = effect.right
65
+ case "forward": effect = effect.forward
66
+ case "backward": effect = effect.backward
67
+ case "clockwise": effect = effect.clockwise
68
+ case "counterClockwise": effect = effect.counterClockwise
69
+ default: break
70
+ }
71
+ if let angle = (configuration["angle"] as? NSNumber)?.doubleValue, angle.isFinite {
72
+ effect = WiggleSymbolEffect.wiggle.custom(angle: angle)
73
+ }
74
+ if scope == "byLayer" { effect = effect.byLayer }
75
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
76
+ return .discrete(effect)
77
+ case "rotate":
78
+ guard #available(iOS 18.0, *) else { return nil }
79
+ var effect: RotateSymbolEffect = .rotate
80
+ if direction == "clockwise" { effect = effect.clockwise }
81
+ if direction == "counterClockwise" { effect = effect.counterClockwise }
82
+ if scope == "byLayer" { effect = effect.byLayer }
83
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
84
+ return .discrete(effect)
85
+ case "breathe":
86
+ guard #available(iOS 18.0, *) else { return nil }
87
+ var effect: BreatheSymbolEffect = .breathe
88
+ if configuration["style"] as? String == "plain" { effect = effect.plain }
89
+ if configuration["style"] as? String == "pulse" { effect = effect.pulse }
90
+ if scope == "byLayer" { effect = effect.byLayer }
91
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
92
+ return .discrete(effect)
93
+ case "drawOn":
94
+ guard #available(iOS 26.0, *) else { return nil }
95
+ var effect: DrawOnSymbolEffect = .drawOn
96
+ if scope == "byLayer" { effect = effect.byLayer }
97
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
98
+ if scope == "individually" { effect = effect.individually }
99
+ return .indefinite(effect)
100
+ case "drawOff":
101
+ guard #available(iOS 26.0, *) else { return nil }
102
+ var effect: DrawOffSymbolEffect = .drawOff
103
+ if scope == "byLayer" { effect = effect.byLayer }
104
+ if scope == "wholeSymbol" { effect = effect.wholeSymbol }
105
+ if scope == "individually" { effect = effect.individually }
106
+ if let reversed = configuration["reversed"] as? NSNumber {
107
+ effect = reversed.boolValue ? effect.reversed : effect.nonReversed
108
+ }
109
+ return .indefinite(effect)
110
+ default: return nil
111
+ }
112
+ }
113
+
114
+ @MainActor
115
+ func add(to imageView: UIImageView, options: SymbolEffectOptions, animated: Bool) {
116
+ switch self {
117
+ case .discrete(let effect): imageView.addSymbolEffect(effect, options: options, animated: animated)
118
+ case .indefinite(let effect): imageView.addSymbolEffect(effect, options: options, animated: animated)
119
+ }
120
+ }
121
+
122
+ @MainActor
123
+ func remove(from imageView: UIImageView, options: SymbolEffectOptions, animated: Bool) {
124
+ switch self {
125
+ case .discrete(let effect):
126
+ imageView.removeSymbolEffect(ofType: effect, options: options, animated: animated)
127
+ case .indefinite(let effect):
128
+ imageView.removeSymbolEffect(ofType: effect, options: options, animated: animated)
129
+ }
130
+ }
131
+ }
132
+
133
+ func tinyuiSFSymbolOptions(_ configuration: [String: Any]?) -> SymbolEffectOptions {
134
+ var options = SymbolEffectOptions.default
135
+ guard let configuration else { return options }
136
+ let speed = (configuration["speed"] as? NSNumber)?.doubleValue ?? 1
137
+ if speed.isFinite, speed > 0 { options = options.speed(speed) }
138
+ let repetition = configuration["repeat"] as? NSNumber
139
+ if repetition == NSNumber(value: false) { return options.nonRepeating }
140
+ // JS validates safe integers; cap the conversion at the native integer range.
141
+ let rawCount = repetition?.doubleValue ?? 1
142
+ let count = rawCount.isFinite ? Int(max(1, min(rawCount, Double(Int.max) / 2))) : 1
143
+ let behavior = configuration["repeatBehavior"] as? String
144
+ let delayValue = configuration["repeatDelay"] as? NSNumber
145
+ let rawDelay = delayValue?.doubleValue ?? 0
146
+ let delay = rawDelay.isFinite && rawDelay >= 0 ? rawDelay : 0
147
+ let forever = configuration["repeat"] as? String == "forever" || behavior != nil || delayValue != nil
148
+
149
+ if #available(iOS 18.0, *) {
150
+ if behavior == "continuous" {
151
+ options = options.repeat(.continuous)
152
+ } else if repetition != nil {
153
+ options = options.repeat(.periodic(count, delay: delayValue == nil ? nil : delay))
154
+ } else if forever {
155
+ options = options.repeat(.periodic(nil, delay: delayValue == nil ? nil : delay))
156
+ }
157
+ } else {
158
+ if repetition != nil { options = options.repeat(count) }
159
+ else if forever { options = options.repeating }
160
+ }
161
+ return options
162
+ }
163
+
164
+ func tinyuiSFSymbolTransition(
165
+ _ configuration: [String: Any]
166
+ ) -> (any SymbolEffect & ContentTransitionSymbolEffect)? {
167
+ let type = configuration["type"] as? String
168
+ if type == "automatic" { return AutomaticSymbolEffect.automatic }
169
+ guard type == "replace" || type == "magicReplace" else { return nil }
170
+
171
+ var transition: ReplaceSymbolEffect = .replace
172
+ switch configuration["direction"] as? String {
173
+ case "downUp": transition = transition.downUp
174
+ case "upUp": transition = transition.upUp
175
+ case "offUp": transition = transition.offUp
176
+ default: break
177
+ }
178
+ if configuration["scope"] as? String == "byLayer" { transition = transition.byLayer }
179
+ if configuration["scope"] as? String == "wholeSymbol" { transition = transition.wholeSymbol }
180
+ if #available(iOS 18.0, *), type == "magicReplace" {
181
+ return ReplaceSymbolEffect.replace.magic(fallback: transition)
182
+ }
183
+ return transition
184
+ }
@@ -0,0 +1,103 @@
1
+ import React
2
+ import UIKit
3
+
4
+ // Shared by Fabric measurement and the mounted view so their metrics match.
5
+ // Keep image creation independent of UIView and the main actor: Fabric measures
6
+ // on a background thread without dispatching synchronously to the main queue.
7
+ @objc(TinyuiSFSymbolImage)
8
+ public final class TinyuiSFSymbolImage: NSObject {
9
+ @objc(imageWithConfiguration:includeColors:)
10
+ public static func image(
11
+ configuration: [String: Any],
12
+ includeColors: Bool
13
+ ) -> UIImage? {
14
+ guard let name = configuration["name"] as? String, !name.isEmpty else { return nil }
15
+
16
+ var size = (configuration["size"] as? NSNumber)?.doubleValue ?? 17
17
+ if !size.isFinite || size <= 0 { size = 17 }
18
+ let weight = symbolWeight(configuration["weight"] as? String)
19
+ let scale = symbolScale(configuration["scale"] as? String)
20
+ if let fontScale = (configuration["fontScale"] as? NSNumber)?.doubleValue,
21
+ fontScale.isFinite, fontScale > 0
22
+ {
23
+ size *= fontScale
24
+ }
25
+
26
+ var symbol = UIImage.SymbolConfiguration(pointSize: CGFloat(size), weight: weight, scale: scale)
27
+
28
+ if includeColors {
29
+ let color = configuration["color"].flatMap(RCTConvert.uiColor) ?? .label
30
+ let colors: UIImage.SymbolConfiguration?
31
+ switch configuration["renderingMode"] as? String {
32
+ case "monochrome": colors = .preferringMonochrome()
33
+ case "multicolor": colors = .preferringMulticolor()
34
+ case "hierarchical": colors = UIImage.SymbolConfiguration(hierarchicalColor: color)
35
+ case "palette":
36
+ let values = configuration["paletteColors"] as? [Any] ?? []
37
+ let palette = Array(values.compactMap(RCTConvert.uiColor).prefix(3))
38
+ colors = UIImage.SymbolConfiguration(paletteColors: palette.isEmpty ? [color] : palette)
39
+ default: colors = nil
40
+ }
41
+ if let colors { symbol = symbol.applying(colors) }
42
+ }
43
+
44
+ if #available(iOS 26.0, *) {
45
+ let variableMode: UIImage.SymbolVariableValueMode
46
+ switch configuration["variableValueMode"] as? String {
47
+ case "color": variableMode = .color
48
+ case "draw": variableMode = .draw
49
+ default: variableMode = .automatic
50
+ }
51
+ symbol = symbol.applying(UIImage.SymbolConfiguration(variableValueMode: variableMode))
52
+
53
+ let colorMode: UIImage.SymbolColorRenderingMode
54
+ switch configuration["colorRenderingMode"] as? String {
55
+ case "flat": colorMode = .flat
56
+ case "gradient": colorMode = .gradient
57
+ default: colorMode = .automatic
58
+ }
59
+ symbol = symbol.applying(UIImage.SymbolConfiguration(colorRenderingMode: colorMode))
60
+ }
61
+
62
+ let asset = configuration["source"] as? String == "asset"
63
+ let image: UIImage?
64
+ if let variableValue = configuration["variableValue"] as? NSNumber {
65
+ let value = variableValue.doubleValue
66
+ let clamped = value.isFinite ? min(1, max(0, value)) : 0
67
+ image = asset
68
+ ? UIImage(named: name, in: .main, variableValue: clamped, configuration: symbol)
69
+ : UIImage(systemName: name, variableValue: clamped, configuration: symbol)
70
+ } else {
71
+ image = asset
72
+ ? UIImage(named: name, in: .main, with: symbol)
73
+ : UIImage(systemName: name, withConfiguration: symbol)
74
+ }
75
+ // Symbol content transitions are undefined for ordinary bitmap assets.
76
+ return image?.isSymbolImage == true ? image : nil
77
+ }
78
+
79
+ private static func symbolWeight(_ value: String?) -> UIImage.SymbolWeight {
80
+ switch value {
81
+ case "ultraLight": return .ultraLight
82
+ case "thin": return .thin
83
+ case "light": return .light
84
+ case "regular": return .regular
85
+ case "medium": return .medium
86
+ case "semibold": return .semibold
87
+ case "bold": return .bold
88
+ case "heavy": return .heavy
89
+ case "black": return .black
90
+ default: return .unspecified
91
+ }
92
+ }
93
+
94
+ private static func symbolScale(_ value: String?) -> UIImage.SymbolScale {
95
+ switch value {
96
+ case "small": return .small
97
+ case "medium": return .medium
98
+ case "large": return .large
99
+ case "unspecified": return .unspecified
100
+ default: return .default
101
+ }
102
+ }
103
+ }
@@ -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({