Skip to content

Commit 1660511

Browse files
committed
feat(react-native): add UIKit adapter APIs
1 parent 24ec34e commit 1660511

20 files changed

Lines changed: 1952 additions & 26 deletions

packages/react-native/README.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ const badgeRef = useRef<UIKitViewRef<UIView>>(null);
112112
await badgeRef.current?.runOnUI((view) => {
113113
view.alpha = 0.8;
114114
});
115+
116+
const measured = await badgeRef.current?.measureNative();
117+
badgeRef.current?.invalidateNativeLayout();
115118
```
116119

117120
React Native view props such as `style`, `testID`, accessibility props, responder
@@ -122,6 +125,118 @@ debug name, so native view descriptions can show `NativeScriptUIView` with your
122125
definition name. It does not dynamically change the registered RN host component
123126
tag.
124127

128+
### Lifecycle and context
129+
130+
`create`, `update`, `mounted`, and `dispose` run through the UIKit path. You do
131+
not need to wrap UIKit work in `runOnUI()` inside those callbacks.
132+
133+
The first argument to `create` is also the current props object, so existing
134+
`create(props)` definitions keep working. New code can use the context helpers:
135+
136+
```tsx
137+
export const NativeSwitch = NativeScript.defineUIKitView<
138+
{value: boolean; onValueChange?: (value: boolean) => void},
139+
UISwitch
140+
>({
141+
name: "NativeSwitch",
142+
layout: {sizing: "intrinsic"},
143+
create(ctx) {
144+
const view = UISwitch.new();
145+
ctx.targetAction(view, UIControlEvents.ValueChanged, () => {
146+
ctx.emit("onValueChange", view.on);
147+
});
148+
return view;
149+
},
150+
update(view, props) {
151+
if (view.on !== props.value) {
152+
view.setOnAnimated(props.value, false);
153+
}
154+
},
155+
});
156+
```
157+
158+
Context helpers cover common native view-manager patterns:
159+
160+
- `ctx.emit(name, payload)` asynchronously calls the matching React prop.
161+
- `ctx.targetAction(control, events, callback)` retains and removes a target/action helper.
162+
- `ctx.delegate(object, protocol, implementation)` creates, assigns, and retains a delegate.
163+
- `ctx.notification(name, object, callback)` observes and removes notifications.
164+
- `ctx.observe(object, keyPath, callback)` observes and removes KVO.
165+
- `ctx.retain(value)` keeps native helper objects alive for the component lifetime.
166+
- `ctx.dispose(callback)` runs cleanup once, in reverse registration order.
167+
- `ctx.invalidateLayout()` schedules a fresh native measurement.
168+
169+
### Layout
170+
171+
React Native owns placement through Yoga. UIKit owns native behavior inside the
172+
placed rectangle. Use `layout.sizing` to opt into native measurement:
173+
174+
- `fill`: fill the RN host bounds.
175+
- `intrinsic`: use `intrinsicContentSize`.
176+
- `sizeThatFits`: use `sizeThatFits` with style constraints.
177+
- `autoLayout`: use `systemLayoutSizeFittingSize`.
178+
179+
Use `defaultSize`, `minSize`, and `maxSize` when a native view can report zero
180+
or needs bounds during the first layout pass.
181+
182+
```tsx
183+
const NativeTitle = NativeScript.defineUIKitView<{text: string}, UILabel>({
184+
name: "NativeTitle",
185+
layout: {
186+
sizing: "intrinsic",
187+
defaultSize: {width: 1, height: 1},
188+
},
189+
create() {
190+
return UILabel.new();
191+
},
192+
update(label, props, _previous, ctx) {
193+
label.text = props.text;
194+
ctx?.invalidateLayout();
195+
},
196+
});
197+
```
198+
199+
### Containers and view controllers
200+
201+
Use `defineUIKitContainer()` when React Native children should mount inside a
202+
UIKit-owned content view:
203+
204+
```tsx
205+
export const BlurCard = NativeScript.defineUIKitContainer({
206+
name: "BlurCard",
207+
create() {
208+
const rootView = UIVisualEffectView.alloc().initWithEffect(
209+
UIBlurEffect.effectWithStyle(UIBlurEffectStyle.SystemMaterial),
210+
);
211+
return {
212+
rootView,
213+
childrenView: rootView.contentView,
214+
};
215+
},
216+
});
217+
218+
<BlurCard style={{padding: 16}}>
219+
<Text>React Native child content</Text>
220+
</BlurCard>;
221+
```
222+
223+
Use `defineUIViewController()` for APIs that require real child view-controller
224+
containment:
225+
226+
```tsx
227+
export const NativePageHost = NativeScript.defineUIViewController({
228+
name: "NativePageHost",
229+
createController() {
230+
return UIViewController.new();
231+
},
232+
update(controller) {
233+
controller.view.backgroundColor = UIColor.systemBackgroundColor;
234+
},
235+
});
236+
```
237+
238+
The package ships example definitions under `@nativescript/react-native/examples`.
239+
125240
The published package includes generated NativeScript metadata, the libffi
126241
xcframework, and generated iOS SDK TypeScript declarations. Build it from the
127242
repository root with:
@@ -239,3 +354,41 @@ Expo development build, EAS Build, or `npx expo run:ios`.
239354

240355
Set `{ "babelPlugin": false }` in the config plugin options if you prefer to add
241356
the Babel plugin manually.
357+
358+
The plugin also writes `nativescript.react-native.json` so metadata options are
359+
visible to native builds. You can pass metadata inputs when the app uses
360+
Objective-C-visible pods or extra system frameworks:
361+
362+
```json
363+
{
364+
"expo": {
365+
"plugins": [
366+
[
367+
"@nativescript/react-native",
368+
{
369+
"metadata": {
370+
"includePods": ["SomeObjCSDK"],
371+
"includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"]
372+
}
373+
}
374+
]
375+
]
376+
}
377+
}
378+
```
379+
380+
## Bare React Native setup helper
381+
382+
The tarball includes a small CLI for bare RN projects:
383+
384+
```sh
385+
npx nativescript-rn configure
386+
npx nativescript-rn generate-metadata --check
387+
cd ios
388+
RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install
389+
```
390+
391+
`configure` adds the bundled Babel plugin when missing, writes
392+
`nativescript.react-native.json`, and warns when the app is not configured for
393+
Hermes and the New Architecture. The command is intentionally conservative and
394+
does not make destructive native project edits.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const {
6+
ensureBabelPlugin,
7+
ensureMetadataConfig,
8+
normalizeMetadataOptions,
9+
} = require('../plugin/withNativeScriptReactNative');
10+
11+
function readJson(filePath) {
12+
if (!fs.existsSync(filePath)) {
13+
return undefined;
14+
}
15+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
16+
}
17+
18+
function projectRoot() {
19+
return process.cwd();
20+
}
21+
22+
function printNextSteps() {
23+
console.log('NativeScript React Native configuration checked.');
24+
console.log('Next steps:');
25+
console.log(' cd ios');
26+
console.log(' RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install');
27+
}
28+
29+
function configure(argv = process.argv.slice(2)) {
30+
const command = argv[0];
31+
if (command === 'generate-metadata') {
32+
require('./generate-metadata').generateMetadata(argv.slice(1));
33+
return;
34+
}
35+
if (command && command !== 'configure') {
36+
console.error(`Unknown command: ${command}`);
37+
process.exitCode = 1;
38+
return;
39+
}
40+
41+
const root = projectRoot();
42+
const packageJson = readJson(path.join(root, 'package.json'));
43+
if (!packageJson) {
44+
console.error('package.json was not found in the current directory.');
45+
process.exitCode = 1;
46+
return;
47+
}
48+
49+
ensureBabelPlugin(root);
50+
ensureMetadataConfig(root, normalizeMetadataOptions(packageJson.nativeScriptReactNative || {}));
51+
52+
const dependencies = {
53+
...(packageJson.dependencies || {}),
54+
...(packageJson.devDependencies || {}),
55+
};
56+
if (!dependencies['@nativescript/react-native']) {
57+
console.warn('Warning: @nativescript/react-native is not listed in package.json dependencies.');
58+
}
59+
60+
const iosPropertiesPath = path.join(root, 'ios', 'Podfile.properties.json');
61+
const iosProperties = readJson(iosPropertiesPath);
62+
if (iosProperties) {
63+
if (iosProperties['expo.jsEngine'] && iosProperties['expo.jsEngine'] !== 'hermes') {
64+
console.warn('Warning: iOS jsEngine is not Hermes.');
65+
}
66+
if (
67+
iosProperties.newArchEnabled !== 'true' &&
68+
iosProperties.newArchEnabled !== true
69+
) {
70+
console.warn('Warning: React Native New Architecture is not enabled for iOS.');
71+
}
72+
}
73+
74+
printNextSteps();
75+
}
76+
77+
if (require.main === module) {
78+
configure();
79+
}
80+
81+
module.exports = {
82+
configure,
83+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
function readConfig(projectRoot) {
7+
const configPath = path.join(projectRoot, 'nativescript.react-native.json');
8+
if (!fs.existsSync(configPath)) {
9+
return {};
10+
}
11+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
12+
}
13+
14+
function generateMetadata(argv = process.argv.slice(2)) {
15+
const root = process.cwd();
16+
const config = readConfig(root);
17+
const metadata = config.reactNative?.metadata || {};
18+
const includePods = metadata.includePods || [];
19+
const includeSystemFrameworks = metadata.includeSystemFrameworks || [];
20+
21+
console.log('NativeScript React Native metadata configuration:');
22+
console.log(` project: ${root}`);
23+
console.log(` pods: ${includePods.length ? includePods.join(', ') : '(none)'}`);
24+
console.log(
25+
` system frameworks: ${
26+
includeSystemFrameworks.length
27+
? includeSystemFrameworks.join(', ')
28+
: 'UIKit, Foundation'
29+
}`,
30+
);
31+
32+
if (argv.includes('--check')) {
33+
return;
34+
}
35+
36+
console.log(
37+
'Metadata is bundled with @nativescript/react-native for the default iOS SDK. ' +
38+
'Custom pod/framework metadata generation will use this configuration during native builds.',
39+
);
40+
}
41+
42+
if (require.main === module) {
43+
generateMetadata();
44+
}
45+
46+
module.exports = {
47+
generateMetadata,
48+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import NativeScript from '@nativescript/react-native';
2+
3+
export const UIKitContainer = NativeScript.defineUIKitContainer<{
4+
backgroundColor?: UIColor;
5+
}>({
6+
name: 'UIKitContainer',
7+
layout: {sizing: 'fill'},
8+
create() {
9+
const rootView = UIView.new();
10+
const childrenView = UIView.new();
11+
childrenView.frame = rootView.bounds;
12+
childrenView.autoresizingMask =
13+
UIViewAutoresizing.FlexibleWidth |
14+
UIViewAutoresizing.FlexibleHeight;
15+
rootView.addSubview(childrenView);
16+
return {rootView, childrenView};
17+
},
18+
update(view, props) {
19+
view.rootView.backgroundColor =
20+
props.backgroundColor ?? UIColor.clearColor;
21+
},
22+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import NativeScript from '@nativescript/react-native';
2+
3+
export const UIKitIntrinsicLabel = NativeScript.defineUIKitView<
4+
{text: string},
5+
UILabel
6+
>({
7+
name: 'UIKitIntrinsicLabel',
8+
layout: {
9+
sizing: 'intrinsic',
10+
defaultSize: {width: 1, height: 1},
11+
},
12+
create() {
13+
return UILabel.new();
14+
},
15+
update(label, props, _previous, ctx) {
16+
label.text = props.text;
17+
ctx?.invalidateLayout();
18+
},
19+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import NativeScript from '@nativescript/react-native';
2+
3+
export const UIKitSwitch = NativeScript.defineUIKitView<
4+
{
5+
value: boolean;
6+
onValueChange?: (value: boolean) => void;
7+
},
8+
UISwitch
9+
>({
10+
name: 'UIKitSwitch',
11+
layout: {sizing: 'intrinsic'},
12+
create(ctx) {
13+
const view = UISwitch.new();
14+
ctx.targetAction(view, UIControlEvents.ValueChanged, () => {
15+
ctx.emit('onValueChange', view.on);
16+
});
17+
return view;
18+
},
19+
update(view, props) {
20+
if (view.on !== props.value) {
21+
view.setOnAnimated(props.value, false);
22+
}
23+
},
24+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import NativeScript from '@nativescript/react-native';
2+
3+
export const UIKitViewControllerHost = NativeScript.defineUIViewController<{
4+
backgroundColor?: UIColor;
5+
}>({
6+
name: 'UIKitViewControllerHost',
7+
layout: {sizing: 'fill'},
8+
createController() {
9+
return UIViewController.new();
10+
},
11+
update(controller, props) {
12+
controller.view.backgroundColor =
13+
props.backgroundColor ?? UIColor.systemBackgroundColor;
14+
},
15+
});

0 commit comments

Comments
 (0)