react-native-tinyui 3.0.0 → 3.2.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 (69) hide show
  1. package/README.md +136 -6
  2. package/ios/Core/Tinyui.mm +3 -0
  3. package/ios/Core/TinyuiComponentClasses.mm +11 -0
  4. package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
  5. package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
  6. package/ios/TipKit/TinyuiTipPopoverProvider.swift +29 -2
  7. package/ios/TipKit/TinyuiTipPopoverView.mm +10 -0
  8. package/ios/TipKit/TinyuiTipView.mm +9 -0
  9. package/ios/TipKit/TinyuiTipViewProvider.swift +8 -2
  10. package/ios/TipKit/TinyuiTipsRuntime.swift +12 -0
  11. package/lib/module/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  12. package/lib/module/SwipeActions/actions.js +21 -0
  13. package/lib/module/SwipeActions/actions.js.map +1 -0
  14. package/lib/module/SwipeActions/index.js +43 -0
  15. package/lib/module/SwipeActions/index.js.map +1 -0
  16. package/lib/module/SwipeActions/types.js +4 -0
  17. package/lib/module/SwipeActions/types.js.map +1 -0
  18. package/lib/module/TipKit/Group.js +52 -0
  19. package/lib/module/TipKit/Group.js.map +1 -0
  20. package/lib/module/TipKit/Popover.js +15 -1
  21. package/lib/module/TipKit/Popover.js.map +1 -1
  22. package/lib/module/TipKit/TipPopoverNativeComponent.ts +7 -0
  23. package/lib/module/TipKit/TipViewNativeComponent.ts +6 -0
  24. package/lib/module/TipKit/View.js +13 -1
  25. package/lib/module/TipKit/View.js.map +1 -1
  26. package/lib/module/TipKit/index.js +2 -0
  27. package/lib/module/TipKit/index.js.map +1 -1
  28. package/lib/module/index.js +1 -0
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/utils/getNativeTinyui.js.map +1 -1
  31. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts +15 -0
  32. package/lib/typescript/src/SwipeActions/SwipeActionsNativeComponent.d.ts.map +1 -0
  33. package/lib/typescript/src/SwipeActions/actions.d.ts +9 -0
  34. package/lib/typescript/src/SwipeActions/actions.d.ts.map +1 -0
  35. package/lib/typescript/src/SwipeActions/index.d.ts +4 -0
  36. package/lib/typescript/src/SwipeActions/index.d.ts.map +1 -0
  37. package/lib/typescript/src/SwipeActions/types.d.ts +37 -0
  38. package/lib/typescript/src/SwipeActions/types.d.ts.map +1 -0
  39. package/lib/typescript/src/TipKit/Group.d.ts +13 -0
  40. package/lib/typescript/src/TipKit/Group.d.ts.map +1 -0
  41. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
  42. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +5 -0
  43. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
  44. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +4 -0
  45. package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -1
  46. package/lib/typescript/src/TipKit/View.d.ts.map +1 -1
  47. package/lib/typescript/src/TipKit/index.d.ts +3 -1
  48. package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
  49. package/lib/typescript/src/TipKit/types.d.ts +18 -0
  50. package/lib/typescript/src/TipKit/types.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +3 -1
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
  54. package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
  55. package/package.json +10 -2
  56. package/react-native-tinyui.podspec +6 -1
  57. package/src/SwipeActions/SwipeActionsNativeComponent.ts +19 -0
  58. package/src/SwipeActions/actions.ts +26 -0
  59. package/src/SwipeActions/index.tsx +54 -0
  60. package/src/SwipeActions/types.ts +37 -0
  61. package/src/TipKit/Group.tsx +76 -0
  62. package/src/TipKit/Popover.tsx +16 -2
  63. package/src/TipKit/TipPopoverNativeComponent.ts +7 -0
  64. package/src/TipKit/TipViewNativeComponent.ts +6 -0
  65. package/src/TipKit/View.tsx +14 -2
  66. package/src/TipKit/index.ts +4 -0
  67. package/src/TipKit/types.ts +24 -0
  68. package/src/index.tsx +9 -0
  69. 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,13 @@ 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.View`, `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
