Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
576 changes: 576 additions & 0 deletions apps/example/__tests__/nitro.views.children.harness.tsx

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useColors } from './useColors'
import { Image } from 'react-native'
import { ViewScreen } from './screens/ViewScreen'
import { EvalScreen } from './screens/EvalScreen'
import { ChildrenScreen } from './screens/ChildrenScreen'

const dna = require('./img/dna.png')
const map = require('./img/map.png')
Expand Down Expand Up @@ -53,6 +54,20 @@ export default function App() {
),
}}
/>
<Tabs.Screen
name="Children"
component={ChildrenScreen}
options={{
tabBarLabel: 'Children',
tabBarIcon: ({ size, color }) => (
<Image
source={map}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="Eval"
component={EvalScreen}
Expand Down
160 changes: 160 additions & 0 deletions apps/example/src/screens/ChildrenScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as React from 'react'
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
ChildrenContainerTestView,
ChildrenTestView,
TestView,
} from 'react-native-nitro-test'
import { callback } from 'react-native-nitro-modules'
import { useColors } from '../useColors'

function Section({
title,
children,
}: {
title: string
children: React.ReactNode
}): React.ReactElement {
const colors = useColors()
return (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.foreground }]}>
{title}
</Text>
{children}
</View>
)
}

export function ChildrenScreen(): React.ReactElement {
const safeArea = useSafeAreaInsets()
const colors = useColors()
const [items, setItems] = React.useState(['A', 'B'])

return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.content,
{ paddingTop: safeArea.top + 15, paddingBottom: safeArea.bottom + 30 },
]}
>
<Text style={[styles.header, { color: colors.foreground }]}>
Nitro View children
</Text>

<Section title="Children render on top of the native View">
<ChildrenTestView style={styles.box} isBlue={true}>
<Text style={styles.label}>Hello from React</Text>
</ChildrenTestView>
</Section>

<Section title="Yoga lays them out — padding, border, radius">
<ChildrenTestView style={styles.paddedBox} isBlue={true}>
<View style={styles.filler}>
<Text style={styles.label}>flex: 1</Text>
</View>
</ChildrenTestView>
</Section>

<Section title="Nested Nitro Views">
<ChildrenTestView style={styles.paddedBox} isBlue={true}>
<ChildrenTestView style={styles.innerBox} isBlue={false}>
<Text style={styles.label}>inner</Text>
</ChildrenTestView>
<TestView
style={styles.leaf}
isBlue={false}
hasBeenCalled={false}
colorScheme="dark"
someCallback={callback(() => {})}
/>
</ChildrenTestView>
</Section>

<Section title="childrenContainer — children live in a sub-view">
<ChildrenContainerTestView style={styles.box} isBlue={false}>
<Text style={styles.label}>mounted into the container</Text>
</ChildrenContainerTestView>
</Section>

<Section title={`Reconciliation — ${items.length} children`}>
<ChildrenTestView style={styles.listBox} isBlue={true}>
{items.map((item) => (
<View key={item} style={styles.row}>
<Text style={styles.label}>{item}</Text>
</View>
))}
</ChildrenTestView>
<View style={styles.buttons}>
<Button
title="Add"
onPress={() =>
setItems((i) => [...i, String.fromCharCode(65 + i.length)])
}
/>
<Button
title="Remove"
onPress={() => setItems((i) => i.slice(0, -1))}
/>
<Button
title="Reverse"
onPress={() => setItems((i) => [...i].reverse())}
/>
</View>
</Section>
</ScrollView>
)
}

