Skip to content

Commit 5763782

Browse files
MazinZfacebook-github-bot
authored andcommitted
CSS calc() percentage post-layout offset support
Summary: Add native support for CSS calc() expressions containing percentages (e.g., `calc(50% - 20px)`). The JS side produces `{__rsdCalc: true, percent, offset}` objects; this diff adds the C++ side that consumes them. **YogaStylableProps:** - `CalcPropId` enum for width/height/min/max dimension properties - `calcOffsets[]` array storing point offsets indexed by CalcPropId - `trySetCalcDimension()` parses `__rsdCalc` map objects from RawValue, sets percentage on Yoga and stores point offset - Width/height/min/max cases in `setProp` call `trySetCalcDimension` before falling back to standard handling **YogaLayoutableShadowNode:** - `applyCalcOffsets()` adjusts layout metrics after Yoga layout, adding point offsets to percentage-resolved dimensions - Called in `layout()` after `layoutMetricsFromYogaNode` when `hasCalcOffsets()` is true **LayoutTest:** - `CalcOffsetTest` verifies calc(50% - 20px) in 200px parent → 80px child width - Verifies calc(100% - 64px) → 136px height Changelog: [General][Added] - Added initial support for calc CSS property Differential Revision: D97422357
1 parent 320e3bd commit 5763782

4 files changed

Lines changed: 313 additions & 5 deletions

File tree

‎packages/react-native/ReactCommon/react/renderer/components/view/YogaLayoutableShadowNode.cpp‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,59 @@ static EdgeInsets calculateOverflowInset(
688688
return overflowInset;
689689
}
690690

