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.
- package/README.md +136 -6
- package/ios/Core/Tinyui.mm +3 -0
- package/ios/Core/TinyuiComponentClasses.mm +11 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.h +12 -0
- package/ios/SwipeActions/TinyuiSwipeActionsView.mm +289 -0
- package/ios/TipKit/TinyuiTipPopoverProvider.swift +29 -2
- package/ios/TipKit/TinyuiTipPopoverView.mm +10 -0
- package/ios/TipKit/TinyuiTipView.mm +9 -0
- package/ios/TipKit/TinyuiTipViewProvider.swift +8 -2
- package/ios/TipKit/TinyuiTipsRuntime.swift +12 -0
- 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/Group.js +52 -0
- package/lib/module/TipKit/Group.js.map +1 -0
- package/lib/module/TipKit/Popover.js +15 -1
- package/lib/module/TipKit/Popover.js.map +1 -1
- package/lib/module/TipKit/TipPopoverNativeComponent.ts +7 -0
- package/lib/module/TipKit/TipViewNativeComponent.ts +6 -0
- package/lib/module/TipKit/View.js +13 -1
- package/lib/module/TipKit/View.js.map +1 -1
- package/lib/module/TipKit/index.js +2 -0
- package/lib/module/TipKit/index.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/Group.d.ts +13 -0
- package/lib/typescript/src/TipKit/Group.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +5 -0
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts +4 -0
- package/lib/typescript/src/TipKit/TipViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/View.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/index.d.ts +3 -1
- package/lib/typescript/src/TipKit/index.d.ts.map +1 -1
- package/lib/typescript/src/TipKit/types.d.ts +18 -0
- package/lib/typescript/src/TipKit/types.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 +10 -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/Group.tsx +76 -0
- package/src/TipKit/Popover.tsx +16 -2
- package/src/TipKit/TipPopoverNativeComponent.ts +7 -0
- package/src/TipKit/TipViewNativeComponent.ts +6 -0
- package/src/TipKit/View.tsx +14 -2
- package/src/TipKit/index.ts +4 -0
- package/src/TipKit/types.ts +24 -0
- package/src/index.tsx +9 -0
- 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 `
|
|
426
|
-
the tip.
|
|
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 `
|
|
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.
|
|
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.
|
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
|
|
@@ -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,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
|
-
|
|
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();
|