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.
- package/README.md +140 -32
- package/ios/Core/Tinyui.mm +18 -0
- package/ios/Core/TinyuiComponentClasses.mm +7 -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/TinyuiTip.swift +16 -0
- package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
- package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
- package/ios/TipKit/TinyuiTipView.h +8 -0
- package/ios/TipKit/TinyuiTipView.mm +93 -0
- package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
- package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
- package/ios/TipKit/TinyuiTipsRuntime.swift +16 -74
- 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/Popover.js +0 -22
- package/lib/module/TipKit/Popover.js.map +1 -1
- package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
- package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
- package/lib/module/TipKit/View.js +62 -0
- package/lib/module/TipKit/View.js.map +1 -0
- package/lib/module/TipKit/index.js +11 -0
- package/lib/module/TipKit/index.js.map +1 -1
- package/lib/module/TipKit/validation.js +4 -4
- package/lib/module/TipKit/validation.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/Popover.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/View.d.ts +3 -0
- package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/index.d.ts +8 -1
- package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/types.d.ts +17 -15
- package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/validation.d.ts +2 -2
- package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +4 -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/Popover.tsx +1 -21
- package/src/TipKit/TipPopoverNativeComponent.ts +0 -6
- package/src/TipKit/TipViewNativeComponent.ts +28 -0
- package/src/TipKit/View.tsx +64 -0
- package/src/TipKit/index.ts +13 -1
- package/src/TipKit/types.ts +19 -20
- package/src/TipKit/validation.ts +8 -5
- package/src/index.tsx +1 -2
- 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,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={[
|
|
282
|
-
|
|
283
|
-
|
|
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` |
|
|
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` |
|
|
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
|
-
|
|
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
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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`.
|
|
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
|
-
| `
|
|
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. |
|
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
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|