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.
- package/README.md +39 -7
- package/ios/Core/Tinyui.mm +18 -0
- package/ios/LiquidGlassText/TinyuiLiquidGlassTextProvider.swift +0 -1
- package/ios/SFSymbol/TinyuiSFSymbolEffects.swift +184 -0
- package/ios/SFSymbol/TinyuiSFSymbolImage.swift +103 -0
- package/ios/SFSymbol/TinyuiSFSymbolProvider.swift +135 -0
- package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +4 -2
- package/ios/SFSymbol/TinyuiSFSymbolView.mm +14 -122
- package/ios/TipKit/TinyuiTipsRuntime.swift +14 -0
- package/lib/module/NativeTinyui.js.map +1 -1
- package/lib/module/SFSymbol/index.js +0 -4
- package/lib/module/SFSymbol/index.js.map +1 -1
- package/lib/module/SFSymbol/options.js +0 -2
- package/lib/module/SFSymbol/options.js.map +1 -1
- package/lib/module/TipKit/index.js +9 -0
- package/lib/module/TipKit/index.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/LiquidGlassText/types.d.ts +1 -1
- package/lib/typescript/src/LiquidGlassText/types.d.ts.map +1 -1
- package/lib/typescript/src/NativeTinyui.d.ts +1 -0
- package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
- package/lib/typescript/src/SFSymbol/index.d.ts +1 -1
- package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -1
- package/lib/typescript/src/SFSymbol/options.d.ts +0 -2
- package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -1
- package/lib/typescript/src/SFSymbol/types.d.ts +1 -5
- package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/index.d.ts +6 -0
- package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/LiquidGlassText/types.ts +1 -1
- package/src/NativeTinyui.ts +1 -0
- package/src/SFSymbol/index.tsx +0 -5
- package/src/SFSymbol/options.ts +0 -2
- package/src/SFSymbol/types.ts +1 -18
- package/src/TipKit/index.ts +10 -0
- package/src/index.tsx +0 -1
- package/ios/SFSymbol/TinyuiSFSymbolEffects.h +0 -9
- package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +0 -180
- package/ios/SFSymbol/TinyuiSFSymbolImage.h +0 -8
- 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
|
|
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
|
|
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`.
|
|
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
|
-
| `
|
|
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. |
|
package/ios/Core/Tinyui.mm
CHANGED
|
@@ -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
|
|
@@ -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 "
|
|
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
|
-
|
|
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({
|