react-native-tinyui 1.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +140 -32
  2. package/ios/Core/Tinyui.mm +18 -0
  3. package/ios/Core/TinyuiComponentClasses.mm +7 -0
  4. package/ios/LiquidGlassText/TinyuiLiquidGlassTextProvider.swift +0 -1
  5. package/ios/SFSymbol/TinyuiSFSymbolEffects.swift +184 -0
  6. package/ios/SFSymbol/TinyuiSFSymbolImage.swift +103 -0
  7. package/ios/SFSymbol/TinyuiSFSymbolProvider.swift +135 -0
  8. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +4 -2
  9. package/ios/SFSymbol/TinyuiSFSymbolView.mm +14 -122
  10. package/ios/TipKit/TinyuiTip.swift +16 -0
  11. package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
  12. package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
  13. package/ios/TipKit/TinyuiTipView.h +8 -0
  14. package/ios/TipKit/TinyuiTipView.mm +93 -0
  15. package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
  16. package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
  17. package/ios/TipKit/TinyuiTipsRuntime.swift +16 -74
  18. package/lib/module/NativeTinyui.js.map +1 -1
  19. package/lib/module/SFSymbol/index.js +0 -4
  20. package/lib/module/SFSymbol/index.js.map +1 -1
  21. package/lib/module/SFSymbol/options.js +0 -2
  22. package/lib/module/SFSymbol/options.js.map +1 -1
  23. package/lib/module/TipKit/Popover.js +0 -22
  24. package/lib/module/TipKit/Popover.js.map +1 -1
  25. package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
  26. package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
  27. package/lib/module/TipKit/View.js +62 -0
  28. package/lib/module/TipKit/View.js.map +1 -0
  29. package/lib/module/TipKit/index.js +11 -0
  30. package/lib/module/TipKit/index.js.map +1 -1
  31. package/lib/module/TipKit/validation.js +4 -4
  32. package/lib/module/TipKit/validation.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/typescript/src/LiquidGlassText/types.d.ts +1 -1
  35. package/lib/typescript/src/LiquidGlassText/types.d.ts.map +1 -1
  36. package/lib/typescript/src/NativeTinyui.d.ts +1 -0
  37. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
  38. package/lib/typescript/src/SFSymbol/index.d.ts +1 -1
  39. package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -1
  40. package/lib/typescript/src/SFSymbol/options.d.ts +0 -2
  41. package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -1
  42. package/lib/typescript/src/SFSymbol/types.d.ts +1 -5
  43. package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -1
  44. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  45. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
  46. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  47. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
  48. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
  49. package/lib/typescript/src/TipKit/View.d.ts +3 -0
  50. package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
  51. package/lib/typescript/src/TipKit/index.d.ts +8 -1
  52. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  53. package/lib/typescript/src/TipKit/types.d.ts +17 -15
  54. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  55. package/lib/typescript/src/TipKit/validation.d.ts +2 -2
  56. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
  57. package/lib/typescript/src/index.d.ts +2 -2
  58. package/lib/typescript/src/index.d.ts.map +1 -1
  59. package/package.json +4 -1
  60. package/src/LiquidGlassText/types.ts +1 -1
  61. package/src/NativeTinyui.ts +1 -0
  62. package/src/SFSymbol/index.tsx +0 -5
  63. package/src/SFSymbol/options.ts +0 -2
  64. package/src/SFSymbol/types.ts +1 -18
  65. package/src/TipKit/Popover.tsx +1 -21
  66. package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
  67. package/src/TipKit/TipViewNativeComponent.ts +28 -0
  68. package/src/TipKit/View.tsx +64 -0
  69. package/src/TipKit/index.ts +13 -1
  70. package/src/TipKit/types.ts +19 -20
  71. package/src/TipKit/validation.ts +8 -5
  72. package/src/index.tsx +1 -2
  73. package/ios/SFSymbol/TinyuiSFSymbolEffects.h +0 -9
  74. package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +0 -180
  75. package/ios/SFSymbol/TinyuiSFSymbolImage.h +0 -8
  76. package/ios/SFSymbol/TinyuiSFSymbolImage.mm +0 -141