691+
static void applyCalcOffsets(
692+
LayoutMetrics& metrics,
693+
const YogaStylableProps& props) {
694+
// Width
695+
float widthOffset = props.calcOffsets[static_cast<size_t>(CalcPropId::Width)];
696+
if (widthOffset != 0.0f) {
697+
metrics.frame.size.width += widthOffset;
698+
}
699+
// Height
700+
float heightOffset =
701+
props.calcOffsets[static_cast<size_t>(CalcPropId::Height)];
702+
if (heightOffset != 0.0f) {
703+
metrics.frame.size.height += heightOffset;
704+
}
705+
// MinWidth: clamp width from below
706+
float minWidthOffset =
707+
props.calcOffsets[static_cast<size_t>(CalcPropId::MinWidth)];
708+
if (minWidthOffset != 0.0f) {
709+
// Yoga already applied minWidth percent; adjust the resolved minimum
710+
float adjustedMin = metrics.frame.size.width + minWidthOffset;
711+
if (metrics.frame.size.width < adjustedMin) {
712+
metrics.frame.size.width = adjustedMin;
713+
}
714+
}
715+
// MinHeight
716+
float minHeightOffset =
717+
props.calcOffsets[static_cast<size_t>(CalcPropId::MinHeight)];
718+
if (minHeightOffset != 0.0f) {
719+
float adjustedMin = metrics.frame.size.height + minHeightOffset;
720+
if (metrics.frame.size.height < adjustedMin) {
721+
metrics.frame.size.height = adjustedMin;
722+
}
723+
}
724+
// MaxWidth: clamp width from above
725+
float maxWidthOffset =
726+
props.calcOffsets[static_cast<size_t>(CalcPropId::MaxWidth)];
727+
if (maxWidthOffset != 0.0f) {
728+
float adjustedMax = metrics.frame.size.width + maxWidthOffset;
729+
if (metrics.frame.size.width > adjustedMax) {
730+
metrics.frame.size.width = adjustedMax;
731+
}
732+
}
733+
// MaxHeight
734+
float maxHeightOffset =
735+
props.calcOffsets[static_cast<size_t>(CalcPropId::MaxHeight)];
736+
if (maxHeightOffset != 0.0f) {
737+
float adjustedMax = metrics.frame.size.height + maxHeightOffset;
738+
if (metrics.frame.size.height > adjustedMax) {
739+
metrics.frame.size.height = adjustedMax;
740+
}
741+
}
742+
}
743+
691744
void YogaLayoutableShadowNode::layout(LayoutContext layoutContext) {
692745
// Reading data from a dirtied node does not make sense.
693746
react_native_assert(!YGNodeIsDirty(&yogaNode_));
@@ -717,6 +770,20 @@ void YogaLayoutableShadowNode::layout(LayoutContext layoutContext) {
717770
layoutContext.swapLeftAndRightInRTL &&
718771
newLayoutMetrics.layoutDirection == LayoutDirection::RightToLeft;
719772

773+
// Apply CSS calc() post-layout offsets. When a calc expression contains
774+
// a percentage (e.g. calc(50% - 20px)), Yoga resolves the percentage
775+
// part during layout. The point offset is applied here afterward.
776+
{
777+
const auto& childPropsShared = childNode.getProps();
778+
if (childPropsShared != nullptr) {
779+
const auto& childProps =
780+
static_cast<const YogaStylableProps&>(*childPropsShared);
781+
if (childProps.hasCalcOffsets()) {
782+
applyCalcOffsets(newLayoutMetrics, childProps);
783+
}
784+
}
785+
}
786+
720787
// Child node's layout has changed. When a node is added to
721788
// `affectedNodes`, onLayout event is called on the component. Comparing
722789
// `newLayoutMetrics.frame` with `childNode.getLayoutMetrics().frame` to

‎packages/react-native/ReactCommon/react/renderer/components/view/YogaStylableProps.cpp‎

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
#include "YogaStylableProps.h"
99

10+
#include <string>
11+
#include <unordered_map>
12+
1013
#include <react/featureflags/ReactNativeFeatureFlags.h>
1114
#include <react/renderer/components/view/conversions.h>
1215
#include <react/renderer/components/view/propsConversions.h>
@@ -121,6 +124,43 @@ static inline const T getFieldValue(
121124
REBUILD_YG_FIELD_SWITCH_CASE_INDEXED( \
122125
position, setPosition, yoga::Edge::All, "inset");
123126

127+
bool YogaStylableProps::trySetCalcDimension(
128+
const PropsParserContext& /*context*/,
129+
const RawValue& value,
130+
yoga::Dimension dim,
131+
CalcPropId propId,
132+
bool isMinMax) {
133+
if (!value.hasType<std::unordered_map<std::string, RawValue>>()) {
134+
return false;
135+
}
136+
auto map = static_cast<std::unordered_map<std::string, RawValue>>(value);
137+
auto it = map.find("__rsdCalc");
138+
if (it == map.end()) {
139+
return false;
140+
}
141+
auto percentIt = map.find("percent");
142+
auto offsetIt = map.find("offset");
143+
if (percentIt == map.end() || offsetIt == map.end()) {
144+
return false;
145+
}
146+
float percent = static_cast<float>(static_cast<double>(percentIt->second));
147+
float offset = static_cast<float>(static_cast<double>(offsetIt->second));
148+
149+
auto percentValue = yoga::StyleSizeLength::percent(percent);
150+
if (isMinMax) {
151+
// For min/max, determine setter based on propId
152+
if (propId == CalcPropId::MinWidth || propId == CalcPropId::MinHeight) {
153+
yogaStyle.setMinDimension(dim, percentValue);
154+
} else {
155+
yogaStyle.setMaxDimension(dim, percentValue);
156+
}
157+
} else {
158+
yogaStyle.setDimension(dim, percentValue);
159+
}
160+
calcOffsets[static_cast<size_t>(propId)] = offset;
161+
return true;
162+
}
163+
124164
void YogaStylableProps::setProp(
125165
const PropsParserContext& context,
126166
RawPropsPropNameHash hash,
@@ -154,11 +194,101 @@ void YogaStylableProps::setProp(
154194
return;
155195
}
156196
REBUILD_FIELD_SWITCH_CASE_YSP(boxSizing, setBoxSizing);
157-
REBUILD_FIELD_YG_DIMENSION(dimension, setDimension, "width", "height");
158-
REBUILD_FIELD_YG_DIMENSION(
159-
minDimension, setMinDimension, "minWidth", "minHeight");
160-
REBUILD_FIELD_YG_DIMENSION(
161-
maxDimension, setMaxDimension, "maxWidth", "maxHeight");
197+
// Dimension props with calc() offset support.
198+
// For __rsdCalc objects {percent, offset}, set percentage on Yoga
199+
// and store the point offset for post-layout adjustment.
200+
case CONSTEXPR_RAW_PROPS_KEY_HASH("width"): {
201+
if (trySetCalcDimension(
202+
context, value, yoga::Dimension::Width, CalcPropId::Width)) {
203+
return;
204+
}
205+
yogaStyle.setDimension(
206+
yoga::Dimension::Width,
207+
getFieldValue(
208+
context, value, ygDefaults.dimension(yoga::Dimension::Width)));
209+
calcOffsets[static_cast<size_t>(CalcPropId::Width)] = 0;
210+
return;
211+
}
212+
case CONSTEXPR_RAW_PROPS_KEY_HASH("height"): {
213+
if (trySetCalcDimension(
214+
context, value, yoga::Dimension::Height, CalcPropId::Height)) {
215+
return;
216+
}
217+
yogaStyle.setDimension(
218+
yoga::Dimension::Height,
219+
getFieldValue(
220+
context, value, ygDefaults.dimension(yoga::Dimension::Height)));
221+
calcOffsets[static_cast<size_t>(CalcPropId::Height)] = 0;
222+
return;
223+
}
224+
case CONSTEXPR_RAW_PROPS_KEY_HASH("minWidth"): {
225+
if (trySetCalcDimension(
226+
context,
227+
value,
228+
yoga::Dimension::Width,
229+
CalcPropId::MinWidth,
230+
true)) {
231+
return;
232+
}
233+
yogaStyle.setMinDimension(
234+
yoga::Dimension::Width,
235+
getFieldValue(
236+
context, value, ygDefaults.minDimension(yoga::Dimension::Width)));
237+
calcOffsets[static_cast<size_t>(CalcPropId::MinWidth)] = 0;
238+
return;
239+
}
240+
case CONSTEXPR_RAW_PROPS_KEY_HASH("minHeight"): {
241+
if (trySetCalcDimension(
242+
context,
243+
value,
244+
yoga::Dimension::Height,
245+
CalcPropId::MinHeight,
246+
true)) {
247+
return;
248+
}
249+
yogaStyle.setMinDimension(
250+
yoga::Dimension::Height,
251+
getFieldValue(
252+
context,
253+
value,
254+
ygDefaults.minDimension(yoga::Dimension::Height)));
255+
calcOffsets[static_cast<size_t>(CalcPropId::MinHeight)] = 0;
256+
return;
257+
}
258+
case CONSTEXPR_RAW_PROPS_KEY_HASH("maxWidth"): {
259+
if (trySetCalcDimension(
260+
context,
261+
value,
262+
yoga::Dimension::Width,
263+
CalcPropId::MaxWidth,
264+
true)) {
265+
return;
266+
}
267+
yogaStyle.setMaxDimension(
268+
yoga::Dimension::Width,
269+
getFieldValue(
270+
context, value, ygDefaults.maxDimension(yoga::Dimension::Width)));
271+
calcOffsets[static_cast<size_t>(CalcPropId::MaxWidth)] = 0;
272+
return;
273+
}
274+
case CONSTEXPR_RAW_PROPS_KEY_HASH("maxHeight"): {
275+
if (trySetCalcDimension(
276+
context,
277+
value,
278+
yoga::Dimension::Height,
279+
CalcPropId::MaxHeight,
280+
true)) {
281+
return;
282+
}
283+
yogaStyle.setMaxDimension(
284+
yoga::Dimension::Height,
285+
getFieldValue(
286+
context,
287+
value,
288+
ygDefaults.maxDimension(yoga::Dimension::Height)));
289+
calcOffsets[static_cast<size_t>(CalcPropId::MaxHeight)] = 0;
290+
return;
291+
}
162292
REBUILD_FIELD_YG_EDGES_POSITION();
163293
REBUILD_FIELD_YG_EDGES(margin, setMargin, "margin", "");
164294
REBUILD_FIELD_YG_EDGES(padding, setPadding, "padding", "");

‎packages/react-native/ReactCommon/react/renderer/components/view/YogaStylableProps.h‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#pragma once
99

10+
#include <cstdint>
11+
1012
#include <yoga/style/Style.h>
1113

1214
#include <react/renderer/core/Props.h>
@@ -15,6 +17,18 @@
1517

1618
namespace facebook::react {
1719

20+
// Property identifiers for calc post-layout offsets, matching Yoga dimension
21+
// props. Used by CSS calc() expressions like calc(50% - 20px), where the
22+
// percentage is set on Yoga and the point offset is applied post-layout.
23+
enum class CalcPropId : uint8_t {
24+
Width,
25+
Height,
26+
MinWidth,
27+
MinHeight,
28+
MaxWidth,
29+
MaxHeight,
30+
};
31+
1832
class YogaStylableProps : public Props {
1933
public:
2034
YogaStylableProps() = default;
@@ -57,6 +71,21 @@ class YogaStylableProps : public Props {
5771
yoga::Style::Length paddingBlockStart;
5872
yoga::Style::Length paddingBlockEnd;
5973

74+
// Post-layout offsets for CSS calc() expressions containing percentages.
75+
// Indexed by CalcPropId (cast to size_t). Non-zero offset means active.
76+
static constexpr size_t kCalcPropCount = 6;
77+
float calcOffsets[kCalcPropCount] = {0, 0, 0, 0, 0, 0};
78+
79+
bool hasCalcOffsets() const
80+
{
81+
for (size_t i = 0; i < kCalcPropCount; i++) {
82+
if (calcOffsets[i] != 0.0f) {
83+
return true;
84+
}
85+
}
86+
return false;
87+
}
88+
6089
#if RN_DEBUG_STRING_CONVERTIBLE
6190

6291
#pragma mark - DebugStringConvertible (Partial)
@@ -66,6 +95,16 @@ class YogaStylableProps : public Props {
6695
#endif
6796

6897
private:
98+
// Attempts to parse a __rsdCalc object from the RawValue. If found, sets the
99+
// percentage part on yogaStyle and stores the point offset in calcOffsets.
100+
// Returns true if the value was a calc object, false otherwise.
101+
bool trySetCalcDimension(
102+
const PropsParserContext &context,
103+
const RawValue &value,
104+
yoga::Dimension dim,
105+
CalcPropId propId,
106+
bool isMinMax = false);
107+
69108
void convertRawPropAliases(
70109
const PropsParserContext &context,
71110
const YogaStylableProps &sourceProps,

‎packages/react-native/ReactCommon/react/renderer/components/view/tests/LayoutTest.cpp‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
1111
#include <react/renderer/components/root/RootComponentDescriptor.h>
1212
#include <react/renderer/components/view/ViewComponentDescriptor.h>
13+
#include <react/renderer/components/view/YogaStylableProps.h>
1314
#include <react/renderer/element/ComponentBuilder.h>
1415
#include <react/renderer/element/Element.h>
1516
#include <react/renderer/element/testUtils.h>
@@ -444,4 +445,75 @@ TEST_F(LayoutTest, overflowInsetHitSlopTransformTranslateTest) {
444445
EXPECT_EQ(layoutMetricsABC.overflowInset.bottom, 0);
445446
}
446447

448+
// Test CSS calc() post-layout offsets.
449+
// A child with width: calc(50% - 20px) inside a 200px-wide parent
450+
// should resolve to 200*0.5 - 20 = 80px.
451+
class CalcOffsetTest : public ::testing::Test {
452+
protected:
453+
ComponentBuilder builder_;
454+
std::shared_ptr<RootShadowNode> rootShadowNode_;
455+
std::shared_ptr<ViewShadowNode> childNode_;
456+
457+
CalcOffsetTest() : builder_(simpleComponentBuilder()) {}
458+
459+
void initialize() {
460+
// clang-format off
461+
auto element =
462+
Element<RootShadowNode>()
463+
.reference(rootShadowNode_)
464+
.tag(1)
465+
.props([] {
466+
auto sharedProps = std::make_shared<RootProps>();
467+
auto &props = *sharedProps;
468+
props.layoutConstraints = LayoutConstraints{
469+
.minimumSize = {.width = 0, .height = 0},
470+
.maximumSize = {.width = 500, .height = 500}};
471+
auto &yogaStyle = props.yogaStyle;
472+
yogaStyle.setDimension(
473+
yoga::Dimension::Width, yoga::StyleSizeLength::points(200));
474+
yogaStyle.setDimension(
475+
yoga::Dimension::Height, yoga::StyleSizeLength::points(200));
476+
return sharedProps;
477+
})
478+
.children({
479+
Element<ViewShadowNode>()
480+
.reference(childNode_)
481+
.tag(2)
482+
.props([] {
483+
auto sharedProps = std::make_shared<ViewShadowNodeProps>();
484+
auto &props = *sharedProps;
485+
auto &yogaStyle = props.yogaStyle;
486+
// Simulate calc(50% - 20px): set 50% on Yoga, store -20 offset
487+
yogaStyle.setDimension(
488+
yoga::Dimension::Width,
489+
yoga::StyleSizeLength::percent(50));
490+
props.calcOffsets[static_cast<size_t>(CalcPropId::Width)] =
491+
-20.0f;
492+
// Simulate calc(100% - 64px) for height
493+
yogaStyle.setDimension(
494+
yoga::Dimension::Height,
495+
yoga::StyleSizeLength::percent(100));
496+
props.calcOffsets[static_cast<size_t>(CalcPropId::Height)] =
497+
-64.0f;
498+
return sharedProps;
499+
})
500+
});
501+
// clang-format on
502+
503+
builder_.build(element);
504+
rootShadowNode_->layoutIfNeeded();
505+
}
506+
};
507+
508+
TEST_F(CalcOffsetTest, calcWidthAndHeight) {
509+
initialize();
510+
511+
auto layoutMetrics = childNode_->getLayoutMetrics();
512+
513+
// width: calc(50% - 20px) = 200 * 0.5 - 20 = 80
514+
EXPECT_EQ(layoutMetrics.frame.size.width, 80);
515+
// height: calc(100% - 64px) = 200 * 1.0 - 64 = 136
516+
EXPECT_EQ(layoutMetrics.frame.size.height, 136);
517+
}
518+
447519
} // namespace facebook::react

0 commit comments

Comments
 (0)