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.
Files changed (73) hide show
  1. package/README.md +177 -42
  2. package/ios/Core/Tinyui.mm +3 -0
  3. package/ios/Core/TinyuiComponentClasses.mm +18 -0
  4. package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
  5. package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
  6. package/ios/TipKit/TinyuiTip.swift +16 -0
  7. package/ios/TipKit/TinyuiTipPopoverProvider.swift +66 -135
  8. package/ios/TipKit/TinyuiTipPopoverView.mm +0 -18
  9. package/ios/TipKit/TinyuiTipView.h +8 -0
  10. package/ios/TipKit/TinyuiTipView.mm +93 -0
  11. package/ios/TipKit/TinyuiTipViewProvider.swift +140 -0
  12. package/ios/TipKit/TinyuiTipViewShadowNode.h +48 -0
  13. package/ios/TipKit/TinyuiTipsRuntime.swift +2 -74
  14. package/lib/module/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  15. package/lib/module/SwipeActions/actions.js +21 -0
  16. package/lib/module/SwipeActions/actions.js.map +1 -0
  17. package/lib/module/SwipeActions/index.js +43 -0
  18. package/lib/module/SwipeActions/index.js.map +1 -0
  19. package/lib/module/SwipeActions/types.js +4 -0
  20. package/lib/module/SwipeActions/types.js.map +1 -0
  21. package/lib/module/TipKit/Popover.js +0 -22
  22. package/lib/module/TipKit/Popover.js.map +1 -1
  23. package/lib/module/TipKit/TipPopoverNativeComponent.ts +0 -6
  24. package/lib/module/TipKit/TipViewNativeComponent.ts +28 -0
  25. package/lib/module/TipKit/View.js +62 -0
  26. package/lib/module/TipKit/View.js.map +1 -0
  27. package/lib/module/TipKit/index.js +2 -0
  28. package/lib/module/TipKit/index.js.map +1 -1
  29. package/lib/module/TipKit/validation.js +4 -4
  30. package/lib/module/TipKit/validation.js.map +1 -1
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/utils/getNativeTinyui.js.map +1 -1
  34. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts +15 -0
  35. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts.map +1 -0
  36. package/lib/typescript/src/SwipeActions/actions.d.ts +9 -0
  37. package/lib/typescript/src/SwipeActions/actions.d.ts.map +1 -0
  38. package/lib/typescript/src/SwipeActions/index.d.ts +4 -0
  39. package/lib/typescript/src/SwipeActions/index.d.ts.map +1 -0
  40. package/lib/typescript/src/SwipeActions/types.d.ts +37 -0
  41. package/lib/typescript/src/SwipeActions/types.d.ts.map +1 -0
  42. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  43. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +0 -9
  44. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  45. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +29 -0
  46. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -0
  47. package/lib/typescript/src/TipKit/View.d.ts +3 -0
  48. package/lib/typescript/src/TipKit/View.d.ts.map +1 -0
  49. package/lib/typescript/src/TipKit/index.d.ts +2 -1
  50. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  51. package/lib/typescript/src/TipKit/types.d.ts +17 -15
  52. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  53. package/lib/typescript/src/TipKit/validation.d.ts +2 -2
  54. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -1
  55. package/lib/typescript/src/index.d.ts +3 -1
  56. package/lib/typescript/src/index.d.ts.map +1 -1
  57. package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
  58. package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
  59. package/package.json +13 -2
  60. package/react-native-tinyui.podspec +6 -1
  61. package/src/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  62. package/src/SwipeActions/actions.ts +26 -0
  63. package/src/SwipeActions/index.tsx +54 -0
  64. package/src/SwipeActions/types.ts +37 -0
  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 +3 -1
  70. package/src/TipKit/types.ts +19 -20
  71. package/src/TipKit/validation.ts +8 -5
  72. package/src/index.tsx +8 -1
  73. 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={[{ id: 'learn-more', title: 'Learn more' }]}
282
- onActionPress={({ id }) => {
283
- if (id === 'learn-more') openHelp();
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` | Buttons with unique nonempty `{ id, title }` values | `[]` |
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` | 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 | — |
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
- 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
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 to the next presentation; the currently visible bubble
319
- retains the content and action labels it was presented with.
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` and can recover after configuration succeeds.
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
- `onStatusChange` reports eligibility, independently of actual bubble visibility:
355
-
356
- | Status | Meaning |
357
- | --- | --- |
358
- | `pending` | Currently ineligible, such as while waiting for the display frequency interval |
359
- | `available` | Eligible; presentation still requires `enabled`, a visible anchor and a free presenter |
360
- | `invalidated` | Persistently ineligible, with an invalidation `reason`; does not recover just by remounting or restarting |
361
-
362
- Invalidation reasons are `actionPerformed` (explicitly call `invalidate` after
363
- the feature is used), `tipClosed` (the system close button or explicit
364
- invalidation), `displayCountExceeded` (the `maxDisplayCount` limit), and
365
- `displayDurationExceeded` (a native duration limit; this wrapper does not expose
366
- a duration option). `unknown` is a fallback for future native reasons.
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. TinyUI also waits for a visible anchor and a free presenter, and
379
- dismisses on detachment or when the anchor leaves the visible area. It presents
380
- at most one TinyUI tip at a time and waits while another modal occupies the
381
- presenter. An outside dismissal suppresses immediate reopening for that mount;
382
- toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
383
- The system close button can permanently invalidate a tip.
384
-
385
- This first version exposes popover tips and JS eligibility conditions. Inline
386
- `TipUIView`, native rule/event builders and TipGroup are
387
- not exposed. The example's **New demo tip** button deliberately creates another
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.
@@ -12,6 +12,9 @@
12
12
  #if TINYUI_FEATURE_MENU
13
13
  [components addObject:@"Menu"];
14
14
  #endif
15
+ #if TINYUI_FEATURE_SWIPE_ACTIONS
16
+ [components addObject:@"SwipeActions"];
17
+ #endif
15
18
  #if TINYUI_FEATURE_POPOVER
16
19
  [components addObject:@"Popover"];
17
20
  #endif
@@ -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,12 @@
1
+ #if TINYUI_FEATURE_SWIPE_ACTIONS
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface TinyuiSwipeActionsView : RCTViewComponentView
8
+ @end
9
+
10
+ NS_ASSUME_NONNULL_END
11
+
12
+ #endif
@@ -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