85
+ - `TipKit` (enables `TipKit.Group`, `TipKit.View`, `TipKit.Popover`, `TipKit.configure`, `TipKit.resetDatastore` and `TipKit.invalidate`)
84
86
 
85
87
  `TipKit` is one optional module containing both native views,
86
88
  `TinyuiTipPopoverView` and `TinyuiTipView`. Select it once in your app's
@@ -107,6 +109,7 @@ Component-specific JavaScript entry points are also available:
107
109
 
108
110
  ```tsx
109
111
  import { Menu } from 'react-native-tinyui/menu';
112
+ import { SwipeActions } from 'react-native-tinyui/swipe-actions';
110
113
  import { Popover } from 'react-native-tinyui/popover';
111
114
  import { LiquidGlassText } from 'react-native-tinyui/liquid-glass-text';
112
115
  import { Stepper } from 'react-native-tinyui/stepper';
@@ -221,6 +224,62 @@ deprecated `titleColor` field is retained only for source compatibility; use
221
224
  `destructive` for system red styling. Submenus support the same label fields
222
225
  plus `destructive`, `disabled`, `hidden`, and `displayInline`.
223
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
+
224
283
  ## Popover
225
284
 
226
285
  <img src="docs/screenshots/popover.png" alt="Popover displaying resizable content above its trigger" width="320" />
@@ -372,8 +431,77 @@ The remaining props are shared by both components:
372
431
  | `ignoresDisplayFrequency` | Exempts this tip from the app-wide display interval | `false` |
373
432
  | `arrowEdge` | `top`, `bottom`, `leading`, `trailing`; Popover also accepts `auto`, View accepts `none` | Popover: `auto`; View: `none` |
374
433
  | `onActionPress` | Receives the selected `{ id, title }`; does not automatically invalidate | — |
434
+ | `onInvalidated` | Receives `{ reason }` when invalidated, including persisted invalidation on mount; Popover waits until dismissal finishes | — |
375
435
  | `onError` | Receives `{ code, message }`; otherwise a warning is logged | — |
376
436
 
437
+ `TipKit.Popover` also accepts `invalidateOnOutsideDismiss`. It defaults to
438
+ `false`, or inherits its Group's value. When enabled, tapping outside the bubble
439
+ invalidates the tip with `tipClosed`. Programmatic hiding, disabling, unmounting
440
+ and leaving the screen do not themselves invalidate it.
441
+
442
+ ### Ordered groups
443
+
444
+ `TipKit.Group` presents tips in the explicit `tipIds` order. It is a React
445
+ coordinator available on **iOS 17+**, backed by native TipKit eligibility and
446
+ persisted invalidation; it does not wrap Apple's iOS 18 `TipGroup` API.
447
+ `priority` currently supports only `ordered` and defaults to it. The Group adds
448
+ no layout view, so its tips can be nested in ordinary layout components.
449
+
450
+ ```tsx
451
+ // At app startup, before mounting tips:
452
+ await TipKit.configure({ displayFrequency: 'immediate' });
453
+
454
+ // In your screen:
455
+ <TipKit.Group
456
+ priority="ordered"
457
+ tipIds={['favorite-v1', 'share-v1']}
458
+ enabled={isScreenFocused}
459
+ invalidateOnOutsideDismiss
460
+ >
461
+ <TipKit.Popover tipId="favorite-v1" title="Save your favorites">
462
+ <Button
463
+ title="Favorite"
464
+ onPress={async () => {
465
+ await TipKit.invalidate('favorite-v1', 'actionPerformed');
466
+ saveFavorite();
467
+ }}
468
+ />
469
+ </TipKit.Popover>
470
+ <TipKit.Popover tipId="share-v1" title="Share with a friend">
471
+ <Button
472
+ title="Share"
473
+ onPress={async () => {
474
+ await TipKit.invalidate('share-v1', 'actionPerformed');
475
+ shareItem();
476
+ }}
477
+ />
478
+ </TipKit.Popover>
479
+ </TipKit.Group>;
480
+ ```
481
+
482
+ - The system close button, `TipKit.invalidate`, or native display limits complete
483
+ a step. The next tip is enabled after the preceding popover has finished
484
+ dismissing. Action buttons still require explicit invalidation.
485
+ - Outside dismissal does **not** complete a step by default. Opt in with
486
+ `invalidateOnOutsideDismiss`, either on the Group or an individual Popover.
487
+ An explicit Popover value overrides the Group's value.
488
+ - Already-invalidated tips are skipped when the group mounts again. Keep IDs
489
+ stable across launches. Progress is driven by native state, not JS storage.
490
+ - The next tip still obeys native eligibility and display frequency. Use
491
+ `immediate` at startup for consecutive tips; TinyUI otherwise defaults to
492
+ `daily`.
493
+ - Use each declared ID for one `TipKit.View` or `TipKit.Popover` in the group.
494
+ A missing, disabled or ineligible current tip blocks later tips. Keep the
495
+ current tip mounted and enabled until it reports invalidation; invalidate it
496
+ instead of immediately disabling it when its feature is used.
497
+ - `enabled={false}` pauses the group without completing a step. Groups are
498
+ independent; this is not a global queue for other tips, sheets or screens.
499
+
500
+ `onInvalidated` reports `actionPerformed`, `tipClosed`, `displayCountExceeded`,
501
+ `displayDurationExceeded`, or `unknown` for an unrecognized native reason.
502
+ It is a state notification, not an outside-dismiss callback, and can fire again
503
+ when an already-invalidated tip resumes observation.
504
+
377
505
  Both components accept standard `ViewProps` and a native view ref. For Popover,
378
506
  `style` lays out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
379
507
  the arrow to available space; leading/trailing follow layout direction. Bubble
@@ -422,8 +550,9 @@ launch will erase users' tip history on every launch.
422
550
  requires configuration first. It persistently invalidates the ID even when no
423
551
  view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
424
552
 
425
- Both native views observe TipKit's `shouldDisplayUpdates` to show or remove
426
- the tip. Eligibility and visibility changes are not sent to JavaScript.
553
+ Both native views observe TipKit's `statusUpdates` to show or remove
554
+ the tip. Invalidation is sent to JavaScript through `onInvalidated`; other
555
+ eligibility and visibility changes are not sent to JavaScript.
427
556
  Call `invalidate` with `actionPerformed` after the feature is used, or with
428
557
  `tipClosed` to permanently dismiss the tip. TipKit also handles the system
429
558
  close button and display limits.
@@ -437,7 +566,7 @@ with the same ID share their TipKit history. Keep `maxDisplayCount` and
437
566
  tip. Hiding a tip, unmounting it or reconfiguring TinyUI never resets history.
438
567
 
439
568
  Use `enabled={isScreenFocused && businessCondition}` with your navigation
440
- library. Each mounted, enabled Popover anchor starts its own `shouldDisplayUpdates`
569
+ library. Each mounted, enabled Popover anchor starts its own `statusUpdates`
441
570
  observation once it has a size and a parent view controller. Disabling,
442
571
  detaching or recycling the component stops observation and dismisses its
443
572
  bubble; changing `tipId` starts a new observation. This follows the independent
@@ -458,7 +587,8 @@ detached or recycled. They do not present a view controller or block interaction
458
587
  with the rest of the screen. Both presentations use the same datastore; an
459
588
  invalidation affects every instance of the same `tipId`.
460
589
 
461
- Native rule/event builders and TipGroup are not exposed. The example's
590
+ Native rule/event builders and Apple's TipGroup are not exposed. Use the
591
+ React `TipKit.Group` above for ordered presentation. The example's
462
592
  **Inline view** switch toggles between both presentations. Its **New demo tip**
463
593
  button deliberately creates another
464
594
  ID for trying the behavior again without clearing the app's TipKit datastore.
@@ -744,7 +874,7 @@ visibility state changes apply instantly. Set it to `false` to opt out.
744
874
 
745
875
  ## Example App
746
876
 
747
- The [example app](example/src/App.tsx) demonstrates Menu, Popover, TipKit,
877
+ The [example app](example/src/App.tsx) demonstrates Menu, SwipeActions, Popover, TipKit,
748
878
  LiquidGlassText, Stepper, ConcentricView, and SFSymbol. It includes all native
749
879
  components, so building it requires Xcode 26+. Use iOS 26+ to try the glass effects
750
880
  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
@@ -68,6 +74,11 @@ Class<RCTComponentViewProtocol> TinyuiMenuViewCls(void)
68
74
  return TinyuiMenuView.class;
69
75
  }
70
76
 
77
+ Class<RCTComponentViewProtocol> TinyuiSwipeActionsViewCls(void)
78
+ {
79
+ return TinyuiSwipeActionsView.class;
80
+ }
81
+
71
82
  Class<RCTComponentViewProtocol> TinyuiPopoverViewCls(void)
72
83
  {
73
84
  return TinyuiPopoverView.class;
@@ -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
@@ -4,6 +4,7 @@ import UIKit
4
4
  @objc public protocol TinyuiTipPopoverDelegate: AnyObject {
5
5
  func onTipAction(tipId: String, id: String, title: String)
6
6
  func onTipError(tipId: String, code: String, message: String)
7
+ func onTipInvalidated(tipId: String, reason: String)
7
8
  }
8
9
 
9
10
  @MainActor
@@ -26,6 +27,8 @@ import UIKit
26
27
  private var presentedTipId: String?
27
28
  private var isPresenting = false
28
29
  private var isDismissing = false
30
+ private var invalidateOnOutsideDismiss = false
31
+ private var pendingInvalidation: (tipId: String, reason: String)?
29
32
 
30
33
  @objc public convenience init(delegate: TinyuiTipPopoverDelegate) {
31
34
  self.init(frame: .zero)
@@ -34,6 +37,7 @@ import UIKit
34
37
 
35
38
  @objc public func configure(_ configuration: [String: Any], enabled: Bool, arrowEdge: String) {
36
39
  let nextTip = TinyuiTip(configuration: configuration)
40
+ if tip?.id != nextTip.id { pendingInvalidation = nil }
37
41
  if tip?.id != nextTip.id || self.enabled != enabled {
38
42
  stop()
39
43
  lastError = nil
@@ -41,6 +45,7 @@ import UIKit
41
45
  tip = nextTip
42
46
  self.enabled = enabled
43
47
  self.arrowEdge = arrowEdge
48
+ invalidateOnOutsideDismiss = configuration["invalidateOnOutsideDismiss"] as? Bool ?? false
44
49
  // Validate changed options even when an observation is already running.
45
50
  do {
46
51
  try TinyuiTipsRuntime.shared.remember(nextTip)
@@ -87,10 +92,16 @@ import UIKit
87
92
  }
88
93
 
89
94
  observation = Task { @MainActor [weak self] in
90
- for await shouldDisplay in tip.shouldDisplayUpdates {
95
+ var reportedInvalidation = false
96
+ for await status in tip.statusUpdates {
91
97
  guard !Task.isCancelled, let self else { break }
92
- self.shouldDisplay = shouldDisplay
98
+ if case .available = status { self.shouldDisplay = true } else { self.shouldDisplay = false }
99
+ if case .invalidated(let reason) = status, !reportedInvalidation {
100
+ reportedInvalidation = true
101
+ self.pendingInvalidation = (tip.id, TinyuiTipsRuntime.invalidationReason(reason))
102
+ }
93
103
  self.updatePresentation()
104
+ self.reportInvalidationIfDismissed()
94
105
  }
95
106
  }
96
107
  }
@@ -178,11 +189,25 @@ import UIKit
178
189
  presentedTipId = nil
179
190
  isPresenting = false
180
191
  isDismissing = false
192
+ reportInvalidationIfDismissed()
193
+ }
194
+
195
+ private func reportInvalidationIfDismissed() {
196
+ // The next grouped anchor must not present until this transition finishes.
197
+ guard controller == nil, let event = pendingInvalidation else { return }
198
+ pendingInvalidation = nil
199
+ delegate?.onTipInvalidated(tipId: event.tipId, reason: event.reason)
181
200
  }
182
201
 
183
202
  public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
184
203
  guard let controller, presentationController.presentedViewController === controller else { return }
204
+ let dismissedTip = presentedTipId == tip?.id ? tip : nil
205
+ let shouldInvalidate = invalidateOnOutsideDismiss && !isDismissing
185
206
  finishDismissal(controller)
207
+ // Programmatic hiding, navigation and recycling do not complete a tip.
208
+ if shouldInvalidate, let dismissedTip, case .available = dismissedTip.status {
209
+ dismissedTip.invalidate(reason: .tipClosed)
210
+ }
186
211
  }
187
212
 
188
213
  public func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { .none }
@@ -199,6 +224,8 @@ import UIKit
199
224
  enabled = false
200
225
  tip = nil
201
226
  lastError = nil
227
+ pendingInvalidation = nil
228
+ invalidateOnOutsideDismiss = false
202
229
  stop()
203
230
  parentViewController = nil
204
231
  }
@@ -65,6 +65,7 @@ using namespace facebook::react;
65
65
  @"actions": actions,
66
66
  @"maxDisplayCount": @(next.maxDisplayCount),
67
67
  @"ignoresDisplayFrequency": @(next.ignoresDisplayFrequency),
68
+ @"invalidateOnOutsideDismiss": @(next.invalidateOnOutsideDismiss),
68
69
  } enabled:next.enabled arrowEdge:RCTNSStringFromString(next.arrowEdge)];
69
70
  [super updateProps:props oldProps:oldProps];
70
71
  }
@@ -87,6 +88,15 @@ using namespace facebook::react;
87
88
  });
88
89
  }
89
90
 
91
+ - (void)onTipInvalidatedWithTipId:(NSString *)tipId reason:(NSString *)reason
92
+ {
93
+ if (_eventEmitter == nullptr) return;
94
+ std::static_pointer_cast<const TinyuiTipPopoverViewEventEmitter>(_eventEmitter)->onTipInvalidated({
95
+ .tipId = std::string(tipId.UTF8String ?: ""),
96
+ .reason = std::string(reason.UTF8String ?: "unknown"),
97
+ });
98
+ }
99
+
90
100
  - (void)prepareForRecycle
91
101
  {
92
102
  [_provider reset];
@@ -83,6 +83,15 @@ using namespace facebook::react;
83
83
  });
84
84
  }
85
85
 
86
+ - (void)onTipInvalidatedWithTipId:(NSString *)tipId reason:(NSString *)reason
87
+ {
88
+ if (_eventEmitter == nullptr) return;
89
+ std::static_pointer_cast<const TinyuiTipViewEventEmitter>(_eventEmitter)->onTipInvalidated({
90
+ .tipId = std::string(tipId.UTF8String ?: ""),
91
+ .reason = std::string(reason.UTF8String ?: "unknown"),
92
+ });
93
+ }
94
+
86
95
  - (void)prepareForRecycle
87
96
  {
88
97
  _state.reset();