Skip to content

Commit ffcdbae

Browse files
MazinZfacebook-github-bot
authored andcommitted
CSS calc() percentage post-layout offset support (#56170)
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 2771f90 commit ffcdbae

4 files changed

Lines changed: 205 additions & 0 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#include "YogaStylableProps.h"
99

10+
#include <string>
11+
1012
#include <react/featureflags/ReactNativeFeatureFlags.h>
1113
#include <react/renderer/components/view/conversions.h>
1214
#include <react/renderer/components/view/propsConversions.h>
@@ -34,6 +36,42 @@ YogaStylableProps::YogaStylableProps(
3436
if (!ReactNativeFeatureFlags::enableCppPropsIteratorSetter()) {
3537
convertRawPropAliases(context, sourceProps, rawProps);
3638
}
39+
40+
// Read calc() offsets for dimension props. The JS side sends
41+
// the percentage part as a normal dimension value (e.g. width: "50%")
42+
// and the point offset as a separate float prop.
43+
calcOffsets[0] = convertRawProp(
44+
context, rawProps, "__calcWidthOffset", sourceProps.calcOffsets[0], 0.0f);
45+
calcOffsets[1] = convertRawProp(
46+
context,
47+
rawProps,
48+
"__calcHeightOffset",
49+
sourceProps.calcOffsets[1],
50+
0.0f);
51+
calcOffsets[2] = convertRawProp(
52+
context,
53+
rawProps,
54+
"__calcMinWidthOffset",
55+
sourceProps.calcOffsets[2],
56+
0.0f);
57+
calcOffsets[3] = convertRawProp(
58+
context,
59+
rawProps,
60+
"__calcMinHeightOffset",
61+
sourceProps.calcOffsets[3],
62+
0.0f);
63+
calcOffsets[4] = convertRawProp(
64+
context,
65+
rawProps,
66+
"__calcMaxWidthOffset",
67+
sourceProps.calcOffsets[4],
68+
0.0f);
69+
calcOffsets[5] = convertRawProp(
70+
context,
71+
rawProps,
72+
"__calcMaxHeightOffset",
73+
sourceProps.calcOffsets[5],
74+
0.0f);
3775
};
3876

3977
template <typename T>

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

Lines changed: 29 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)

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

Lines changed: 71 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,74 @@ 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+
// CalcPropId::Width == 0, CalcPropId::Height == 1
491+
props.calcOffsets[0] = -20.0f;
492+
// Simulate calc(100% - 64px) for height
493+
yogaStyle.setDimension(
494+
yoga::Dimension::Height,
495+
yoga::StyleSizeLength::percent(100));
496+
props.calcOffsets[1] = -64.0f;
497+
return sharedProps;
498+
})
499+
});
500+
// clang-format on
501+
502+
builder_.build(element);
503+
rootShadowNode_->layoutIfNeeded();
504+
}
505+
};
506+
507+
TEST_F(CalcOffsetTest, calcWidthAndHeight) {
508+
initialize();
509+
510+
auto layoutMetrics = childNode_->getLayoutMetrics();
511+
512+
// width: calc(50% - 20px) = 200 * 0.5 - 20 = 80
513+
EXPECT_EQ(layoutMetrics.frame.size.width, 80);
514+
// height: calc(100% - 64px) = 200 * 1.0 - 64 = 136
515+
EXPECT_EQ(layoutMetrics.frame.size.height, 136);
516+
}
517+
447518
} // namespace facebook::react

0 commit comments

Comments
 (0)