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
64 changes: 64 additions & 0 deletions docs/docs/guides/view-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,70 @@ function App() {
}
```

## Self-measurement (`MeasurableView`)

By default a `HybridView` is sized by its children or by explicit style props. To instead size a view to its **own content** - a `<Text>` that grows to fit its string, a chart that derives a height from its data - conform your native implementation to `MeasurableView`. Nitro then makes the view a measurable Yoga **leaf**: Yoga calls your `measureContent` during layout and uses the returned `Size` as the intrinsic size, so no explicit `height` is needed. Like [`RecyclableView`](#recycling) it's a native opt-in, so **your `.nitro.ts` spec is unchanged**:

```ts title="NitroTextView.nitro.ts"
export interface NitroTextViewProps extends HybridViewProps {
text: string
fontSize: number
}
export type NitroTextView = HybridView<NitroTextViewProps>
```

<Tabs groupId="native-view-language">
<TabItem value="swift" label="Swift" default>
```swift title="HybridNitroTextView.swift"
class HybridNitroTextView: HybridNitroTextViewSpec, MeasurableView {
let view = UILabel()
var text: String = "" { didSet { view.text = text } }
var fontSize: Double = 17 { didSet { view.font = .systemFont(ofSize: fontSize) } }

// `static` - no view instance needed.
// highlight-next-line
static func measureContent(props: NitroTextViewProps, layoutContext: LayoutContext, layoutConstraints: LayoutConstraints) -> Size {
// Measure `props.text` within `layoutConstraints.maximumSize` (off-thread, e.g. NSAttributedString.boundingRect).
return Size(width: measuredWidth, height: measuredHeight)
}
}
```
</TabItem>
<TabItem value="kotlin" label="Kotlin">
```kotlin title="HybridNitroTextView.kt"
class HybridNitroTextView(context: Context) : HybridNitroTextViewSpec() {
override val view = TextView(context)
override var text: String = "" /* set { view.text = … } */
override var fontSize: Double = 17.0 /* set { view.textSize = … } */

// Kotlin interfaces can't require statics, so measureContent lives on the companion.
companion object : MeasurableView<NitroTextViewProps> {
override fun measureContent(props: NitroTextViewProps, layoutContext: LayoutContext, layoutConstraints: LayoutConstraints): Size {
// Measure `props.text` within `layoutConstraints.maximumSize` (off-thread, e.g. StaticLayout).
return Size(measuredWidth, measuredHeight)
}
}
}
```
</TabItem>
</Tabs>

Now `<NitroTextView text="…" fontSize={17} />` grows to fit its text - no `height` in its style:

```jsx
<NitroTextView text="A long string that wraps onto several lines…" fontSize={17} />
```

Because Yoga runs `measureContent` on the shadow thread during layout, it has a few hard rules:

- **No view, pure function.** Never touch your native view inside it (it may not exist yet, and runs on another thread). Compute the size from `props` only - Yoga caches the result keyed on `props` + constraints. Use whatever measurement fits your content and is safe off the main thread (text → `boundingRect`/`StaticLayout`, an image → its source dimensions, …).
- **It's a Yoga leaf**, so a measurable view **can't also host React children** - the two are mutually exclusive (like RN's `<Text>`).
- `maximumSize.width`/`.height` is `.infinity` on an unconstrained axis (return your intrinsic size) and finite when bounded (measure within it, e.g. to wrap text).

:::caution Not every view can self-measure
The size must be derivable from `props`, off the main thread, with no mounted view. Content that can only be sized on the main thread or via a live view's layout pass (UIKit `systemLayoutSizeFitting`, Android `View.measure()`) doesn't fit - use flexbox + explicit sizing, or precompute the size and pass it in as props.
:::

## Props

Since every `HybridView` is also a `HybridObject`, you can use any type that Nitro supports as a property - including custom types (`interface`), `ArrayBuffer`, and even other `HybridObject`s!
Expand Down
44 changes: 44 additions & 0 deletions example/src/screens/ViewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { callback, NitroModules } from 'react-native-nitro-modules'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useColors } from '../useColors'
import {
MeasuredView,
HybridTestObjectSwiftKotlin,
RecyclableTestView,
TestView,
Expand All @@ -14,6 +15,30 @@ import { useIsFocused } from '@react-navigation/native'
const VIEWS_X = 15
const VIEWS_Y = 15


const MeasuredViewImpl = () => {
const [longText, setLongText] = React.useState(false)
const measuredText = longText
? 'MeasuredView sizes its own height from this text a long string that wraps onto several lines, so the bordered box grows to fit it. Tap the button to shrink it.'
: 'MeasuredView: tap below to grow.'

return (
<View style={styles.measuredWrapper}>
<MeasuredView
text={measuredText}
fontSize={17}
style={styles.measuredView}
/>
<View style={styles.measuredButtonRow}>
<Button
title={longText ? 'Shrink text' : 'Grow text'}
onPress={() => setLongText((v) => !v)}
/>
</View>
</View>
)
}

export function ViewScreenImpl() {
const safeArea = useSafeAreaInsets()
const colors = useColors()
Expand Down Expand Up @@ -76,6 +101,8 @@ export function ViewScreenImpl() {
<Text style={styles.buildTypeText}>{NitroModules.buildType}</Text>
</View>

<MeasuredViewImpl />

<View style={styles.resultContainer}>
<View style={[styles.viewShadow]}>
<View style={[styles.viewBorder, { borderColor: colors.foreground }]}>
Expand Down Expand Up @@ -128,6 +155,23 @@ const styles = StyleSheet.create({
}),
fontWeight: 'bold',
},
// No fixed height: the box grows to the height MeasuredView measures.
measuredWrapper: {
marginHorizontal: 15,
marginBottom: 15,
padding: 12,
borderRadius: 12,
borderWidth: 2,
borderColor: 'teal',
backgroundColor: 'rgba(0, 128, 128, 0.12)',
},
measuredView: {
width: '100%',
},
measuredButtonRow: {
marginTop: 8,
alignItems: 'flex-start',
},
segmentedControl: {
minWidth: 180,
},
Expand Down
61 changes: 57 additions & 4 deletions packages/nitrogen/src/views/CppHybridViewComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,16 @@ ${createFileMetadataString(`${component}.hpp`)}
#include <optional>
#include <NitroModules/NitroDefines.hpp>
#include <NitroModules/NitroHash.hpp>
#include <atomic>
#include <NitroModules/CachedProp.hpp>
#include <react/renderer/core/ConcreteComponentDescriptor.h>
#include <react/renderer/core/LayoutConstraints.h>
#include <react/renderer/core/LayoutContext.h>
#include <react/renderer/core/PropsParserContext.h>
#include <react/renderer/core/ShadowNodeTraits.h>
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
#include <react/renderer/components/view/ViewProps.h>
#include <react/renderer/graphics/Size.h>

${includes.join('\n')}

Expand Down Expand Up @@ -156,13 +161,61 @@ namespace ${namespace} {
std::shared_ptr<${propsClassName}> _props;
};

/**
* The base Shadow Node for the "${spec.name}" View.
*/
using ${shadowNodeClassName}Base = react::ConcreteViewShadowNode<${nameVariable} /* "${HybridT}" */,
${shadowIndent} ${propsClassName} /* custom props */,
${shadowIndent} react::ViewEventEmitter /* default */,
${shadowIndent} ${stateClassName} /* custom state */>;

/**
* The Shadow Node for the "${spec.name}" View.
*
* Self-measurement is opt-in: when the native implementation conforms to
* \`MeasurableView\`, the platform layer installs \`measureFunction\` once during
* view registration and this becomes a measurable Yoga leaf that sizes itself
* from its props. Otherwise it stays a normal container whose React children
* are laid out by flexbox.
*/
using ${shadowNodeClassName} = react::ConcreteViewShadowNode<${nameVariable} /* "${HybridT}" */,
${shadowIndent} ${propsClassName} /* custom props */,
${shadowIndent} react::ViewEventEmitter /* default */,
${shadowIndent} ${stateClassName} /* custom state */>;
class ${shadowNodeClassName} final: public ${shadowNodeClassName}Base {
public:
// Inherit both the create- and clone-constructors.
using ${shadowNodeClassName}Base::${shadowNodeClassName}Base;

/**
* A pure (props + constraints) -> size function, run on the Yoga/shadow thread.
*/
using MeasureFunction = react::Size (*)(const react::ViewProps& props,
const react::LayoutContext& layoutContext,
const react::LayoutConstraints& layoutConstraints);

/**
* Installed exactly once during view registration (before the first layout
* pass), iff the native implementation conforms to \`MeasurableView\`. Read
* lock-free on the shadow thread - no map lookup, locking or allocation on
* the measure hot-path.
*/
inline static std::atomic<MeasureFunction> measureFunction{nullptr};

static react::ShadowNodeTraits BaseTraits() {
auto traits = ${shadowNodeClassName}Base::BaseTraits();
if (measureFunction.load(std::memory_order_acquire) != nullptr) {
traits.set(react::ShadowNodeTraits::Trait::LeafYogaNode);
traits.set(react::ShadowNodeTraits::Trait::MeasurableYogaNode);
}
return traits;
}

react::Size measureContent(const react::LayoutContext& layoutContext,
const react::LayoutConstraints& layoutConstraints) const override {
const MeasureFunction measure = measureFunction.load(std::memory_order_acquire);
if (measure == nullptr) [[unlikely]] {
return react::Size{0, 0};
}
return measure(getConcreteProps(), layoutContext, layoutConstraints);
}
};

/**
* The Component Descriptor for the "${spec.name}" View.
Expand Down
60 changes: 60 additions & 0 deletions packages/nitrogen/src/views/createViewMeasurerShared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { HybridObjectSpec } from '../syntax/HybridObjectSpec.js'
import type { Language } from '../getPlatformSpecs.js'
import { isFunction } from '../syntax/helpers.js'
import { StructType } from '../syntax/types/StructType.js'
import { NamedWrappingType } from '../syntax/types/NamedWrappingType.js'
import { NumberType } from '../syntax/types/NumberType.js'
import { BooleanType } from '../syntax/types/BooleanType.js'
import { addKnownType } from '../syntax/createType.js'

export function getMeasureProps(spec: HybridObjectSpec) {
return spec.properties.filter((p) => !isFunction(p.type))
}

export function getMeasurePropsStructName(spec: HybridObjectSpec): string {
return `${spec.name}Props`
}

export function buildMeasurePropsStruct(
spec: HybridObjectSpec,
languages: Language[]
): StructType {
const props = getMeasureProps(spec)
const struct = new StructType(
getMeasurePropsStructName(spec),
props.map((p) => new NamedWrappingType(p.name, p.type))
)
for (const language of languages) {
addKnownType(struct.structName, struct, language)
}
return struct
}

export interface MeasureGeometryStructs {
size: StructType
layoutContext: StructType
layoutConstraints: StructType
}

export function buildMeasureGeometryStructs(
languages: Language[]
): MeasureGeometryStructs {
const size = new StructType('Size', [
new NamedWrappingType('width', new NumberType()),
new NamedWrappingType('height', new NumberType()),
])
const layoutContext = new StructType('LayoutContext', [
new NamedWrappingType('pointScaleFactor', new NumberType()),
new NamedWrappingType('isRTL', new BooleanType()),
])
const layoutConstraints = new StructType('LayoutConstraints', [
new NamedWrappingType('minimumSize', size),
new NamedWrappingType('maximumSize', size),
])
for (const language of languages) {
addKnownType(size.structName, size, language)
addKnownType(layoutContext.structName, layoutContext, language)
addKnownType(layoutConstraints.structName, layoutConstraints, language)
}
return { size, layoutContext, layoutConstraints }
}
10 changes: 10 additions & 0 deletions packages/nitrogen/src/views/kotlin/KotlinHybridViewManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
import { getHybridObjectName } from '../../syntax/getHybridObjectName.js'
import { addJNINativeRegistration } from '../../syntax/kotlin/JNINativeRegistrations.js'
import { indent } from '../../utils.js'
import {
createKotlinViewMeasurer,
getKotlinMeasurerName,
} from './createKotlinViewMeasurer.js'

export function createKotlinHybridViewManager(
spec: HybridObjectSpec
Expand All @@ -37,6 +41,8 @@ export function createKotlinHybridViewManager(
)
}
const viewImplementation = implementation.implementationClassName
const measurerName = getKotlinMeasurerName(spec)
const measurerFiles = createKotlinViewMeasurer(spec)

const viewManagerCode = `
${createFileMetadataString(`${manager}.kt`)}
Expand Down Expand Up @@ -159,6 +165,7 @@ ${createFileMetadataString(`J${stateUpdaterName}.hpp`)}
#include <NitroModules/JStateWrapper.hpp>
#include "${JHybridTSpec}.hpp"
#include "views/${component}.hpp"
#include "views/J${measurerName}.hpp"

namespace ${cxxNamespace} {

Expand All @@ -183,6 +190,8 @@ public:
auto provider = react::concreteComponentDescriptorProvider<${descriptorClassName}>();
auto providerRegistry = react::CoreComponentsRegistry::sharedProviderRegistry();
providerRegistry->add(provider);
// Install lock-free self-measurement (no-op unless the impl conforms to MeasurableView)
J${measurerName}::install();
}
};

Expand Down Expand Up @@ -290,5 +299,6 @@ void J${stateUpdaterName}::updateViewProps(jni::alias_ref<jni::JClass> /* class
platform: 'android',
subdirectory: ['views'],
},
...measurerFiles,
]
}
Loading