react-native-tinyui 2.0.0 → 3.1.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 +177 -42
- package/ios/Core/Tinyui.mm +3 -0
- package/ios/Core/TinyuiComponentClasses.mm +18 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
- 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 +2 -74
- package/lib/module/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
- package/lib/module/SwipeActions/actions.js +21 -0
- package/lib/module/SwipeActions/actions.js.map +1 -0
- package/lib/module/SwipeActions/index.js +43 -0
- package/lib/module/SwipeActions/index.js.map +1 -0
- package/lib/module/SwipeActions/types.js +4 -0
- package/lib/module/SwipeActions/types.js.map +1 -0
- 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 +2 -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 +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/getNativeTinyui.js.map +1 -1
- package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts +15 -0
- package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/actions.d.ts +9 -0
- package/lib/typescript/src/SwipeActions/actions.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/index.d.ts +4 -0
- package/lib/typescript/src/SwipeActions/index.d.ts.map +1 -0
- package/lib/typescript/src/SwipeActions/types.d.ts +37 -0
- package/lib/typescript/src/SwipeActions/types.d.ts.map +1 -0
- 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 +2 -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 +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
- package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
- package/package.json +13 -2
- package/react-native-tinyui.podspec +6 -1
- package/src/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
- package/src/SwipeActions/actions.ts +26 -0
- package/src/SwipeActions/index.tsx +54 -0
- package/src/SwipeActions/types.ts +37 -0
- 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 +3 -1
- package/src/TipKit/types.ts +19 -20
- package/src/TipKit/validation.ts +8 -5
- package/src/index.tsx +8 -1
- package/src/utils/getNativeTinyui.ts +1 -0
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ staying close to native APIs. It uses UIKit by default and SwiftUI where needed.
|
|
|
11
11
|
| Component | What it does |
|
|
12
12
|
| ---------------------------------- | -------------------------------------------------------------- |
|
|
13
13
|
| [Menu](#menu) | Present native menus with actions, submenus, and sections |
|
|
14
|
+
| [SwipeActions](#swipeactions) | Reveal native action buttons by swiping React Native rows |
|
|
14
15
|
| [Popover](#popover) | Show interactive React Native content in a native popover |
|
|
15
16
|
| [TipKit](#tipkit) | Present contextual tips using Apple's TipKit framework |
|
|
16
17
|
| [LiquidGlassText](#liquidglasstext) | Render text with native glass effects inside the glyphs |
|
|
@@ -75,12 +76,29 @@ use in your app's `package.json`:
|
|
|
75
76
|
Available components:
|
|
76
77
|
|
|
77
78
|
- `Menu`
|
|
79
|
+
- `SwipeActions`
|
|
78
80
|
- `Popover`
|
|
79
81
|
- `LiquidGlassText`
|
|
80
82
|
- `Stepper`
|
|
81
83
|
- `ConcentricView`
|
|
82
84
|
- `SFSymbol`
|
|
83
|
-
- `TipKit` (enables `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
|
|
85
|
+
- `TipKit` (enables `TipKit.View`, `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
|
|
86
|
+
|
|
87
|
+
`TipKit` is one optional module containing both native views,
|
|
88
|
+
`TinyuiTipPopoverView` and `TinyuiTipView`. Select it once in your app's
|
|
89
|
+
`package.json` to include both:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"react-native-tinyui": {
|
|
94
|
+
"components": ["TipKit"]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The JavaScript API is also `TipKit`, imported from `react-native-tinyui` or
|
|
100
|
+
`react-native-tinyui/tip-kit`. The library's Codegen configuration registers the
|
|
101
|
+
two native view classes individually; it is not the optional-module selection.
|
|
84
102
|
|
|
85
103
|
All components are enabled by default. Use an empty `components` array to
|
|
86
104
|
compile only the TurboModule core. Run `pod install` again whenever this list
|
|
@@ -91,6 +109,7 @@ Component-specific JavaScript entry points are also available:
|
|
|
91
109
|
|
|
92
110
|
```tsx
|
|
93
111
|
import { Menu } from 'react-native-tinyui/menu';
|
|
112
|
+
import { SwipeActions } from 'react-native-tinyui/swipe-actions';
|
|
94
113
|
import { Popover } from 'react-native-tinyui/popover';
|
|
95
114
|
import { LiquidGlassText } from 'react-native-tinyui/liquid-glass-text';
|
|
96
115
|
import { Stepper } from 'react-native-tinyui/stepper';
|
|
@@ -205,6 +224,62 @@ deprecated `titleColor` field is retained only for source compatibility; use
|
|
|
205
224
|
`destructive` for system red styling. Submenus support the same label fields
|
|
206
225
|
plus `destructive`, `disabled`, `hidden`, and `displayInline`.
|
|
207
226
|
|
|
227
|
+
## SwipeActions
|
|
228
|
+
|
|
229
|
+
`SwipeActions` wraps React Native row content in a native, non-scrolling
|
|
230
|
+
`UITableView` row and uses `UISwipeActionsConfiguration`. It provides the edge
|
|
231
|
+
and full-swipe behavior of SwiftUI's
|
|
232
|
+
[`swipeActions`](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:))
|
|
233
|
+
without requiring a SwiftUI `List`. Use it inside a `FlatList` or `ScrollView`;
|
|
234
|
+
Yoga measures the children, so a fixed row height is optional.
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { SwipeActions } from 'react-native-tinyui/swipe-actions';
|
|
238
|
+
|
|
239
|
+
<SwipeActions
|
|
240
|
+
edge="trailing"
|
|
241
|
+
allowsFullSwipe={false}
|
|
242
|
+
contentContainerStyle={{ backgroundColor: 'white', borderRadius: 12 }}
|
|
243
|
+
actions={[
|
|
244
|
+
{ id: 'archive', title: 'Archive', systemImage: 'archivebox', tintColor: '#007AFF' },
|
|
245
|
+
{ id: 'delete', title: 'Delete', systemImage: 'trash', destructive: true },
|
|
246
|
+
]}
|
|
247
|
+
onActionPress={({ nativeEvent }) => handleAction(nativeEvent.id)}
|
|
248
|
+
>
|
|
249
|
+
<Pressable onPress={openMessage} style={{ padding: 16 }}>
|
|
250
|
+
<Text>Weekend plans</Text>
|
|
251
|
+
</Pressable>
|
|
252
|
+
</SwipeActions>;
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
| Prop | Description | Default |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `actions` | Ordered array of native buttons; an empty array disables swiping | Required |
|
|
258
|
+
| `edge` | `leading` or `trailing`, following the row's layout direction | `trailing` |
|
|
259
|
+
| `allowsFullSwipe` | A full swipe performs the **first** action | `true` |
|
|
260
|
+
| `disabled` | Disables swipe actions while preserving child interactions | `false` |
|
|
261
|
+
| `onActionPress` | Receives `{ nativeEvent: { id, title } }` | — |
|
|
262
|
+
| `children` | React Native row content, including pressable children | Required |
|
|
263
|
+
| `contentContainerStyle` | Styles the moving row, including its background, corners and padding | — |
|
|
264
|
+
|
|
265
|
+
Each action accepts `title`, optional `id` (defaults to `item.<index>`),
|
|
266
|
+
`systemImage` (SF Symbol), `tintColor` (button background), and `destructive`.
|
|
267
|
+
IDs must be unique within the row; use explicit IDs when reordering actions.
|
|
268
|
+
Colors support `PlatformColor` and `DynamicColorIOS`. Buttons appear from the
|
|
269
|
+
selected edge inward in array order. `destructive` changes the native appearance;
|
|
270
|
+
your callback decides whether to remove the row from React state.
|
|
271
|
+
|
|
272
|
+
Standard `ViewProps` and a native view ref are supported. `style` applies to the
|
|
273
|
+
stationary outer container; use it for layout, sizing and margins. Put the row's
|
|
274
|
+
background, corners and padding in `contentContainerStyle` (or on your children)
|
|
275
|
+
so they move with the content. A background on `style` stays behind the revealed
|
|
276
|
+
actions. Keep stable list keys.
|
|
277
|
+
Opening another `SwipeActions` row closes the previous one. Updating the action
|
|
278
|
+
configuration, disabling, resizing or unmounting a row dismisses its actions.
|
|
279
|
+
One edge is supported per wrapper; do not nest wrappers to add a second edge.
|
|
280
|
+
This component hosts one native table per mounted row, so use list virtualization
|
|
281
|
+
for large datasets. It does not expose SwiftUI's arbitrary action view content.
|
|
282
|
+
|
|
208
283
|
## Popover
|
|
209
284
|
|
|
210
285
|
<img src="docs/screenshots/popover.png" alt="Popover displaying resizable content above its trigger" width="320" />
|
|
@@ -256,12 +331,16 @@ outer container, not the presented content.
|
|
|
256
331
|
|
|
257
332
|
<img src="docs/screenshots/tip-kit.png" alt="TipKit popover with a save action anchored to the favorite button" width="320" />
|
|
258
333
|
|
|
334
|
+
`TipKit.View` embeds a native `TipUIView` directly in your layout.
|
|
259
335
|
`TipKit.Popover` anchors a native TipKit `TipUIPopoverViewController` to ordinary
|
|
260
336
|
React Native children on iOS 17+. The children retain their own touch handlers;
|
|
261
337
|
the tip appears automatically when both `enabled` and TipKit's eligibility allow
|
|
262
338
|
it. Its text, SF Symbol, action buttons, display history and invalidation use
|
|
263
339
|
Apple's TipKit framework. No additional package is required.
|
|
264
340
|
|
|
341
|
+
Both components support the same tip content, action callbacks, display options
|
|
342
|
+
and persistent history on iOS 17+.
|
|
343
|
+
|
|
265
344
|
Configure TipKit once, **before mounting tips** (for example, during app startup):
|
|
266
345
|
|
|
267
346
|
```tsx
|
|
@@ -278,12 +357,18 @@ await TipKit.configure({ displayFrequency: 'daily' });
|
|
|
278
357
|
systemImage="star"
|
|
279
358
|
enabled={isScreenFocused && !hasFavorites}
|
|
280
359
|
maxDisplayCount={3}
|
|
281
|
-
actions={[
|
|
282
|
-
|
|
283
|
-
|
|
360
|
+
actions={[
|
|
361
|
+
{ id: 'save', title: 'Save this item' },
|
|
362
|
+
{ id: 'learn-more', title: 'Learn more' },
|
|
363
|
+
]}
|
|
364
|
+
onActionPress={async ({ id }) => {
|
|
365
|
+
if (id === 'save') {
|
|
366
|
+
await saveFavorite();
|
|
367
|
+
await TipKit.invalidate('favorite-feature-v1', 'actionPerformed');
|
|
368
|
+
} else if (id === 'learn-more') {
|
|
369
|
+
openHelp();
|
|
370
|
+
}
|
|
284
371
|
}}
|
|
285
|
-
onStatusChange={(status) => console.log(status)}
|
|
286
|
-
onVisibleChange={(visible) => console.log({ visible })}
|
|
287
372
|
onError={(error) => console.warn(error.message)}
|
|
288
373
|
>
|
|
289
374
|
<Pressable onPress={async () => {
|
|
@@ -295,28 +380,73 @@ await TipKit.configure({ displayFrequency: 'daily' });
|
|
|
295
380
|
</TipKit.Popover>;
|
|
296
381
|
```
|
|
297
382
|
|
|
383
|
+
For an inline tip, use `TipKit.View` without anchor children (after the same
|
|
384
|
+
startup configuration):
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
<TipKit.View
|
|
388
|
+
tipId="favorite-inline-v1"
|
|
389
|
+
title="Keep your favorites close"
|
|
390
|
+
message="Save an item to find it quickly next time."
|
|
391
|
+
systemImage="star"
|
|
392
|
+
enabled={isScreenFocused && !hasFavorites}
|
|
393
|
+
actions={[
|
|
394
|
+
{ id: 'save', title: 'Save this item' },
|
|
395
|
+
{ id: 'learn-more', title: 'Learn more' },
|
|
396
|
+
]}
|
|
397
|
+
onActionPress={async ({ id }) => {
|
|
398
|
+
if (id === 'save') {
|
|
399
|
+
await saveFavorite();
|
|
400
|
+
await TipKit.invalidate('favorite-inline-v1', 'actionPerformed');
|
|
401
|
+
} else if (id === 'learn-more') {
|
|
402
|
+
openHelp();
|
|
403
|
+
}
|
|
404
|
+
}}
|
|
405
|
+
/>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`TipKit.View` fills the available width and measures its native content height.
|
|
409
|
+
The card keeps TipKit's preferred width within that space and aligns to the
|
|
410
|
+
leading edge; the system also chooses the action buttons' layout.
|
|
411
|
+
Give it a bounded width (for example, a stretched child of a column or an
|
|
412
|
+
explicit `style.width`); no fixed height is needed. When disabled or ineligible,
|
|
413
|
+
its content height becomes zero. Explicit height/min-height, padding, margins
|
|
414
|
+
and a parent's `gap` still follow normal React Native layout rules. Native
|
|
415
|
+
measurement updates Fabric directly, including after width and Dynamic Type
|
|
416
|
+
changes; there is no JavaScript size or status callback. The inline arrow is
|
|
417
|
+
optional: `arrowEdge` accepts `top`, `bottom`, `leading`, `trailing` or `none`
|
|
418
|
+
(default). `children` is only supported by `TipKit.Popover`.
|
|
419
|
+
|
|
420
|
+
The remaining props are shared by both components:
|
|
421
|
+
|
|
298
422
|
| Prop | Description | Default |
|
|
299
423
|
| --- | --- | --- |
|
|
300
424
|
| `tipId` | Required stable identity for persistent history | — |
|
|
301
425
|
| `title` | Required nonempty title, already localized by the app | — |
|
|
302
426
|
| `message` | Supporting plain text | — |
|
|
303
427
|
| `systemImage` | SF Symbol name | — |
|
|
304
|
-
| `actions` |
|
|
428
|
+
| `actions` | Native buttons with unique nonempty `id` and nonempty `title`; TipKit chooses their layout | `[]` |
|
|
305
429
|
| `enabled` | Allows presentation; does not override TipKit eligibility | `true` |
|
|
306
430
|
| `maxDisplayCount` | Positive integer; automatically invalidates after this many displays | Unlimited |
|
|
307
431
|
| `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 | — |
|
|
432
|
+
| `arrowEdge` | `top`, `bottom`, `leading`, `trailing`; Popover also accepts `auto`, View accepts `none` | Popover: `auto`; View: `none` |
|
|
311
433
|
| `onActionPress` | Receives the selected `{ id, title }`; does not automatically invalidate | — |
|
|
312
434
|
| `onError` | Receives `{ code, message }`; otherwise a warning is logged | — |
|
|
313
435
|
|
|
314
|
-
|
|
315
|
-
out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
|
|
436
|
+
Both components accept standard `ViewProps` and a native view ref. For Popover,
|
|
437
|
+
`style` lays out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
|
|
316
438
|
the arrow to available space; leading/trailing follow layout direction. Bubble
|
|
317
439
|
content is native text/images/actions; `children` supplies only the anchor.
|
|
318
|
-
Content edits apply
|
|
319
|
-
|
|
440
|
+
Content and arrow edits on an inline tip apply the next time it is shown.
|
|
441
|
+
Content edits on a popover apply to the next presentation. A currently visible
|
|
442
|
+
tip retains the content and action labels it was shown with.
|
|
443
|
+
|
|
444
|
+
To show two action buttons, provide two entries in `actions`, as above. Each
|
|
445
|
+
`title` controls that button's label. `onActionPress` receives the clicked
|
|
446
|
+
button's `{ id, title }`; use `id` to handle the buttons separately. Titles may
|
|
447
|
+
be translated without changing action IDs. To temporarily hide a tip in response
|
|
448
|
+
to an action, set `enabled` to `false`; to permanently complete it, call
|
|
449
|
+
`TipKit.invalidate`.
|
|
320
450
|
|
|
321
451
|
### Configuration and lifetime
|
|
322
452
|
|
|
@@ -326,7 +456,9 @@ app-wide TipKit setting. Repeating the same configuration is safe, including
|
|
|
326
456
|
after Fast Refresh; changing it later rejects with `E_TIPS_ALREADY_CONFIGURED`.
|
|
327
457
|
Coordinate this startup configuration with any other native TipKit integration.
|
|
328
458
|
Initialization errors reject the promise; an enabled view mounted too early
|
|
329
|
-
reports `E_TIPS_NOT_CONFIGURED
|
|
459
|
+
reports `E_TIPS_NOT_CONFIGURED`. Await configuration before mounting tips. If a
|
|
460
|
+
tip was already mounted, remount it or toggle `enabled` after configuration to
|
|
461
|
+
start observing it.
|
|
330
462
|
|
|
331
463
|
`TipKit.resetDatastore()` returns a promise and clears all persisted tip, event
|
|
332
464
|
and parameter records, making previously invalidated tips eligible again when
|
|
@@ -348,25 +480,14 @@ launch will erase users' tip history on every launch.
|
|
|
348
480
|
`TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
|
|
349
481
|
requires configuration first. It persistently invalidates the ID even when no
|
|
350
482
|
view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
|
|
351
|
-
System invalidations can additionally report `displayCountExceeded`,
|
|
352
|
-
`displayDurationExceeded`, or `unknown` through `onStatusChange`.
|
|
353
483
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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.
|
|
484
|
+
Both native views observe TipKit's `shouldDisplayUpdates` to show or remove
|
|
485
|
+
the tip. Eligibility and visibility changes are not sent to JavaScript.
|
|
486
|
+
Call `invalidate` with `actionPerformed` after the feature is used, or with
|
|
487
|
+
`tipClosed` to permanently dismiss the tip. TipKit also handles the system
|
|
488
|
+
close button and display limits.
|
|
367
489
|
Action buttons only emit `onActionPress`; they do not automatically invalidate.
|
|
368
490
|
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
491
|
|
|
371
492
|
Keep IDs stable across renders, app launches and translations. All instances
|
|
372
493
|
with the same ID share their TipKit history. Keep `maxDisplayCount` and
|
|
@@ -375,16 +496,30 @@ with the same ID share their TipKit history. Keep `maxDisplayCount` and
|
|
|
375
496
|
tip. Hiding a tip, unmounting it or reconfiguring TinyUI never resets history.
|
|
376
497
|
|
|
377
498
|
Use `enabled={isScreenFocused && businessCondition}` with your navigation
|
|
378
|
-
library.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
499
|
+
library. Each mounted, enabled Popover anchor starts its own `shouldDisplayUpdates`
|
|
500
|
+
observation once it has a size and a parent view controller. Disabling,
|
|
501
|
+
detaching or recycling the component stops observation and dismisses its
|
|
502
|
+
bubble; changing `tipId` starts a new observation. This follows the independent
|
|
503
|
+
`start()` / `stop()` structure of
|
|
504
|
+
[Tipster's TipPresenter](https://github.com/chockenberry/Tipster/blob/081922aa9421d764c436cbe16ad9e2601cd52740/Tipster/TipKitHelper.swift).
|
|
505
|
+
|
|
506
|
+
Popover presentation is skipped when the host view controller or an ancestor already
|
|
507
|
+
presents another controller. There is no global anchor registry, presentation
|
|
508
|
+
queue, polling timer, app-state observer or scroll visibility tracking.
|
|
509
|
+
Closing another tip or modal does not automatically retry a skipped
|
|
510
|
+
presentation. A later eligibility update, remount or re-enabling the component
|
|
511
|
+
can trigger another attempt. An outside dismissal only cleans up the current
|
|
512
|
+
presentation; there is no additional per-mount suppression policy. The system
|
|
513
|
+
close button can permanently invalidate a tip.
|
|
514
|
+
|
|
515
|
+
Inline tips start observing when attached to a window and stop when disabled,
|
|
516
|
+
detached or recycled. They do not present a view controller or block interaction
|
|
517
|
+
with the rest of the screen. Both presentations use the same datastore; an
|
|
518
|
+
invalidation affects every instance of the same `tipId`.
|
|
519
|
+
|
|
520
|
+
Native rule/event builders and TipGroup are not exposed. The example's
|
|
521
|
+
**Inline view** switch toggles between both presentations. Its **New demo tip**
|
|
522
|
+
button deliberately creates another
|
|
388
523
|
ID for trying the behavior again without clearing the app's TipKit datastore.
|
|
389
524
|
|
|
390
525
|
## LiquidGlassText
|
|
@@ -668,7 +803,7 @@ visibility state changes apply instantly. Set it to `false` to opt out.
|
|
|
668
803
|
|
|
669
804
|
## Example App
|
|
670
805
|
|
|
671
|
-
The [example app](example/src/App.tsx) demonstrates Menu, Popover, TipKit,
|
|
806
|
+
The [example app](example/src/App.tsx) demonstrates Menu, SwipeActions, Popover, TipKit,
|
|
672
807
|
LiquidGlassText, Stepper, ConcentricView, and SFSymbol. It includes all native
|
|
673
808
|
components, so building it requires Xcode 26+. Use iOS 26+ to try the glass effects
|
|
674
809
|
and the latest SF Symbol features.
|
package/ios/Core/Tinyui.mm
CHANGED
|
@@ -29,6 +29,12 @@ TINYUI_DISABLED_COMPONENT(TinyuiMenuView, TinyuiMenuViewComponentDescriptor)
|
|
|
29
29
|
TINYUI_DISABLED_COMPONENT(TinyuiPopoverView, TinyuiPopoverViewComponentDescriptor)
|
|
30
30
|
#endif
|
|
31
31
|
|
|
32
|
+
#if TINYUI_FEATURE_SWIPE_ACTIONS
|
|
33
|
+
#import "TinyuiSwipeActionsView.h"
|
|
34
|
+
#else
|
|
35
|
+
TINYUI_DISABLED_COMPONENT(TinyuiSwipeActionsView, TinyuiSwipeActionsViewComponentDescriptor)
|
|
36
|
+
#endif
|
|
37
|
+
|
|
32
38
|
#if TINYUI_FEATURE_LIQUID_GLASS_TEXT
|
|
33
39
|
#import "TinyuiLiquidGlassTextView.h"
|
|
34
40
|
#else
|
|
@@ -55,8 +61,10 @@ TINYUI_DISABLED_COMPONENT(TinyuiSFSymbolView, TinyuiSFSymbolViewComponentDescrip
|
|
|
55
61
|
|
|
56
62
|
#if TINYUI_FEATURE_TIP_KIT
|
|
57
63
|
#import "TinyuiTipPopoverView.h"
|
|
64
|
+
#import "TinyuiTipView.h"
|
|
58
65
|
#else
|
|
59
66
|
TINYUI_DISABLED_COMPONENT(TinyuiTipPopoverView, TinyuiTipPopoverViewComponentDescriptor)
|
|
67
|
+
TINYUI_DISABLED_COMPONENT(TinyuiTipView, TinyuiTipViewComponentDescriptor)
|
|
60
68
|
#endif
|
|
61
69
|
|
|
62
70
|
#undef TINYUI_DISABLED_COMPONENT
|
|
@@ -66,6 +74,11 @@ Class<RCTComponentViewProtocol> TinyuiMenuViewCls(void)
|
|
|
66
74
|
return TinyuiMenuView.class;
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
Class<RCTComponentViewProtocol> TinyuiSwipeActionsViewCls(void)
|
|
78
|
+
{
|
|
79
|
+
return TinyuiSwipeActionsView.class;
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
Class<RCTComponentViewProtocol> TinyuiPopoverViewCls(void)
|
|
70
83
|
{
|
|
71
84
|
return TinyuiPopoverView.class;
|
|
@@ -95,3 +108,8 @@ Class<RCTComponentViewProtocol> TinyuiTipPopoverViewCls(void)
|
|
|
95
108
|
{
|
|
96
109
|
return TinyuiTipPopoverView.class;
|
|
97
110
|
}
|
|
111
|
+
|
|
112
|
+
Class<RCTComponentViewProtocol> TinyuiTipViewCls(void)
|
|
113
|
+
{
|
|
114
|
+
return TinyuiTipView.class;
|
|
115
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#if TINYUI_FEATURE_SWIPE_ACTIONS
|
|
2
|
+
|
|
3
|
+
#import "TinyuiSwipeActionsView.h"
|
|
4
|
+
|
|
5
|
+
#import <React/RCTConvert.h>
|
|
6
|
+
#import <React/RCTSurfacePointerHandler.h>
|
|
7
|
+
#import <React/RCTSurfaceTouchHandler.h>
|
|
8
|
+
#import <react/utils/FollyConvert.h>
|
|
9
|
+
#import <react/renderer/components/TinyuiSpec/ComponentDescriptors.h>
|
|
10
|
+
#import <react/renderer/components/TinyuiSpec/EventEmitters.h>
|
|
11
|
+
#import <react/renderer/components/TinyuiSpec/Props.h>
|
|
12
|
+
|
|
13
|
+
using namespace facebook::react;
|
|
14
|
+
|
|
15
|
+
@interface TinyuiSwipeActionsView () <UITableViewDataSource, UITableViewDelegate>
|
|
16
|
+
@end
|
|
17
|
+
|
|
18
|
+
@implementation TinyuiSwipeActionsView {
|
|
19
|
+
UITableView *_tableView;
|
|
20
|
+
UITableViewCell *_cell;
|
|
21
|
+
UIView *_rowView;
|
|
22
|
+
NSArray<NSDictionary *> *_actions;
|
|
23
|
+
BOOL _leading;
|
|
24
|
+
BOOL _allowsFullSwipe;
|
|
25
|
+
BOOL _disabled;
|
|
26
|
+
BOOL _needsConfiguration;
|
|
27
|
+
BOOL _actionsPresented;
|
|
28
|
+
NSUInteger _configurationVersion;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// UIKit normally coordinates open rows within one table. Each React row has
|
|
32
|
+
// its own table, so coordinate them without retaining mounted Fabric views.
|
|
33
|
+
static __weak TinyuiSwipeActionsView *activeSwipeView;
|
|
34
|
+
|
|
35
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
|
36
|
+
{
|
|
37
|
+
return concreteComponentDescriptorProvider<TinyuiSwipeActionsViewComponentDescriptor>();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
- (instancetype)initWithFrame:(CGRect)frame
|
|
41
|
+
{
|
|
42
|
+
if (self = [super initWithFrame:frame]) {
|
|
43
|
+
_props = std::make_shared<const TinyuiSwipeActionsViewProps>();
|
|
44
|
+
_actions = @[];
|
|
45
|
+
_allowsFullSwipe = YES;
|
|
46
|
+
|
|
47
|
+
_cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
|
|
48
|
+
_cell.backgroundColor = UIColor.clearColor;
|
|
49
|
+
_cell.contentView.backgroundColor = UIColor.clearColor;
|
|
50
|
+
_cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
|
51
|
+
_cell.isAccessibilityElement = NO;
|
|
52
|
+
|
|
53
|
+
_tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
|
54
|
+
_tableView.backgroundColor = UIColor.clearColor;
|
|
55
|
+
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
|
56
|
+
_tableView.scrollEnabled = NO;
|
|
57
|
+
_tableView.showsVerticalScrollIndicator = NO;
|
|
58
|
+
_tableView.showsHorizontalScrollIndicator = NO;
|
|
59
|
+
_tableView.bounces = NO;
|
|
60
|
+
_tableView.delaysContentTouches = NO;
|
|
61
|
+
_tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
|
62
|
+
_tableView.insetsContentViewsToSafeArea = NO;
|
|
63
|
+
_tableView.sectionHeaderTopPadding = 0;
|
|
64
|
+
_tableView.estimatedRowHeight = 0;
|
|
65
|
+
_tableView.estimatedSectionHeaderHeight = 0;
|
|
66
|
+
_tableView.estimatedSectionFooterHeight = 0;
|
|
67
|
+
_tableView.dataSource = self;
|
|
68
|
+
_tableView.delegate = self;
|
|
69
|
+
// Cover the entire row. Yoga owns the child's frame, including any padding;
|
|
70
|
+
// using RCTView's inset contentView frame would apply that padding twice.
|
|
71
|
+
[self addSubview:_tableView];
|
|
72
|
+
}
|
|
73
|
+
return self;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
|
|
77
|
+
index:(NSInteger)index
|
|
78
|
+
{
|
|
79
|
+
_rowView = childComponentView;
|
|
80
|
+
[_cell.contentView addSubview:childComponentView];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
|
|
84
|
+
index:(NSInteger)index
|
|
85
|
+
{
|
|
86
|
+
[childComponentView removeFromSuperview];
|
|
87
|
+
if (_rowView == childComponentView) {
|
|
88
|
+
_rowView = nil;
|
|
89
|
+
[self closeActions];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
- (void)layoutSubviews
|
|
94
|
+
{
|
|
95
|
+
[super layoutSubviews];
|
|
96
|
+
UISemanticContentAttribute direction = _layoutMetrics.layoutDirection == LayoutDirection::RightToLeft
|
|
97
|
+
? UISemanticContentAttributeForceRightToLeft
|
|
98
|
+
: UISemanticContentAttributeForceLeftToRight;
|
|
99
|
+
if (!CGSizeEqualToSize(_tableView.bounds.size, self.bounds.size) ||
|
|
100
|
+
_tableView.semanticContentAttribute != direction) {
|
|
101
|
+
[self closeActions];
|
|
102
|
+
}
|
|
103
|
+
_tableView.frame = self.bounds;
|
|
104
|
+
CGFloat height = MAX(1, CGRectGetHeight(self.bounds));
|
|
105
|
+
if (_tableView.rowHeight != height) {
|
|
106
|
+
_tableView.rowHeight = height;
|
|
107
|
+
[_tableView reloadData];
|
|
108
|
+
}
|
|
109
|
+
// Map Yoga's resolved direction to UIKit's logical leading/trailing edges.
|
|
110
|
+
_tableView.semanticContentAttribute = direction;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
|
|
114
|
+
{
|
|
115
|
+
UIView *hit = [super hitTest:point withEvent:event];
|
|
116
|
+
// A tap on the exposed row dismisses its actions. Keep that tap in UIKit,
|
|
117
|
+
// rather than also activating a React child while the row is sliding back.
|
|
118
|
+
if (_actionsPresented && _rowView != nil && [hit isDescendantOfView:_rowView]) {
|
|
119
|
+
return _cell.contentView;
|
|
120
|
+
}
|
|
121
|
+
return hit;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
- (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
|
|
125
|
+
{
|
|
126
|
+
// Native action buttons have no Fabric ancestor until this view. Do not let
|
|
127
|
+
// their taps bubble to an outer React Pressable. Child React views still own
|
|
128
|
+
// normal row touches and keep their usual event bubbling.
|
|
129
|
+
return _actionsPresented ? nullptr : [super touchEventEmitterAtPoint:point];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
|
133
|
+
{
|
|
134
|
+
const auto &previous = *std::static_pointer_cast<const TinyuiSwipeActionsViewProps>(_props);
|
|
135
|
+
const auto &next = *std::static_pointer_cast<const TinyuiSwipeActionsViewProps>(props);
|
|
136
|
+
if (_needsConfiguration || previous.actionsConfig != next.actionsConfig || previous.edge != next.edge ||
|
|
137
|
+
previous.allowsFullSwipe != next.allowsFullSwipe || previous.disabled != next.disabled) {
|
|
138
|
+
[self closeActions];
|
|
139
|
+
id config = convertFollyDynamicToId(next.actionsConfig);
|
|
140
|
+
_actions = [config isKindOfClass:NSArray.class] ? config : @[];
|
|
141
|
+
_leading = next.edge == TinyuiSwipeActionsViewEdge::Leading;
|
|
142
|
+
_allowsFullSwipe = next.allowsFullSwipe;
|
|
143
|
+
_disabled = next.disabled;
|
|
144
|
+
_needsConfiguration = NO;
|
|
145
|
+
}
|
|
146
|
+
[super updateProps:props oldProps:oldProps];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
|
150
|
+
{
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
155
|
+
{
|
|
156
|
+
return _cell;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath
|
|
160
|
+
{
|
|
161
|
+
return !_disabled && _actions.count > 0 && _rowView != nil;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView
|
|
165
|
+
leadingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
166
|
+
{
|
|
167
|
+
return _leading ? [self swipeConfiguration] : nil;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView
|
|
171
|
+
trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
|
|
172
|
+
{
|
|
173
|
+
return _leading ? nil : [self swipeConfiguration];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
- (UISwipeActionsConfiguration *)swipeConfiguration
|
|
177
|
+
{
|
|
178
|
+
if (_disabled || _actions.count == 0 || _rowView == nil) {
|
|
179
|
+
return nil;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
NSMutableArray<UIContextualAction *> *actions = [NSMutableArray array];
|
|
183
|
+
NSUInteger version = _configurationVersion;
|
|
184
|
+
__weak TinyuiSwipeActionsView *weakSelf = self;
|
|
185
|
+
for (NSDictionary *item in _actions) {
|
|
186
|
+
if (![item isKindOfClass:NSDictionary.class] ||
|
|
187
|
+
![item[@"id"] isKindOfClass:NSString.class] ||
|
|
188
|
+
![item[@"title"] isKindOfClass:NSString.class]) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
NSString *identifier = item[@"id"];
|
|
192
|
+
NSString *title = item[@"title"];
|
|
193
|
+
UIContextualActionStyle style = [item[@"destructive"] boolValue]
|
|
194
|
+
? UIContextualActionStyleDestructive : UIContextualActionStyleNormal;
|
|
195
|
+
UIContextualAction *action = [UIContextualAction contextualActionWithStyle:style
|
|
196
|
+
title:title handler:^(UIContextualAction *selectedAction, UIView *sourceView,
|
|
197
|
+
void (^completionHandler)(BOOL)) {
|
|
198
|
+
TinyuiSwipeActionsView *view = weakSelf;
|
|
199
|
+
if (view == nil || view->_disabled || view.window == nil || view->_rowView == nil ||
|
|
200
|
+
version != view->_configurationVersion) {
|
|
201
|
+
completionHandler(NO);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Finish UIKit's interaction before JS can update or remove the row.
|
|
205
|
+
completionHandler(YES);
|
|
206
|
+
if (view->_eventEmitter != nullptr) {
|
|
207
|
+
std::static_pointer_cast<const TinyuiSwipeActionsViewEventEmitter>(view->_eventEmitter)
|
|
208
|
+
->onActionPress(TinyuiSwipeActionsViewEventEmitter::OnActionPress{
|
|
209
|
+
.id = std::string(identifier.UTF8String ?: ""),
|
|
210
|
+
.title = std::string(title.UTF8String ?: "")});
|
|
211
|
+
}
|
|
212
|
+
}];
|
|
213
|
+
if ([item[@"systemImage"] isKindOfClass:NSString.class]) {
|
|
214
|
+
action.image = [UIImage systemImageNamed:item[@"systemImage"]];
|
|
215
|
+
}
|
|
216
|
+
if (item[@"tintColor"] != nil && item[@"tintColor"] != NSNull.null) {
|
|
217
|
+
action.backgroundColor = [RCTConvert UIColor:item[@"tintColor"]];
|
|
218
|
+
}
|
|
219
|
+
[actions addObject:action];
|
|
220
|
+
}
|
|
221
|
+
UISwipeActionsConfiguration *configuration = [UISwipeActionsConfiguration configurationWithActions:actions];
|
|
222
|
+
configuration.performsFirstActionWithFullSwipe = _allowsFullSwipe;
|
|
223
|
+
return configuration;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
- (void)tableView:(UITableView *)tableView willBeginEditingRowAtIndexPath:(NSIndexPath *)indexPath
|
|
227
|
+
{
|
|
228
|
+
_actionsPresented = YES;
|
|
229
|
+
// Fabric keeps observing touches handled by recognizers inside its surface.
|
|
230
|
+
// Once UIKit starts a swipe, cancel that React sequence so a child/ancestor
|
|
231
|
+
// Pressable cannot also complete an onPress when the finger is lifted.
|
|
232
|
+
for (UIView *view = self; view != nil; view = view.superview) {
|
|
233
|
+
for (UIGestureRecognizer *recognizer in view.gestureRecognizers) {
|
|
234
|
+
if (recognizer.enabled &&
|
|
235
|
+
([recognizer isKindOfClass:RCTSurfaceTouchHandler.class] ||
|
|
236
|
+
[recognizer isKindOfClass:RCTSurfacePointerHandler.class])) {
|
|
237
|
+
recognizer.enabled = NO;
|
|
238
|
+
recognizer.enabled = YES;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (activeSwipeView != self) {
|
|
243
|
+
[activeSwipeView closeActions];
|
|
244
|
+
}
|
|
245
|
+
activeSwipeView = self;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
- (void)tableView:(UITableView *)tableView didEndEditingRowAtIndexPath:(NSIndexPath *)indexPath
|
|
249
|
+
{
|
|
250
|
+
_actionsPresented = NO;
|
|
251
|
+
if (activeSwipeView == self) {
|
|
252
|
+
activeSwipeView = nil;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
- (void)closeActions
|
|
257
|
+
{
|
|
258
|
+
_configurationVersion += 1;
|
|
259
|
+
_actionsPresented = NO;
|
|
260
|
+
[_tableView setEditing:NO animated:NO];
|
|
261
|
+
if (activeSwipeView == self) {
|
|
262
|
+
activeSwipeView = nil;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
- (void)didMoveToWindow
|
|
267
|
+
{
|
|
268
|
+
[super didMoveToWindow];
|
|
269
|
+
if (self.window == nil) {
|
|
270
|
+
[self closeActions];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
- (void)prepareForRecycle
|
|
275
|
+
{
|
|
276
|
+
[self closeActions];
|
|
277
|
+
[_rowView removeFromSuperview];
|
|
278
|
+
_rowView = nil;
|
|
279
|
+
_actions = @[];
|
|
280
|
+
_leading = NO;
|
|
281
|
+
_allowsFullSwipe = YES;
|
|
282
|
+
_disabled = NO;
|
|
283
|
+
_needsConfiguration = YES;
|
|
284
|
+
[super prepareForRecycle];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
@end
|
|
288
|
+
|
|
289
|
+
#endif
|