package/README.md CHANGED
@@ -80,7 +80,23 @@ Available components:
80
80
  - `Stepper`
81
81
  - `ConcentricView`
82
82
  - `SFSymbol`
83
- - `TipKit` (enables `TipKit.Popover`, `TipKit.configure` and `TipKit.invalidate`)
83
+ - `TipKit` (enables `TipKit.View`, `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
84
+
85
+ `TipKit` is one optional module containing both native views,
86
+ `TinyuiTipPopoverView` and `TinyuiTipView`. Select it once in your app's
87
+ `package.json` to include both:
88
+
89
+ ```json
90
+ {
91
+ "react-native-tinyui": {
92
+ "components": ["TipKit"]
93
+ }
94
+ }
95
+ ```
96
+
97
+ The JavaScript API is also `TipKit`, imported from `react-native-tinyui` or
98
+ `react-native-tinyui/tip-kit`. The library's Codegen configuration registers the
99
+ two native view classes individually; it is not the optional-module selection.
84
100
 
85
101
  All components are enabled by default. Use an empty `components` array to
86
102
  compile only the TurboModule core. Run `pod install` again whenever this list
@@ -256,12 +272,16 @@ outer container, not the presented content.
256
272
 
257
273
  <img src="docs/screenshots/tip-kit.png" alt="TipKit popover with a save action anchored to the favorite button" width="320" />
258
274
 
275
+ `TipKit.View` embeds a native `TipUIView` directly in your layout.
259
276
  `TipKit.Popover` anchors a native TipKit `TipUIPopoverViewController` to ordinary
260
277
  React Native children on iOS 17+. The children retain their own touch handlers;
261
278
  the tip appears automatically when both `enabled` and TipKit's eligibility allow
262
279
  it. Its text, SF Symbol, action buttons, display history and invalidation use
263
280
  Apple's TipKit framework. No additional package is required.
264
281
 
282
+ Both components support the same tip content, action callbacks, display options
283
+ and persistent history on iOS 17+.
284
+
265
285
  Configure TipKit once, **before mounting tips** (for example, during app startup):
266
286
 
267
287
  ```tsx
@@ -278,12 +298,18 @@ await TipKit.configure({ displayFrequency: 'daily' });
278
298
  systemImage="star"
279
299
  enabled={isScreenFocused && !hasFavorites}
280
300
  maxDisplayCount={3}
281
- actions={[{ id: 'learn-more', title: 'Learn more' }]}
282
- onActionPress={({ id }) => {
283
- if (id === 'learn-more') openHelp();
301
+ actions={[
302
+ { id: 'save', title: 'Save this item' },
303
+ { id: 'learn-more', title: 'Learn more' },
304
+ ]}
305
+ onActionPress={async ({ id }) => {
306
+ if (id === 'save') {
307
+ await saveFavorite();
308
+ await TipKit.invalidate('favorite-feature-v1', 'actionPerformed');
309
+ } else if (id === 'learn-more') {
310
+ openHelp();
311
+ }
284
312
  }}
285
- onStatusChange={(status) => console.log(status)}
286
- onVisibleChange={(visible) => console.log({ visible })}
287
313
  onError={(error) => console.warn(error.message)}
288
314
  >
289
315
  <Pressable onPress={async () => {
@@ -295,28 +321,73 @@ await TipKit.configure({ displayFrequency: 'daily' });
295
321
  </TipKit.Popover>;
296
322
  ```
297
323
 
324
+ For an inline tip, use `TipKit.View` without anchor children (after the same
325
+ startup configuration):
326
+
327
+ ```tsx
328
+ <TipKit.View
329
+ tipId="favorite-inline-v1"
330
+ title="Keep your favorites close"
331
+ message="Save an item to find it quickly next time."
332
+ systemImage="star"
333
+ enabled={isScreenFocused && !hasFavorites}
334
+ actions={[
335
+ { id: 'save', title: 'Save this item' },
336
+ { id: 'learn-more', title: 'Learn more' },
337
+ ]}
338
+ onActionPress={async ({ id }) => {
339
+ if (id === 'save') {
340
+ await saveFavorite();
341
+ await TipKit.invalidate('favorite-inline-v1', 'actionPerformed');
342
+ } else if (id === 'learn-more') {
343
+ openHelp();
344
+ }
345
+ }}
346
+ />
347
+ ```
348
+
349
+ `TipKit.View` fills the available width and measures its native content height.
350
+ The card keeps TipKit's preferred width within that space and aligns to the
351
+ leading edge; the system also chooses the action buttons' layout.
352
+ Give it a bounded width (for example, a stretched child of a column or an
353
+ explicit `style.width`); no fixed height is needed. When disabled or ineligible,
354
+ its content height becomes zero. Explicit height/min-height, padding, margins
355
+ and a parent's `gap` still follow normal React Native layout rules. Native
356
+ measurement updates Fabric directly, including after width and Dynamic Type
357
+ changes; there is no JavaScript size or status callback. The inline arrow is
358
+ optional: `arrowEdge` accepts `top`, `bottom`, `leading`, `trailing` or `none`
359
+ (default). `children` is only supported by `TipKit.Popover`.
360
+
361
+ The remaining props are shared by both components:
362
+
298
363
  | Prop | Description | Default |
299
364
  | --- | --- | --- |
300
365
  | `tipId` | Required stable identity for persistent history | — |
301
366
  | `title` | Required nonempty title, already localized by the app | — |
302
367
  | `message` | Supporting plain text | — |
303
368
  | `systemImage` | SF Symbol name | — |
304
- | `actions` | Buttons with unique nonempty `{ id, title }` values | `[]` |
369
+ | `actions` | Native buttons with unique nonempty `id` and nonempty `title`; TipKit chooses their layout | `[]` |
305
370
  | `enabled` | Allows presentation; does not override TipKit eligibility | `true` |
306
371
  | `maxDisplayCount` | Positive integer; automatically invalidates after this many displays | Unlimited |
307
372
  | `ignoresDisplayFrequency` | Exempts this tip from the app-wide display interval | `false` |
308
- | `arrowEdge` | Preferred bubble edge: `top`, `bottom`, `leading`, `trailing`, `auto` | `auto` |
309
- | `onStatusChange` | `{ status: 'pending' \| 'available' }` or `{ status: 'invalidated', reason }` | — |
310
- | `onVisibleChange` | Whether the native bubble is actually visible | — |
373
+ | `arrowEdge` | `top`, `bottom`, `leading`, `trailing`; Popover also accepts `auto`, View accepts `none` | Popover: `auto`; View: `none` |
311
374
  | `onActionPress` | Receives the selected `{ id, title }`; does not automatically invalidate | — |
312
375
  | `onError` | Receives `{ code, message }`; otherwise a warning is logged | — |
313
376
 
314
- The component accepts standard `ViewProps` and a native view ref. `style` lays
315
- out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
377
+ Both components accept standard `ViewProps` and a native view ref. For Popover,
378
+ `style` lays out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
316
379
  the arrow to available space; leading/trailing follow layout direction. Bubble
317
380
  content is native text/images/actions; `children` supplies only the anchor.
318
- Content edits apply to the next presentation; the currently visible bubble
319
- retains the content and action labels it was presented with.
381
+ Content and arrow edits on an inline tip apply the next time it is shown.
382
+ Content edits on a popover apply to the next presentation. A currently visible
383
+ tip retains the content and action labels it was shown with.
384
+
385
+ To show two action buttons, provide two entries in `actions`, as above. Each
386
+ `title` controls that button's label. `onActionPress` receives the clicked
387
+ button's `{ id, title }`; use `id` to handle the buttons separately. Titles may
388
+ be translated without changing action IDs. To temporarily hide a tip in response
389
+ to an action, set `enabled` to `false`; to permanently complete it, call
390
+ `TipKit.invalidate`.
320
391
 
321
392
  ### Configuration and lifetime
322
393
 
@@ -326,13 +397,38 @@ app-wide TipKit setting. Repeating the same configuration is safe, including
326
397
  after Fast Refresh; changing it later rejects with `E_TIPS_ALREADY_CONFIGURED`.
327
398
  Coordinate this startup configuration with any other native TipKit integration.
328
399
  Initialization errors reject the promise; an enabled view mounted too early
329
- reports `E_TIPS_NOT_CONFIGURED` and can recover after configuration succeeds.
400
+ reports `E_TIPS_NOT_CONFIGURED`. Await configuration before mounting tips. If a
401
+ tip was already mounted, remount it or toggle `enabled` after configuration to
402
+ start observing it.
403
+
404
+ `TipKit.resetDatastore()` returns a promise and clears all persisted tip, event
405
+ and parameter records, making previously invalidated tips eligible again when
406
+ their display conditions allow. It is primarily for testing the first-launch
407
+ experience and **must be awaited before `TipKit.configure()`**:
408
+
409
+ ```ts
410
+ // At app startup, when deliberately resetting tip history for testing:
411
+ await TipKit.resetDatastore();
412
+ await TipKit.configure({ displayFrequency: 'immediate' });
413
+ ```
414
+
415
+ Once TinyUI has configured TipKit, resetting rejects with
416
+ `E_TIPS_ALREADY_CONFIGURED`; restart the app process to run this sequence again.
417
+ Fast Refresh does not reset native configuration. Other native reset failures
418
+ also reject the promise (fallback code `E_TIPS_RESET`). Calling reset on every
419
+ launch will erase users' tip history on every launch.
330
420
 
331
421
  `TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
332
422
  requires configuration first. It persistently invalidates the ID even when no
333
423
  view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
334
- System invalidations can additionally report `displayCountExceeded`,
335
- `displayDurationExceeded`, or `unknown` through `onStatusChange`.
424
+
425
+ Both native views observe TipKit's `shouldDisplayUpdates` to show or remove
426
+ the tip. Eligibility and visibility changes are not sent to JavaScript.
427
+ Call `invalidate` with `actionPerformed` after the feature is used, or with
428
+ `tipClosed` to permanently dismiss the tip. TipKit also handles the system
429
+ close button and display limits.
430
+ Action buttons only emit `onActionPress`; they do not automatically invalidate.
431
+ Setting `enabled={false}` or unmounting a tip does not itself invalidate it.
336
432
 
337
433
  Keep IDs stable across renders, app launches and translations. All instances
338
434
  with the same ID share their TipKit history. Keep `maxDisplayCount` and
@@ -341,16 +437,30 @@ with the same ID share their TipKit history. Keep `maxDisplayCount` and
341
437
  tip. Hiding a tip, unmounting it or reconfiguring TinyUI never resets history.
342
438
 
343
439
  Use `enabled={isScreenFocused && businessCondition}` with your navigation
344
- library. TinyUI also waits for a visible anchor and a free presenter, and
345
- dismisses on detachment or when the anchor leaves the visible area. It presents
346
- at most one TinyUI tip at a time and waits while another modal occupies the
347
- presenter. An outside dismissal suppresses immediate reopening for that mount;
348
- toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
349
- The system close button can permanently invalidate a tip.
350
-
351
- This first version exposes popover tips and JS eligibility conditions. Inline
352
- `TipUIView`, native rule/event builders, TipGroup and datastore reset APIs are
353
- not exposed. The example's **New demo tip** button deliberately creates another
440
+ library. Each mounted, enabled Popover anchor starts its own `shouldDisplayUpdates`
441
+ observation once it has a size and a parent view controller. Disabling,
442
+ detaching or recycling the component stops observation and dismisses its
443
+ bubble; changing `tipId` starts a new observation. This follows the independent
444
+ `start()` / `stop()` structure of
445
+ [Tipster's TipPresenter](https://github.com/chockenberry/Tipster/blob/081922aa9421d764c436cbe16ad9e2601cd52740/Tipster/TipKitHelper.swift).
446
+
447
+ Popover presentation is skipped when the host view controller or an ancestor already
448
+ presents another controller. There is no global anchor registry, presentation
449
+ queue, polling timer, app-state observer or scroll visibility tracking.
450
+ Closing another tip or modal does not automatically retry a skipped
451
+ presentation. A later eligibility update, remount or re-enabling the component
452
+ can trigger another attempt. An outside dismissal only cleans up the current
453
+ presentation; there is no additional per-mount suppression policy. The system
454
+ close button can permanently invalidate a tip.
455
+
456
+ Inline tips start observing when attached to a window and stop when disabled,
457
+ detached or recycled. They do not present a view controller or block interaction
458
+ with the rest of the screen. Both presentations use the same datastore; an
459
+ invalidation affects every instance of the same `tipId`.
460
+
461
+ Native rule/event builders and TipGroup are not exposed. The example's
462
+ **Inline view** switch toggles between both presentations. Its **New demo tip**
463
+ button deliberately creates another
354
464
  ID for trying the behavior again without clearing the app's TipKit datastore.
355
465
 
356
466
  ## LiquidGlassText
@@ -389,7 +499,7 @@ import { LiquidGlassText } from 'react-native-tinyui';
389
499
  | Prop | Values | Default |
390
500
  | ------------------------ | ------------------------------------------------ | --------- |
391
501
  | `text` | String (including text translated in JavaScript) | Required |
392
- | `effect` | `clear`, `regular`, `identity` | `clear` |
502
+ | `effect` | `clear`, `regular` | `clear` |
393
503
  | `tint` | React Native `ColorValue` | — |
394
504
  | `interactive` | Whether the glass responds to interaction | `false` |
395
505
  | `fontDesign` | `default`, `serif`, `monospaced`, `rounded` | `default` |
@@ -403,7 +513,7 @@ precedence. `fontFamily` accepts the PostScript name of a font installed in the
403
513
  app, and `fontDesign` only applies to system fonts.
404
514
 
405
515
  `tint` accepts a React Native `ColorValue`, including `PlatformColor` and
406
- `DynamicColorIOS`. `identity` applies no glass effect.
516
+ `DynamicColorIOS`.
407
517
  The component also accepts standard `ViewProps`, including `style`, `testID`,
408
518
  accessibility props and a native view ref. The supplied text is used for
409
519
  the default accessibility label; pass `accessibilityLabel` to override it.
@@ -564,9 +674,7 @@ function Favorite() {
564
674
  | `size` | Positive point size; default `17`. |
565
675
  | `weight` | `unspecified` (default), `ultraLight`, `thin`, `light`, `regular`, `medium`, `semibold`, `bold`, `heavy`, `black`. |
566
676
  | `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`. |
677
+ | `allowFontScaling` | Default `true`. Scales the base point size by React Native's `fontScale`. |
570
678
  | `maxFontSizeMultiplier` | Cap on that scale, >= 1. Omitted or `0` means unlimited. |
571
679
  | `renderingMode` | `automatic` (default), `monochrome`, `hierarchical`, `palette`, `multicolor`. |
572
680
  | `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
@@ -55,8 +55,10 @@ TINYUI_DISABLED_COMPONENT(TinyuiSFSymbolView, TinyuiSFSymbolViewComponentDescrip
55
55
 
56
56
  #if TINYUI_FEATURE_TIP_KIT
57
57
  #import "TinyuiTipPopoverView.h"
58
+ #import "TinyuiTipView.h"
58
59
  #else
59
60
  TINYUI_DISABLED_COMPONENT(TinyuiTipPopoverView, TinyuiTipPopoverViewComponentDescriptor)
61
+ TINYUI_DISABLED_COMPONENT(TinyuiTipView, TinyuiTipViewComponentDescriptor)
60
62
  #endif
61
63
 
62
64
  #undef TINYUI_DISABLED_COMPONENT
@@ -95,3 +97,8 @@ Class<RCTComponentViewProtocol> TinyuiTipPopoverViewCls(void)
95
97
  {
96
98
  return TinyuiTipPopoverView.class;
97
99
  }
100
+
101
+ Class<RCTComponentViewProtocol> TinyuiTipViewCls(void)
102
+ {
103
+ return TinyuiTipView.class;
104
+ }
@@ -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
+ }