react-native-tinyui 0.5.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +468 -28
- package/ios/ConcentricView/TinyuiConcentricView.h +12 -0
- package/ios/ConcentricView/TinyuiConcentricView.mm +60 -0
- package/ios/Core/Tinyui.mm +54 -0
- package/ios/Core/TinyuiComponentClasses.mm +44 -0
- package/ios/Menu/TinyuiMenuBuilder.swift +238 -0
- package/ios/Menu/TinyuiMenuProvider.swift +141 -75
- package/ios/Menu/TinyuiMenuView.mm +24 -35
- package/ios/Popover/TinyuiPopoverProvider.swift +198 -44
- package/ios/Popover/TinyuiPopoverView.mm +26 -1
- package/ios/SFSymbol/TinyuiSFSymbolEffects.h +9 -0
- package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +180 -0
- package/ios/SFSymbol/TinyuiSFSymbolImage.h +8 -0
- package/ios/SFSymbol/TinyuiSFSymbolImage.mm +141 -0
- package/ios/SFSymbol/TinyuiSFSymbolShadowNode.h +31 -0
- package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +30 -0
- package/ios/SFSymbol/TinyuiSFSymbolView.h +4 -0
- package/ios/SFSymbol/TinyuiSFSymbolView.mm +173 -0
- package/ios/Stepper/TinyuiStepperShadowNode.h +32 -0
- package/ios/Stepper/TinyuiStepperShadowNode.mm +28 -0
- package/ios/Stepper/TinyuiStepperView.h +12 -0
- package/ios/Stepper/TinyuiStepperView.mm +128 -0
- package/ios/TipKit/TinyuiTip.swift +31 -0
- package/ios/TipKit/TinyuiTipPopoverProvider.swift +276 -0
- package/ios/TipKit/TinyuiTipPopoverView.h +8 -0
- package/ios/TipKit/TinyuiTipPopoverView.mm +114 -0
- package/ios/TipKit/TinyuiTipsRuntime.swift +148 -0
- package/lib/module/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
- package/lib/module/ConcentricView/index.js +29 -0
- package/lib/module/ConcentricView/index.js.map +1 -0
- package/lib/module/ConcentricView/types.js +4 -0
- package/lib/module/ConcentricView/types.js.map +1 -0
- package/lib/module/Menu/MenuNativeComponent.ts +12 -3
- package/lib/module/Menu/index.js +17 -12
- package/lib/module/Menu/index.js.map +1 -1
- package/lib/module/Menu/options.js +4 -12
- package/lib/module/Menu/options.js.map +1 -1
- package/lib/module/NativeTinyui.js.map +1 -1
- package/lib/module/Popover/index.js +4 -2
- package/lib/module/Popover/index.js.map +1 -1
- package/lib/module/SFSymbol/SFSymbolNativeComponent.ts +20 -0
- package/lib/module/SFSymbol/index.js +73 -0
- package/lib/module/SFSymbol/index.js.map +1 -0
- package/lib/module/SFSymbol/options.js +79 -0
- package/lib/module/SFSymbol/options.js.map +1 -0
- package/lib/module/SFSymbol/types.js +4 -0
- package/lib/module/SFSymbol/types.js.map +1 -0
- package/lib/module/Stepper/StepperNativeComponent.ts +29 -0
- package/lib/module/Stepper/index.js +75 -0
- package/lib/module/Stepper/index.js.map +1 -0
- package/lib/module/Stepper/types.js +4 -0
- package/lib/module/Stepper/types.js.map +1 -0
- package/lib/module/TipKit/Popover.js +89 -0
- package/lib/module/TipKit/Popover.js.map +1 -0
- package/lib/module/TipKit/TipPopoverNativeComponent.ts +37 -0
- package/lib/module/TipKit/index.js +28 -0
- package/lib/module/TipKit/index.js.map +1 -0
- package/lib/module/TipKit/types.js +4 -0
- package/lib/module/TipKit/types.js.map +1 -0
- package/lib/module/TipKit/validation.js +24 -0
- package/lib/module/TipKit/validation.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/getNativeTinyui.js.map +1 -1
- package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts +7 -0
- package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/ConcentricView/index.d.ts +4 -0
- package/lib/typescript/src/ConcentricView/index.d.ts.map +1 -0
- package/lib/typescript/src/ConcentricView/types.d.ts +6 -0
- package/lib/typescript/src/ConcentricView/types.d.ts.map +1 -0
- package/lib/typescript/src/Menu/MenuNativeComponent.d.ts +8 -4
- package/lib/typescript/src/Menu/MenuNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/Menu/index.d.ts +1 -1
- package/lib/typescript/src/Menu/index.d.ts.map +1 -1
- package/lib/typescript/src/Menu/options.d.ts +1 -5
- package/lib/typescript/src/Menu/options.d.ts.map +1 -1
- package/lib/typescript/src/Menu/types.d.ts +26 -7
- package/lib/typescript/src/Menu/types.d.ts.map +1 -1
- package/lib/typescript/src/NativeTinyui.d.ts +2 -0
- package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
- package/lib/typescript/src/Popover/index.d.ts.map +1 -1
- package/lib/typescript/src/Popover/types.d.ts +2 -0
- package/lib/typescript/src/Popover/types.d.ts.map +1 -1
- package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts +11 -0
- package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/SFSymbol/index.d.ts +4 -0
- package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -0
- package/lib/typescript/src/SFSymbol/options.d.ts +86 -0
- package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -0
- package/lib/typescript/src/SFSymbol/types.d.ts +113 -0
- package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -0
- package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts +21 -0
- package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/Stepper/index.d.ts +4 -0
- package/lib/typescript/src/Stepper/index.d.ts.map +1 -0
- package/lib/typescript/src/Stepper/types.d.ts +24 -0
- package/lib/typescript/src/Stepper/types.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/Popover.d.ts +3 -0
- package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +38 -0
- package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/index.d.ts +10 -0
- package/lib/typescript/src/TipKit/index.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/types.d.ts +46 -0
- package/lib/typescript/src/TipKit/types.d.ts.map +1 -0
- package/lib/typescript/src/TipKit/validation.d.ts +4 -0
- package/lib/typescript/src/TipKit/validation.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -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 +37 -2
- package/{Tinyui.podspec → react-native-tinyui.podspec} +23 -2
- package/src/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
- package/src/ConcentricView/index.tsx +34 -0
- package/src/ConcentricView/types.ts +6 -0
- package/src/Menu/MenuNativeComponent.ts +12 -3
- package/src/Menu/index.tsx +35 -12
- package/src/Menu/options.ts +2 -20
- package/src/Menu/types.ts +28 -7
- package/src/NativeTinyui.ts +2 -0
- package/src/Popover/index.tsx +8 -3
- package/src/Popover/types.ts +2 -0
- package/src/SFSymbol/SFSymbolNativeComponent.ts +20 -0
- package/src/SFSymbol/index.tsx +89 -0
- package/src/SFSymbol/options.ts +116 -0
- package/src/SFSymbol/types.ts +158 -0
- package/src/Stepper/StepperNativeComponent.ts +29 -0
- package/src/Stepper/index.tsx +91 -0
- package/src/Stepper/types.ts +24 -0
- package/src/TipKit/Popover.tsx +87 -0
- package/src/TipKit/TipPopoverNativeComponent.ts +37 -0
- package/src/TipKit/index.ts +47 -0
- package/src/TipKit/types.ts +57 -0
- package/src/TipKit/validation.ts +39 -0
- package/src/index.tsx +31 -0
- package/src/utils/getNativeTinyui.ts +8 -1
- package/ios/Core/TinyuiHostingHelpers.swift +0 -74
- package/ios/Menu/TinyuiMenuViewSwift.swift +0 -326
- package/ios/Popover/TinyuiPopoverViewSwift.swift +0 -88
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { assertComponentEnabled, getNativeTinyui } from '../utils';
|
|
2
|
+
import type { TipInvalidationReason, TipKitConfiguration } from './types';
|
|
3
|
+
import { validateTipId } from './validation';
|
|
4
|
+
import { Popover } from './Popover';
|
|
5
|
+
|
|
6
|
+
export const TipKit = {
|
|
7
|
+
Popover,
|
|
8
|
+
/** Configure TipKit once before mounting tips. Repeating the same options is safe. */
|
|
9
|
+
async configure({
|
|
10
|
+
displayFrequency = 'daily',
|
|
11
|
+
}: TipKitConfiguration = {}): Promise<void> {
|
|
12
|
+
assertComponentEnabled('TipKit');
|
|
13
|
+
if (
|
|
14
|
+
!['immediate', 'hourly', 'daily', 'weekly', 'monthly'].includes(
|
|
15
|
+
displayFrequency
|
|
16
|
+
)
|
|
17
|
+
) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'[TinyUI] TipKit.configure received an invalid displayFrequency.'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
await getNativeTinyui().configureTips(displayFrequency);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/** Persistently invalidate a tip, including when its view is not mounted. */
|
|
26
|
+
async invalidate(
|
|
27
|
+
tipId: string,
|
|
28
|
+
reason: TipInvalidationReason = 'actionPerformed'
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
assertComponentEnabled('TipKit');
|
|
31
|
+
validateTipId(tipId);
|
|
32
|
+
if (reason !== 'actionPerformed' && reason !== 'tipClosed') {
|
|
33
|
+
throw new Error('[TinyUI] TipKit.invalidate received an invalid reason.');
|
|
34
|
+
}
|
|
35
|
+
await getNativeTinyui().invalidateTip(tipId, reason);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type {
|
|
40
|
+
TipAction,
|
|
41
|
+
TipDisplayFrequency,
|
|
42
|
+
TipError,
|
|
43
|
+
TipInvalidationReason,
|
|
44
|
+
TipKitConfiguration,
|
|
45
|
+
TipPopoverProps,
|
|
46
|
+
TipStatus,
|
|
47
|
+
} from './types';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ViewProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export type TipDisplayFrequency =
|
|
5
|
+
'immediate' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
|
6
|
+
|
|
7
|
+
export interface TipKitConfiguration {
|
|
8
|
+
/** App-wide interval between new tips. Defaults to `daily`. */
|
|
9
|
+
displayFrequency?: TipDisplayFrequency;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TipInvalidationReason = 'actionPerformed' | 'tipClosed';
|
|
13
|
+
|
|
14
|
+
export type TipStatus =
|
|
15
|
+
| { status: 'pending' | 'available' }
|
|
16
|
+
| {
|
|
17
|
+
status: 'invalidated';
|
|
18
|
+
reason:
|
|
19
|
+
| TipInvalidationReason
|
|
20
|
+
| 'displayCountExceeded'
|
|
21
|
+
| 'displayDurationExceeded'
|
|
22
|
+
| 'unknown';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface TipAction {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TipError {
|
|
31
|
+
code: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TipPopoverProps extends Omit<ViewProps, 'children'> {
|
|
36
|
+
/** Anchor content. Its existing touch handlers are preserved. */
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
/** Stable identity used by TipKit to persist this tip's history. */
|
|
39
|
+
tipId: string;
|
|
40
|
+
title: string;
|
|
41
|
+
message?: string;
|
|
42
|
+
systemImage?: string;
|
|
43
|
+
actions?: readonly TipAction[];
|
|
44
|
+
/** Allows presentation when TipKit is also eligible. Defaults to true. */
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
/** Omit to leave the number of presentations unlimited. */
|
|
47
|
+
maxDisplayCount?: number;
|
|
48
|
+
ignoresDisplayFrequency?: boolean;
|
|
49
|
+
/** Preferred arrow edge on the bubble; UIKit may adapt to available space. */
|
|
50
|
+
arrowEdge?: 'top' | 'bottom' | 'leading' | 'trailing' | 'auto';
|
|
51
|
+
/** TipKit eligibility, independent of whether the bubble is on screen. */
|
|
52
|
+
onStatusChange?: (status: TipStatus) => void;
|
|
53
|
+
onVisibleChange?: (visible: boolean) => void;
|
|
54
|
+
/** Actions do not automatically invalidate the tip. */
|
|
55
|
+
onActionPress?: (action: TipAction) => void;
|
|
56
|
+
onError?: (error: TipError) => void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TipPopoverProps } from './types';
|
|
2
|
+
|
|
3
|
+
export function validateTipId(tipId: string): void {
|
|
4
|
+
if (typeof tipId !== 'string' || tipId.trim().length === 0) {
|
|
5
|
+
throw new Error('[TinyUI] TipKit tipId must be a nonempty string.');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateTip(props: TipPopoverProps): void {
|
|
10
|
+
validateTipId(props.tipId);
|
|
11
|
+
if (typeof props.title !== 'string' || props.title.trim().length === 0) {
|
|
12
|
+
throw new Error('[TinyUI] TipKit.Popover title must be a nonempty string.');
|
|
13
|
+
}
|
|
14
|
+
if (
|
|
15
|
+
props.maxDisplayCount !== undefined &&
|
|
16
|
+
(!Number.isInteger(props.maxDisplayCount) ||
|
|
17
|
+
props.maxDisplayCount < 1 ||
|
|
18
|
+
props.maxDisplayCount > 2147483647)
|
|
19
|
+
) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'[TinyUI] TipKit.Popover maxDisplayCount must be a positive 32-bit integer.'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const ids = new Set<string>();
|
|
25
|
+
for (const action of props.actions ?? []) {
|
|
26
|
+
if (
|
|
27
|
+
typeof action.id !== 'string' ||
|
|
28
|
+
action.id.trim().length === 0 ||
|
|
29
|
+
typeof action.title !== 'string' ||
|
|
30
|
+
action.title.trim().length === 0 ||
|
|
31
|
+
ids.has(action.id)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'[TinyUI] TipKit.Popover actions need unique nonempty ids and nonempty titles.'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
ids.add(action.id);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
export { Menu } from './Menu';
|
|
2
|
+
export { TipKit } from './TipKit';
|
|
3
|
+
export type {
|
|
4
|
+
TipAction,
|
|
5
|
+
TipDisplayFrequency,
|
|
6
|
+
TipError,
|
|
7
|
+
TipInvalidationReason,
|
|
8
|
+
TipPopoverProps,
|
|
9
|
+
TipKitConfiguration,
|
|
10
|
+
TipStatus,
|
|
11
|
+
} from './TipKit';
|
|
2
12
|
export type {
|
|
3
13
|
MenuActionOption,
|
|
14
|
+
MenuActionPressEvent,
|
|
15
|
+
MenuActionPressEventData,
|
|
4
16
|
MenuDividerOption,
|
|
5
17
|
MenuItemState,
|
|
6
18
|
MenuOption,
|
|
7
19
|
MenuProps,
|
|
20
|
+
MenuRef,
|
|
8
21
|
MenuSectionOption,
|
|
9
22
|
MenuSubmenuOption,
|
|
10
23
|
} from './Menu';
|
|
11
24
|
|
|
25
|
+
export { Stepper } from './Stepper';
|
|
26
|
+
export type { StepperProps } from './Stepper';
|
|
27
|
+
|
|
28
|
+
export { ConcentricView } from './ConcentricView';
|
|
29
|
+
export type { ConcentricViewProps } from './ConcentricView';
|
|
30
|
+
|
|
31
|
+
export { SFSymbol } from './SFSymbol';
|
|
32
|
+
export type {
|
|
33
|
+
SFSymbolContentTransition,
|
|
34
|
+
SFSymbolEffect,
|
|
35
|
+
SFSymbolEffectOptions,
|
|
36
|
+
SFSymbolProps,
|
|
37
|
+
SFSymbolRenderingMode,
|
|
38
|
+
SFSymbolScale,
|
|
39
|
+
SFSymbolTextStyle,
|
|
40
|
+
SFSymbolWeight,
|
|
41
|
+
} from './SFSymbol';
|
|
42
|
+
|
|
12
43
|
export { Popover, PopoverClose } from './Popover';
|
|
13
44
|
export type {
|
|
14
45
|
PopoverArrowEdge,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import NativeTinyui, { type Spec } from '../NativeTinyui';
|
|
2
2
|
|
|
3
|
-
export type TinyuiComponent =
|
|
3
|
+
export type TinyuiComponent =
|
|
4
|
+
| 'Menu'
|
|
5
|
+
| 'Popover'
|
|
6
|
+
| 'LiquidGlassText'
|
|
7
|
+
| 'Stepper'
|
|
8
|
+
| 'ConcentricView'
|
|
9
|
+
| 'SFSymbol'
|
|
10
|
+
| 'TipKit';
|
|
4
11
|
|
|
5
12
|
let nativeTinyui: Spec | null | undefined;
|
|
6
13
|
let enabledComponents: ReadonlySet<string> | undefined;
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import SwiftUI
|
|
2
|
-
import UIKit
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Identifiable wrapper used to render React Native views (UIView) inside
|
|
6
|
-
* SwiftUI. Modeled after react-native-pager-view's bridge.
|
|
7
|
-
*/
|
|
8
|
-
struct IdentifiablePlatformView: Identifiable, Equatable {
|
|
9
|
-
let id = UUID()
|
|
10
|
-
let view: UIView
|
|
11
|
-
|
|
12
|
-
init(_ view: UIView) {
|
|
13
|
-
self.view = view
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Renders a UIView inside SwiftUI. Uses UIViewControllerRepresentable so the
|
|
19
|
-
* hosted React view keeps a valid view-controller hierarchy.
|
|
20
|
-
*/
|
|
21
|
-
struct RepresentableView: UIViewControllerRepresentable {
|
|
22
|
-
var view: UIView
|
|
23
|
-
|
|
24
|
-
func makeUIViewController(context: Context) -> PlatformChildViewController {
|
|
25
|
-
let viewController = PlatformChildViewController()
|
|
26
|
-
viewController.wrappedView = view
|
|
27
|
-
return viewController
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
func updateUIViewController(
|
|
31
|
-
_ uiViewController: PlatformChildViewController,
|
|
32
|
-
context: Context
|
|
33
|
-
) {
|
|
34
|
-
if uiViewController.wrappedView !== view {
|
|
35
|
-
uiViewController.wrappedView = view
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
class PlatformChildViewController: UIViewController {
|
|
41
|
-
var wrappedView: UIView? {
|
|
42
|
-
didSet {
|
|
43
|
-
guard oldValue !== wrappedView else { return }
|
|
44
|
-
oldValue?.removeFromSuperview()
|
|
45
|
-
if isViewLoaded {
|
|
46
|
-
mountWrappedView()
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
override func viewDidLoad() {
|
|
52
|
-
super.viewDidLoad()
|
|
53
|
-
view.backgroundColor = .clear
|
|
54
|
-
mountWrappedView()
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
private func mountWrappedView() {
|
|
58
|
-
guard let wrappedView else { return }
|
|
59
|
-
view.addSubview(wrappedView)
|
|
60
|
-
wrappedView.translatesAutoresizingMaskIntoConstraints = false
|
|
61
|
-
wrappedView.pinEdges(to: view)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
extension UIView {
|
|
66
|
-
func pinEdges(to other: UIView) {
|
|
67
|
-
NSLayoutConstraint.activate([
|
|
68
|
-
leadingAnchor.constraint(equalTo: other.leadingAnchor),
|
|
69
|
-
trailingAnchor.constraint(equalTo: other.trailingAnchor),
|
|
70
|
-
topAnchor.constraint(equalTo: other.topAnchor),
|
|
71
|
-
bottomAnchor.constraint(equalTo: other.bottomAnchor),
|
|
72
|
-
])
|
|
73
|
-
}
|
|
74
|
-
}
|
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import SwiftUI
|
|
3
|
-
import UIKit
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Observable props shared between the Objective-C++ component view and the
|
|
7
|
-
* SwiftUI `Menu` implementation. Mirror of `TinyuiMenuProps` in codegen.
|
|
8
|
-
*/
|
|
9
|
-
class TinyuiMenuProps: ObservableObject {
|
|
10
|
-
@Published var menuConfig: [String: Any] = [:]
|
|
11
|
-
@Published var title: String = ""
|
|
12
|
-
@Published var disabled: Bool = false
|
|
13
|
-
@Published var hasPrimaryAction: Bool = false
|
|
14
|
-
@Published var triggerView: UIView?
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* SwiftUI `Menu` backed by UIKit's UIMenu, matching expo-ui's MenuView.
|
|
19
|
-
*
|
|
20
|
-
* The React Native trigger (a UIView) is embedded as the menu label through
|
|
21
|
-
* `RepresentableView`, so the tap target remains the native RN subtree while
|
|
22
|
-
* the menu content is built natively from the configuration object.
|
|
23
|
-
*
|
|
24
|
-
* Named distinctly from the Fabric component view `TinyuiMenuView`.
|
|
25
|
-
*/
|
|
26
|
-
struct TinyuiMenuSwiftUIView: View {
|
|
27
|
-
@ObservedObject var props: TinyuiMenuProps
|
|
28
|
-
weak var delegate: TinyuiMenuViewDelegate?
|
|
29
|
-
|
|
30
|
-
var body: some View {
|
|
31
|
-
Group {
|
|
32
|
-
if let triggerView = props.triggerView {
|
|
33
|
-
if props.hasPrimaryAction {
|
|
34
|
-
Menu {
|
|
35
|
-
menuContent
|
|
36
|
-
} label: {
|
|
37
|
-
RepresentableView(view: triggerView)
|
|
38
|
-
} primaryAction: {
|
|
39
|
-
delegate?.onPrimaryAction()
|
|
40
|
-
}
|
|
41
|
-
.disabled(props.disabled)
|
|
42
|
-
} else {
|
|
43
|
-
Menu {
|
|
44
|
-
menuContent
|
|
45
|
-
} label: {
|
|
46
|
-
RepresentableView(view: triggerView)
|
|
47
|
-
}
|
|
48
|
-
.disabled(props.disabled)
|
|
49
|
-
}
|
|
50
|
-
} else {
|
|
51
|
-
// No trigger mounted yet; render an empty view until the RN child
|
|
52
|
-
// is attached to the provider.
|
|
53
|
-
Color.clear
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
@ViewBuilder
|
|
59
|
-
private var menuContent: some View {
|
|
60
|
-
if props.title.isEmpty {
|
|
61
|
-
elementsContent
|
|
62
|
-
} else {
|
|
63
|
-
Section {
|
|
64
|
-
elementsContent
|
|
65
|
-
} header: {
|
|
66
|
-
Text(props.title)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
@ViewBuilder
|
|
72
|
-
private var elementsContent: some View {
|
|
73
|
-
ForEach(buildElements(from: props.menuConfig)) { element in
|
|
74
|
-
element.buildView(delegate: delegate)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/// Converts the menu config into a list of SwiftUI element builders.
|
|
79
|
-
private func buildElements(from config: [String: Any]) -> [TinyuiMenuElement] {
|
|
80
|
-
guard let items = config["items"] as? [[String: Any]] else {
|
|
81
|
-
return []
|
|
82
|
-
}
|
|
83
|
-
return items.map { TinyuiMenuElement(dictionary: $0) }
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Lightweight representation of a single UIMenu element read from the config.
|
|
89
|
-
* Used to build SwiftUI menu content without resolving all elements
|
|
90
|
-
* eagerly. Returns `AnyView` to avoid recursive opaque return type errors.
|
|
91
|
-
*/
|
|
92
|
-
struct TinyuiMenuElement: Identifiable {
|
|
93
|
-
let id = UUID()
|
|
94
|
-
private let dictionary: [String: Any]
|
|
95
|
-
|
|
96
|
-
init(dictionary: [String: Any]) {
|
|
97
|
-
self.dictionary = dictionary
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
func buildView(
|
|
101
|
-
delegate: TinyuiMenuViewDelegate?,
|
|
102
|
-
inheritedDestructive: Bool = false
|
|
103
|
-
) -> AnyView {
|
|
104
|
-
if dictionary["hidden"] as? Bool ?? false {
|
|
105
|
-
return AnyView(EmptyView())
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
switch dictionary["type"] as? String {
|
|
109
|
-
case "action":
|
|
110
|
-
let destructive = inheritedDestructive || (dictionary["destructive"] as? Bool ?? false)
|
|
111
|
-
let keepOpen = dictionary["keepOpen"] as? Bool ?? false
|
|
112
|
-
let id = dictionary["id"] as? String ?? ""
|
|
113
|
-
let state = dictionary["state"] as? String ?? "off"
|
|
114
|
-
|
|
115
|
-
// `on`/`off` use SwiftUI's native `Toggle` support inside `Menu`,
|
|
116
|
-
// which renders the system checkmark. `mixed` has no native SwiftUI
|
|
117
|
-
// equivalent, so it falls back to the minus indicator.
|
|
118
|
-
if state == "on" || state == "off" {
|
|
119
|
-
let isOn = Binding<Bool>(
|
|
120
|
-
get: { state == "on" },
|
|
121
|
-
set: { _ in delegate?.onItemPress(id: id) }
|
|
122
|
-
)
|
|
123
|
-
return AnyView(
|
|
124
|
-
Toggle(isOn: isOn) {
|
|
125
|
-
TinyuiMenuLabel(
|
|
126
|
-
dictionary: dictionary,
|
|
127
|
-
destructive: destructive,
|
|
128
|
-
showsState: false
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
.disabled(dictionary["disabled"] as? Bool ?? false)
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return AnyView(
|
|
136
|
-
Button(role: destructive ? .destructive : nil) {
|
|
137
|
-
delegate?.onItemPress(id: id)
|
|
138
|
-
} label: {
|
|
139
|
-
TinyuiMenuLabel(
|
|
140
|
-
dictionary: dictionary,
|
|
141
|
-
destructive: destructive,
|
|
142
|
-
showsState: true
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
.disabled(dictionary["disabled"] as? Bool ?? false)
|
|
146
|
-
.menuActionDismissBehavior(keepOpen ? .disabled : .automatic)
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
case "submenu":
|
|
150
|
-
let children = (dictionary["children"] as? [[String: Any]] ?? [])
|
|
151
|
-
.map { TinyuiMenuElement(dictionary: $0) }
|
|
152
|
-
let destructive = inheritedDestructive || (dictionary["destructive"] as? Bool ?? false)
|
|
153
|
-
let disabled = dictionary["disabled"] as? Bool ?? false
|
|
154
|
-
|
|
155
|
-
if dictionary["displayInline"] as? Bool ?? false {
|
|
156
|
-
return AnyView(
|
|
157
|
-
Section {
|
|
158
|
-
ForEach(children) { child in
|
|
159
|
-
child.buildView(
|
|
160
|
-
delegate: delegate,
|
|
161
|
-
inheritedDestructive: destructive
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
} header: {
|
|
165
|
-
TinyuiMenuLabel(
|
|
166
|
-
dictionary: dictionary,
|
|
167
|
-
destructive: destructive,
|
|
168
|
-
showsState: false
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
.disabled(disabled)
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return AnyView(
|
|
176
|
-
Menu {
|
|
177
|
-
ForEach(children) { child in
|
|
178
|
-
child.buildView(
|
|
179
|
-
delegate: delegate,
|
|
180
|
-
inheritedDestructive: destructive
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
} label: {
|
|
184
|
-
TinyuiMenuLabel(
|
|
185
|
-
dictionary: dictionary,
|
|
186
|
-
destructive: destructive,
|
|
187
|
-
showsState: false
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
.disabled(disabled)
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
case "section":
|
|
194
|
-
let children = (dictionary["children"] as? [[String: Any]] ?? [])
|
|
195
|
-
.map { TinyuiMenuElement(dictionary: $0) }
|
|
196
|
-
return AnyView(
|
|
197
|
-
Section {
|
|
198
|
-
ForEach(children) { child in
|
|
199
|
-
child.buildView(
|
|
200
|
-
delegate: delegate,
|
|
201
|
-
inheritedDestructive: inheritedDestructive
|
|
202
|
-
)
|
|
203
|
-
}
|
|
204
|
-
} header: {
|
|
205
|
-
if let title = dictionary["title"] as? String, !title.isEmpty {
|
|
206
|
-
Text(title)
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
case "separator":
|
|
212
|
-
return AnyView(Divider())
|
|
213
|
-
|
|
214
|
-
default:
|
|
215
|
-
return AnyView(EmptyView())
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Label content shared by actions and submenus. */
|
|
221
|
-
private struct TinyuiMenuLabel: View {
|
|
222
|
-
let dictionary: [String: Any]
|
|
223
|
-
let destructive: Bool
|
|
224
|
-
let showsState: Bool
|
|
225
|
-
|
|
226
|
-
@ViewBuilder
|
|
227
|
-
var body: some View {
|
|
228
|
-
// Menu controls interpret the first text as the title and the second as
|
|
229
|
-
// the subtitle. When an icon is present, `Label` replaces the first Text,
|
|
230
|
-
// matching SwiftUI's native menu-item representation.
|
|
231
|
-
if hasIcon {
|
|
232
|
-
Label {
|
|
233
|
-
title
|
|
234
|
-
} icon: {
|
|
235
|
-
menuIcon
|
|
236
|
-
}
|
|
237
|
-
} else {
|
|
238
|
-
title
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if let subtitle {
|
|
242
|
-
Text(subtitle)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if showsState {
|
|
246
|
-
stateIndicator
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
private var subtitle: String? {
|
|
251
|
-
(dictionary["subtitle"] as? String).flatMap {
|
|
252
|
-
$0.isEmpty ? nil : $0
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private var hasIcon: Bool {
|
|
257
|
-
if let icon = dictionary["icon"] as? String, !icon.isEmpty {
|
|
258
|
-
return UIImage(named: icon) != nil
|
|
259
|
-
}
|
|
260
|
-
return !(dictionary["systemImage"] as? String ?? "").isEmpty
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
private var stateIndicator: some View {
|
|
264
|
-
// Only `mixed` reaches here; `on`/`off` are handled natively by `Toggle`.
|
|
265
|
-
// UIKit's menu system renders "mixed" state with a minus/dash mark.
|
|
266
|
-
if dictionary["state"] as? String == "mixed" {
|
|
267
|
-
return AnyView(
|
|
268
|
-
Image(systemName: "minus")
|
|
269
|
-
.font(.system(size: 13, weight: .semibold))
|
|
270
|
-
)
|
|
271
|
-
}
|
|
272
|
-
return AnyView(EmptyView())
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
private var title: Text {
|
|
276
|
-
let text = Text(dictionary["title"] as? String ?? "")
|
|
277
|
-
|
|
278
|
-
if destructive {
|
|
279
|
-
return text.foregroundColor(.red)
|
|
280
|
-
} else if let color = TinyuiMenuColor.uiColor(from: dictionary["titleColor"]) {
|
|
281
|
-
return text.foregroundColor(Color(uiColor: color))
|
|
282
|
-
} else {
|
|
283
|
-
return text
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
@ViewBuilder
|
|
288
|
-
private var menuIcon: some View {
|
|
289
|
-
let iconColor = TinyuiMenuColor.uiColor(from: dictionary["iconColor"])
|
|
290
|
-
|
|
291
|
-
if let icon = dictionary["icon"] as? String, !icon.isEmpty {
|
|
292
|
-
if let image = UIImage(named: icon) {
|
|
293
|
-
if let iconColor {
|
|
294
|
-
Image(
|
|
295
|
-
uiImage: image.withTintColor(
|
|
296
|
-
iconColor,
|
|
297
|
-
renderingMode: .alwaysOriginal
|
|
298
|
-
)
|
|
299
|
-
)
|
|
300
|
-
} else {
|
|
301
|
-
Image(uiImage: image.withRenderingMode(.alwaysTemplate))
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} else if let systemImage = dictionary["systemImage"] as? String,
|
|
305
|
-
!systemImage.isEmpty {
|
|
306
|
-
if let iconColor {
|
|
307
|
-
Image(systemName: systemImage)
|
|
308
|
-
.foregroundStyle(Color(uiColor: iconColor))
|
|
309
|
-
} else {
|
|
310
|
-
Image(systemName: systemImage)
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Converts React Native's processed ColorValue JSON into a dynamic UIColor. */
|
|
317
|
-
private enum TinyuiMenuColor {
|
|
318
|
-
static func uiColor(from value: Any?) -> UIColor? {
|
|
319
|
-
value.flatMap(RCTConvert.uiColor)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
@objc public protocol TinyuiMenuViewDelegate {
|
|
324
|
-
func onItemPress(id: String)
|
|
325
|
-
func onPrimaryAction()
|
|
326
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import SwiftUI
|
|
2
|
-
import UIKit
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Observable props shared between the Objective-C++ component view and the
|
|
6
|
-
* SwiftUI `Popover` implementation. Mirror of `TinyuiPopoverProps` in codegen.
|
|
7
|
-
*/
|
|
8
|
-
class TinyuiPopoverProps: ObservableObject {
|
|
9
|
-
@Published var isPresented: Bool = false
|
|
10
|
-
@Published var attachmentAnchor: String = "center"
|
|
11
|
-
@Published var arrowEdge: String = "none"
|
|
12
|
-
@Published var triggerView: UIView?
|
|
13
|
-
@Published var contentView: UIView?
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* SwiftUI `popover` backed by UIPopoverPresentationController, matching
|
|
18
|
-
* expo-ui's PopoverView. The RN trigger is embedded as the anchor view and the
|
|
19
|
-
* RN content view is presented inside the popover.
|
|
20
|
-
*
|
|
21
|
-
* Named distinctly from the Fabric component view `TinyuiPopoverView`.
|
|
22
|
-
*/
|
|
23
|
-
struct TinyuiPopoverSwiftUIView: View {
|
|
24
|
-
@ObservedObject var props: TinyuiPopoverProps
|
|
25
|
-
weak var delegate: TinyuiPopoverViewDelegate?
|
|
26
|
-
|
|
27
|
-
@State private var isPresented: Bool = false
|
|
28
|
-
|
|
29
|
-
var body: some View {
|
|
30
|
-
if let triggerView = props.triggerView {
|
|
31
|
-
RepresentableView(view: triggerView)
|
|
32
|
-
.popover(
|
|
33
|
-
isPresented: $isPresented,
|
|
34
|
-
attachmentAnchor: attachmentAnchorValue,
|
|
35
|
-
arrowEdge: arrowEdgeValue
|
|
36
|
-
) {
|
|
37
|
-
if #available(iOS 16.4, *) {
|
|
38
|
-
popoverContent
|
|
39
|
-
.presentationCompactAdaptation(.popover)
|
|
40
|
-
} else {
|
|
41
|
-
popoverContent
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
.onChange(of: isPresented) { newValue in
|
|
45
|
-
delegate?.onIsPresentedChange(isPresented: newValue)
|
|
46
|
-
}
|
|
47
|
-
.onChange(of: props.isPresented) { newValue in
|
|
48
|
-
isPresented = newValue
|
|
49
|
-
}
|
|
50
|
-
.onAppear {
|
|
51
|
-
isPresented = props.isPresented
|
|
52
|
-
}
|
|
53
|
-
} else {
|
|
54
|
-
Color.clear
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
private var attachmentAnchorValue: PopoverAttachmentAnchor {
|
|
59
|
-
switch props.attachmentAnchor {
|
|
60
|
-
case "top": return .point(.top)
|
|
61
|
-
case "bottom": return .point(.bottom)
|
|
62
|
-
case "leading": return .point(.leading)
|
|
63
|
-
case "trailing": return .point(.trailing)
|
|
64
|
-
default: return .point(.center)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private var arrowEdgeValue: Edge? {
|
|
69
|
-
switch props.arrowEdge {
|
|
70
|
-
case "top": return .top
|
|
71
|
-
case "bottom": return .bottom
|
|
72
|
-
case "leading": return .leading
|
|
73
|
-
case "trailing": return .trailing
|
|
74
|
-
default: return nil
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
@ViewBuilder
|
|
79
|
-
private var popoverContent: some View {
|
|
80
|
-
if let contentView = props.contentView {
|
|
81
|
-
RepresentableView(view: contentView)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
@objc public protocol TinyuiPopoverViewDelegate {
|
|
87
|
-
func onIsPresentedChange(isPresented: Bool)
|
|
88
|
-
}
|