Skip to content

Commit

Permalink
feat(iOS/fabric): percentage support in translate (#43192)
Browse files Browse the repository at this point in the history
Summary:
This PR adds percentage support in translate properties for new arch iOS. Isolating this PR for easier reviews.

The approach taken here introduces usage of `ValueUnit` struct for transform operations so it can support `%` in translates and delay the generation of actual transform matrix until view dimensions are known. I have tried to keep the changes minimal and reuse existing APIs, open to changes if there's an alternative approach.

## Changelog:
[IOS] [ADDED] - Percentage support in translate in new arch.
<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

[ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Pull Request resolved: #43192

Test Plan:
- Checkout TransformExample.js -> Translate percentage example.
- Added a simple test in `processTransform-test.js`. The regex is not perfect (values like 20px%, 20%px will pass, can be improved, let me know!)

Related PRs - #43193, #43191

Reviewed By: javache

Differential Revision: D56802425

Pulled By: NickGerleman

fbshipit-source-id: 978cbbdde004afe1e68ffee9a3c7eb7d16336b46
  • Loading branch information
intergalacticspacehighway authored and facebook-github-bot committed May 17, 2024
1 parent 82c6f8a commit f997b81
Show file tree
Hide file tree
Showing 14 changed files with 324 additions and 123 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ exports[`processTransform validation should throw when passing an invalid angle

exports[`processTransform validation should throw when passing an invalid angle prop 4`] = `"Rotate transform must be expressed in degrees (deg) or radians (rad): {\\"skewX\\":\\"10drg\\"}"`;

exports[`processTransform validation should throw when passing an invalid value to a number prop 1`] = `"Transform with key of \\"translateY\\" must be a number: {\\"translateY\\":\\"20deg\\"}"`;
exports[`processTransform validation should throw when passing an invalid value to a number prop 1`] = `"Transform with key of \\"translateY\\" must be number or a percentage. Passed value: {\\"translateY\\":\\"20deg\\"}."`;

exports[`processTransform validation should throw when passing an invalid value to a number prop 2`] = `"Transform with key of \\"scale\\" must be a number: {\\"scale\\":{\\"x\\":10,\\"y\\":10}}"`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ describe('processTransform', () => {
);
});

it('should accept a percentage translate transform', () => {
processTransform([{translateY: '20%'}, {translateX: '10%'}]);
processTransform('translateX(10%)');
});

it('should throw on object with multiple properties', () => {
expect(() =>
processTransform([{scale: 0.5, translateY: 10}]),
Expand Down
21 changes: 18 additions & 3 deletions packages/react-native/Libraries/StyleSheet/processTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,11 @@ const _getKeyAndValueFromCSSTransform: (
| $TEMPORARY$string<'translateX'>
| $TEMPORARY$string<'translateY'>,
args: string,
) => {key: string, value?: number[] | number | string} = (key, args) => {
const argsWithUnitsRegex = new RegExp(/([+-]?\d+(\.\d+)?)([a-zA-Z]+)?/g);
) => {key: string, value?: Array<string | number> | number | string} = (
key,
args,
) => {
const argsWithUnitsRegex = new RegExp(/([+-]?\d+(\.\d+)?)([a-zA-Z]+|%)?/g);

switch (key) {
case 'matrix':
Expand All @@ -88,7 +91,11 @@ const _getKeyAndValueFromCSSTransform: (
missingUnitOfMeasurement = true;
}

parsedArgs.push(value);
if (unitOfMeasurement === '%') {
parsedArgs.push(`${value}%`);
} else {
parsedArgs.push(value);
}
}

if (__DEV__) {
Expand Down Expand Up @@ -256,6 +263,14 @@ function _validateTransform(
break;
case 'translateX':
case 'translateY':
invariant(
typeof value === 'number' ||
(typeof value === 'string' && value.endsWith('%')),
'Transform with key of "%s" must be number or a percentage. Passed value: %s.',
key,
stringifySafe(transformation),
);
break;
case 'scale':
case 'scaleX':
case 'scaleY':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,8 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
_contentView.frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame());
}

if (_props->transformOrigin.isSet()) {
if ((_props->transformOrigin.isSet() || _props->transform.operations.size() > 0) &&
layoutMetrics.frame.size != oldLayoutMetrics.frame.size) {
auto newTransform = _props->resolveTransform(layoutMetrics);
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1112,12 +1112,14 @@ ShadowView LayoutAnimationKeyFrameManager::createInterpolatedShadowView(
// Animate opacity or scale/transform
PropsParserContext propsParserContext{
finalView.surfaceId, *contextContainer_};
const auto& finalViewSize = finalView.layoutMetrics.frame.size;
mutatedShadowView.props = interpolateProps(
componentDescriptor,
propsParserContext,
progress,
startingView.props,
finalView.props);
finalView.props,
finalViewSize);

react_native_assert(mutatedShadowView.props != nullptr);
if (mutatedShadowView.props == nullptr) {
Expand Down Expand Up @@ -1626,7 +1628,8 @@ Props::Shared LayoutAnimationKeyFrameManager::interpolateProps(
const PropsParserContext& context,
Float animationProgress,
const Props::Shared& props,
const Props::Shared& newProps) const {
const Props::Shared& newProps,
const Size& size) const {
#ifdef ANDROID
// On Android only, the merged props should have the same RawProps as the
// final props struct
Expand All @@ -1643,7 +1646,7 @@ Props::Shared LayoutAnimationKeyFrameManager::interpolateProps(
if (componentDescriptor.getTraits().check(
ShadowNodeTraits::Trait::ViewKind)) {
interpolateViewProps(
animationProgress, props, newProps, interpolatedPropsShared);
animationProgress, props, newProps, interpolatedPropsShared, size);
}

return interpolatedPropsShared;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ class LayoutAnimationKeyFrameManager : public UIManagerAnimationDelegate,
const PropsParserContext& context,
Float animationProgress,
const Props::Shared& props,
const Props::Shared& newProps) const;
const Props::Shared& newProps,
const Size& size) const;
};

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -458,21 +458,36 @@ BorderMetrics BaseViewProps::resolveBorderMetrics(

Transform BaseViewProps::resolveTransform(
const LayoutMetrics& layoutMetrics) const {
float viewWidth = layoutMetrics.frame.size.width;
float viewHeight = layoutMetrics.frame.size.height;
if (!transformOrigin.isSet() || (viewWidth == 0 && viewHeight == 0)) {
return transform;
const auto& frameSize = layoutMetrics.frame.size;
auto transformMatrix = Transform{};
if (frameSize.width == 0 && frameSize.height == 0) {
return transformMatrix;
}
std::array<float, 3> translateOffsets =
getTranslateForTransformOrigin(viewWidth, viewHeight, transformOrigin);
auto newTransform = Transform::Translate(
translateOffsets[0], translateOffsets[1], translateOffsets[2]);
newTransform = newTransform * transform;
newTransform =
newTransform *
Transform::Translate(
-translateOffsets[0], -translateOffsets[1], -translateOffsets[2]);
return newTransform;

// transform is matrix
if (transform.operations.size() == 1 &&
transform.operations[0].type == TransformOperationType::Arbitrary) {
transformMatrix = transform;
} else {
for (const auto& operation : transform.operations) {
transformMatrix = transformMatrix *
Transform::FromTransformOperation(
operation, layoutMetrics.frame.size);
}
}

if (transformOrigin.isSet()) {
std::array<float, 3> translateOffsets = getTranslateForTransformOrigin(
frameSize.width, frameSize.height, transformOrigin);
transformMatrix =
Transform::Translate(
translateOffsets[0], translateOffsets[1], translateOffsets[2]) *
transformMatrix *
Transform::Translate(
-translateOffsets[0], -translateOffsets[1], -translateOffsets[2]);
}

return transformMatrix;
}

bool BaseViewProps::getClipsContentToBounds() const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ static inline void interpolateViewProps(
Float animationProgress,
const Props::Shared& oldPropsShared,
const Props::Shared& newPropsShared,
Props::Shared& interpolatedPropsShared) {
Props::Shared& interpolatedPropsShared,
const Size& size) {
const ViewProps* oldViewProps =
static_cast<const ViewProps*>(oldPropsShared.get());
const ViewProps* newViewProps =
Expand All @@ -31,9 +32,11 @@ static inline void interpolateViewProps(

interpolatedProps->opacity = oldViewProps->opacity +
(newViewProps->opacity - oldViewProps->opacity) * animationProgress;

interpolatedProps->transform = Transform::Interpolate(
animationProgress, oldViewProps->transform, newViewProps->transform);
animationProgress,
oldViewProps->transform,
newViewProps->transform,
size);

// Android uses RawProps, not props, to update props on the platform...
// Since interpolated props don't interpolate at all using RawProps, we need
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,35 @@ inline Float toRadians(
return static_cast<Float>(num); // assume suffix is "rad"
}

inline void fromRawValue(
const PropsParserContext& /*context*/,
const RawValue& value,
ValueUnit& result) {
react_native_expect(value.hasType<RawValue>());
ValueUnit valueUnit;

if (value.hasType<Float>()) {
auto valueFloat = (float)value;
if (std::isfinite(valueFloat)) {
valueUnit = ValueUnit(valueFloat, UnitType::Point);
} else {
valueUnit = ValueUnit(0.0f, UnitType::Undefined);
}
} else if (value.hasType<std::string>()) {
const auto stringValue = (std::string)value;

if (stringValue.back() == '%') {
auto tryValue = folly::tryTo<float>(
std::string_view(stringValue).substr(0, stringValue.length() - 1));
if (tryValue.hasValue()) {
valueUnit = ValueUnit(tryValue.value(), UnitType::Percent);
}
}
}

result = valueUnit;
}

inline void fromRawValue(
const PropsParserContext& context,
const RawValue& value,
Expand All @@ -504,6 +533,8 @@ inline void fromRawValue(
auto pair = configurationPair.begin();
auto operation = pair->first;
auto& parameters = pair->second;
auto Zero = ValueUnit(0, UnitType::Point);
auto One = ValueUnit(1, UnitType::Point);

if (operation == "matrix") {
react_native_expect(parameters.hasType<std::vector<Float>>());
Expand All @@ -513,84 +544,90 @@ inline void fromRawValue(
for (auto number : numbers) {
transformMatrix.matrix[i++] = number;
}
transformMatrix.operations.push_back(
TransformOperation{TransformOperationType::Arbitrary, 0, 0, 0});
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Arbitrary, Zero, Zero, Zero});
} else if (operation == "perspective") {
transformMatrix =
transformMatrix * Transform::Perspective((Float)parameters);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Perspective,
ValueUnit((Float)parameters, UnitType::Point),
Zero,
Zero});
} else if (operation == "rotateX") {
transformMatrix = transformMatrix *
Transform::Rotate(toRadians(parameters, 0.0f), 0, 0);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Rotate,
ValueUnit(toRadians(parameters, 0.0f), UnitType::Point),
Zero,
Zero});
} else if (operation == "rotateY") {
transformMatrix = transformMatrix *
Transform::Rotate(0, toRadians(parameters, 0.0f), 0);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Rotate,
Zero,
ValueUnit(toRadians(parameters, 0.0f), UnitType::Point),
Zero});
} else if (operation == "rotateZ" || operation == "rotate") {
transformMatrix = transformMatrix *
Transform::Rotate(0, 0, toRadians(parameters, 0.0f));
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Rotate,
Zero,
Zero,
ValueUnit(toRadians(parameters, 0.0f), UnitType::Point)});
} else if (operation == "scale") {
auto number = (Float)parameters;
transformMatrix =
transformMatrix * Transform::Scale(number, number, number);
auto number = ValueUnit((Float)parameters, UnitType::Point);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Scale, number, number, number});
} else if (operation == "scaleX") {
transformMatrix =
transformMatrix * Transform::Scale((Float)parameters, 1, 1);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Scale,
ValueUnit((Float)parameters, UnitType::Point),
One,
One});
} else if (operation == "scaleY") {
transformMatrix =
transformMatrix * Transform::Scale(1, (Float)parameters, 1);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Scale,
One,
ValueUnit((Float)parameters, UnitType::Point),
One});
} else if (operation == "scaleZ") {
transformMatrix =
transformMatrix * Transform::Scale(1, 1, (Float)parameters);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Scale,
One,
One,
ValueUnit((Float)parameters, UnitType::Point)});
} else if (operation == "translate") {
auto numbers = (std::vector<Float>)parameters;
transformMatrix = transformMatrix *
Transform::Translate(numbers.at(0), numbers.at(1), 0);
auto numbers = (std::vector<RawValue>)parameters;
ValueUnit valueX;
fromRawValue(context, numbers.at(0), valueX);
ValueUnit valueY;
fromRawValue(context, numbers.at(1), valueY);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Translate, valueX, valueY, Zero});
} else if (operation == "translateX") {
transformMatrix =
transformMatrix * Transform::Translate((Float)parameters, 0, 0);
ValueUnit valueX;
fromRawValue(context, parameters, valueX);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Translate, valueX, Zero, Zero});
} else if (operation == "translateY") {
transformMatrix =
transformMatrix * Transform::Translate(0, (Float)parameters, 0);
ValueUnit valueY;
fromRawValue(context, parameters, valueY);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Translate, Zero, valueY, Zero});
} else if (operation == "skewX") {
transformMatrix =
transformMatrix * Transform::Skew(toRadians(parameters, 0.0f), 0);
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Skew,
ValueUnit(toRadians(parameters, 0.0f), UnitType::Point),
Zero,
Zero});
} else if (operation == "skewY") {
transformMatrix =
transformMatrix * Transform::Skew(0, toRadians(parameters, 0.0f));
transformMatrix.operations.push_back(TransformOperation{
TransformOperationType::Skew,
Zero,
ValueUnit(toRadians(parameters, 0.0f), UnitType::Point),
Zero});
}
}

result = transformMatrix;
}

inline void fromRawValue(
const PropsParserContext& /*context*/,
const RawValue& value,
ValueUnit& result) {
react_native_expect(value.hasType<RawValue>());
ValueUnit valueUnit;

if (value.hasType<Float>()) {
auto valueFloat = (float)value;
if (std::isfinite(valueFloat)) {
valueUnit = ValueUnit(valueFloat, UnitType::Point);
} else {
valueUnit = ValueUnit(0.0f, UnitType::Undefined);
}
} else if (value.hasType<std::string>()) {
const auto stringValue = (std::string)value;

if (stringValue.back() == '%') {
auto tryValue = folly::tryTo<float>(
std::string_view(stringValue).substr(0, stringValue.length() - 1));
if (tryValue.hasValue()) {
valueUnit = ValueUnit(tryValue.value(), UnitType::Percent);
}
}
}

result = valueUnit;
}

inline void fromRawValue(
const PropsParserContext& context,
const RawValue& value,
Expand Down

0 comments on commit f997b81

Please sign in to comment.