react-native-tinyui 1.1.0 → 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 +43 -7
- package/ios/Core/Tinyui.mm +18 -0
- package/ios/LiquidGlassText/TinyuiLiquidGlassTextProvider.swift +0 -1
- package/ios/Menu/TinyuiMenuView.mm +15 -0
- 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
|
|
@@ -165,6 +165,10 @@ or `type: 'divider'` for structural entries; nested entries use their own
|
|
|
165
165
|
`options` array. SF Symbols are passed with `systemImage`; missing symbols
|
|
166
166
|
simply render no image.
|
|
167
167
|
|
|
168
|
+
The trigger can be nested inside a React Native `Pressable` or `Touchable`.
|
|
169
|
+
Tapping an enabled menu trigger opens the menu without firing the parent's
|
|
170
|
+
touch or press handlers; tapping elsewhere in the parent keeps its normal behavior.
|
|
171
|
+
|
|
168
172
|
To open the menu from another control, call `open()` on its ref:
|
|
169
173
|
|
|
170
174
|
```tsx
|
|
@@ -324,12 +328,46 @@ Coordinate this startup configuration with any other native TipKit integration.
|
|
|
324
328
|
Initialization errors reject the promise; an enabled view mounted too early
|
|
325
329
|
reports `E_TIPS_NOT_CONFIGURED` and can recover after configuration succeeds.
|
|
326
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
|
+
|
|
327
348
|
`TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
|
|
328
349
|
requires configuration first. It persistently invalidates the ID even when no
|
|
329
350
|
view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
|
|
330
351
|
System invalidations can additionally report `displayCountExceeded`,
|
|
331
352
|
`displayDurationExceeded`, or `unknown` through `onStatusChange`.
|
|
332
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
|
+
|
|
333
371
|
Keep IDs stable across renders, app launches and translations. All instances
|
|
334
372
|
with the same ID share their TipKit history. Keep `maxDisplayCount` and
|
|
335
373
|
`ignoresDisplayFrequency` consistent for each ID; conflicting values report
|
|
@@ -345,7 +383,7 @@ toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
|
|
|
345
383
|
The system close button can permanently invalidate a tip.
|
|
346
384
|
|
|
347
385
|
This first version exposes popover tips and JS eligibility conditions. Inline
|
|
348
|
-
`TipUIView`, native rule/event builders
|
|
386
|
+
`TipUIView`, native rule/event builders and TipGroup are
|
|
349
387
|
not exposed. The example's **New demo tip** button deliberately creates another
|
|
350
388
|
ID for trying the behavior again without clearing the app's TipKit datastore.
|
|
351
389
|
|
|
@@ -385,7 +423,7 @@ import { LiquidGlassText } from 'react-native-tinyui';
|
|
|
385
423
|
| Prop | Values | Default |
|
|
386
424
|
| ------------------------ | ------------------------------------------------ | --------- |
|
|
387
425
|
| `text` | String (including text translated in JavaScript) | Required |
|
|
388
|
-
| `effect` | `clear`, `regular
|
|
426
|
+
| `effect` | `clear`, `regular` | `clear` |
|
|
389
427
|
| `tint` | React Native `ColorValue` | — |
|
|
390
428
|
| `interactive` | Whether the glass responds to interaction | `false` |
|
|
391
429
|
| `fontDesign` | `default`, `serif`, `monospaced`, `rounded` | `default` |
|
|
@@ -399,7 +437,7 @@ precedence. `fontFamily` accepts the PostScript name of a font installed in the
|
|
|
399
437
|
app, and `fontDesign` only applies to system fonts.
|
|
400
438
|
|
|
401
439
|
`tint` accepts a React Native `ColorValue`, including `PlatformColor` and
|
|
402
|
-
`DynamicColorIOS`.
|
|
440
|
+
`DynamicColorIOS`.
|
|
403
441
|
The component also accepts standard `ViewProps`, including `style`, `testID`,
|
|
404
442
|
accessibility props and a native view ref. The supplied text is used for
|
|
405
443
|
the default accessibility label; pass `accessibilityLabel` to override it.
|
|
@@ -560,9 +598,7 @@ function Favorite() {
|
|
|
560
598
|
| `size` | Positive point size; default `17`. |
|
|
561
599
|
| `weight` | `unspecified` (default), `ultraLight`, `thin`, `light`, `regular`, `medium`, `semibold`, `bold`, `heavy`, `black`. |
|
|
562
600
|
| `scale` | `default`, `unspecified`, `small`, `medium`, `large`. |
|
|
563
|
-
| `
|
|
564
|
-
| `fontFamily` | Font/PostScript name used to derive symbol metrics; unknown fonts use system metrics. |
|
|
565
|
-
| `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`. |
|
|
566
602
|
| `maxFontSizeMultiplier` | Cap on that scale, >= 1. Omitted or `0` means unlimited. |
|
|
567
603
|
| `renderingMode` | `automatic` (default), `monochrome`, `hierarchical`, `palette`, `multicolor`. |
|
|
568
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
|
|
@@ -64,6 +64,21 @@ using namespace facebook::react;
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
#pragma mark - Touch handling
|
|
68
|
+
|
|
69
|
+
- (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
|
|
70
|
+
{
|
|
71
|
+
// The provider's hitTest returns its UIButton, so the surface touch handler
|
|
72
|
+
// finds this Fabric view first. Keep the trigger's touch sequence in UIKit:
|
|
73
|
+
// forwarding it to JS lets an ancestor Pressable/Touchable claim the same
|
|
74
|
+
// touch and run its press callbacks while the native menu is opening.
|
|
75
|
+
if (_menuProvider.isEnabled) {
|
|
76
|
+
return nullptr;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return [super touchEventEmitterAtPoint:point];
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
#pragma mark - Commands
|
|
68
83
|
|
|
69
84
|
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
|
|
@@ -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({
|