const styles = StyleSheet.create({
container: { flex: 1 },
content: { paddingHorizontal: 15 },
header: { fontSize: 26, fontWeight: 'bold', paddingBottom: 10 },
section: { paddingVertical: 10 },
sectionTitle: { fontSize: 13, fontWeight: '600', paddingBottom: 8 },
box: {
height: 60,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 10,
overflow: 'hidden',
},
paddedBox: {
height: 110,
padding: 12,
borderWidth: 2,
borderColor: 'black',
borderRadius: 16,
overflow: 'hidden',
flexDirection: 'row',
alignItems: 'stretch',
},
innerBox: {
flex: 1,
padding: 10,
borderRadius: 8,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
},
leaf: { width: 40, marginLeft: 10, borderRadius: 8, overflow: 'hidden' },
filler: {
flex: 1,
backgroundColor: 'rgba(255,255,255,0.35)',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
listBox: { padding: 8, borderRadius: 10, overflow: 'hidden' },
row: {
backgroundColor: 'rgba(255,255,255,0.35)',
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 10,
marginBottom: 4,
},
label: { color: 'white', fontWeight: '600' },
buttons: { flexDirection: 'row', gap: 12, paddingTop: 8 },
})
16 changes: 16 additions & 0 deletions docs/docs/concepts/hybrid-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ function App() {

Internally, the `<Camera />` view will create the `HybridCamera` hybrid object - one hybrid object per view.

## Rendering children

A Nitro View is a leaf by default. To let it render React children, declare a `children` prop of
type `HybridViewChildren` in its spec - React Native then mounts the child views into your native
view, and lays them out with Yoga:

```ts title="Card.nitro.ts"
export interface CardProps extends HybridViewProps {
// highlight-next-line
children?: HybridViewChildren
}
export type Card = HybridView<CardProps>
```

See [View Components → Children](../guides/view-components#children) for the native side.

## Accessing the underlying Hybrid Object

To access the actual underlying object, you can use the `hybridRef`:
Expand Down
121 changes: 121 additions & 0 deletions docs/docs/guides/view-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,127 @@ class HybridImageView: HybridImageViewSpec, RecyclableView {
}
```

## Children

By default a Nitro View is a leaf - passing React children to it is a compile error.

To render children inside your View, declare a `children` prop of type `HybridViewChildren` in its spec:

```ts title="Card.nitro.ts"
import type { HybridView, HybridViewProps, HybridViewChildren } from 'react-native-nitro-modules'

export interface CardProps extends HybridViewProps {
// highlight-next-line
children?: HybridViewChildren
isElevated: boolean
}
export type Card = HybridView<CardProps>
```

`children` is only a marker - it is not a Nitro prop, and never crosses the JS ↔ native prop bridge.
React's renderer mounts and unmounts the child views directly, and React Native's layout engine
(Yoga) positions them - exactly like it does for a regular `<View>`.

Now the View can render children:

```jsx
function App() {
return (
<Card isElevated={true} style={{ padding: 20 }}>
<Text>Hello</Text>
</Card>
)
}
```

### Implementing a container View

Children are mounted **into** your native View, so it has to be able to hold them.

<Tabs groupId="native-view-language">
<TabItem value="swift" label="Swift" default>
```swift title="HybridCard.swift"
class HybridCard : HybridCardSpec {
// Children are added as subviews of this UIView
var view: UIView = UIView()
var isElevated: Bool = false
}
```
</TabItem>
<TabItem value="kotlin" label="Kotlin">
```kotlin title="HybridCard.kt"
import com.margelo.nitro.views.NitroViewGroup

class HybridCard(context: ThemedReactContext): HybridCardSpec() {
// Children are added to this ViewGroup
override val view: ViewGroup = NitroViewGroup(context)
override var isElevated: Boolean = false
}
```
</TabItem>
</Tabs>

On **Android**, the generated `HybridCardSpec` narrows `view` to a `ViewGroup` - React Native cannot
mount children into a plain `View`, so a leaf `View` fails to compile instead of crashing at runtime.
Use Nitro's `NitroViewGroup`: React Native positions every child itself, and a `ViewGroup` that lays
out its own children (such as a `LinearLayout`) would fight Fabric and move them to the wrong place.

On **iOS** any `UIView` works - children become its subviews.

:::note
Your native View may keep its own subviews, but add them before any React child is mounted -
React Native addresses children by index. If that's awkward, give the children their own container -
see below.
:::

:::warning
A container View's native view fills the whole component, so React Native's layout for the children
lands in the right place. An opaque native view therefore paints over the component's own
`borderWidth` and `borderRadius` - add `overflow: 'hidden'` to clip it back to the rounded shape, or
draw the border in your native view.

`overflow` itself only reaches the native View on iOS, where React Native turns it into
`clipsToBounds`. On Android it is implemented by React Native's own `ReactViewGroup`, which a Nitro
View is not, so children are never clipped there - clip them in your own `ViewGroup` if you need it.
:::

### Mounting children into a sub-view

Sometimes the children can't live in `view` itself: a `UIVisualEffectView` requires its `contentView`,
a native map wants its markers in an overlay, and a third-party `ViewGroup` may lay out its own
children. Override `childrenContainer` to point React at a different view - it defaults to `view`:

<Tabs groupId="native-view-language">
<TabItem value="swift" label="Swift" default>
```swift title="HybridBlurCard.swift"
class HybridBlurCard : HybridBlurCardSpec {
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))

var view: UIView { blurView }
// highlight-next-line
var childrenContainer: UIView { blurView.contentView }
}
```
</TabItem>
<TabItem value="kotlin" label="Kotlin">
```kotlin title="HybridBlurCard.kt"
class HybridBlurCard(context: ThemedReactContext): HybridBlurCardSpec() {
private val overlay = NitroViewGroup(context)

override val view: ViewGroup = SomeThirdPartyView(context).apply { addView(overlay) }
// diff-add
override val childrenContainer: ViewGroup = overlay
}
```
</TabItem>
</Tabs>

The container has to cover the same area as `view`. React Native positions each child relative to
`view`'s top-left corner, so a container that is offset or smaller moves every child with it - and on
Android a `NitroViewGroup` parent won't lay the container out for you, so size it yourself (in
`onSizeChanged`, for example). Like `view`, `childrenContainer` should not change over the lifetime of
the Hybrid View.

## Methods

Since every `HybridView` is also a `HybridObject`, methods can be directly called on the object.
Expand Down
Loading