Skip to content

Commit 8a84a2d

Browse files
feat: ios fabric transform origin (facebook#38559)
Summary: This PR adds transform-origin support for iOS fabric. This PR also incorporates feedback/changes suggested by javache in the original [PR.](facebook#37606) ## Changelog: [IOS] [ADDED] - Fabric Transform origin <!-- 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: facebook#38559 Test Plan: Run iOS RNTester app in old architecture and test transform-origin example in `TransformExample.js`. Differential Revision: D48528363 Pulled By: javache fbshipit-source-id: 21cfe06db750c8cf55d43039f0189089d29fca6f
1 parent 5c513cb commit 8a84a2d

File tree

17 files changed

+516
-12
lines changed

17 files changed

+516
-12
lines changed

packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import processAspectRatio from '../../StyleSheet/processAspectRatio';
1414
import processColor from '../../StyleSheet/processColor';
1515
import processFontVariant from '../../StyleSheet/processFontVariant';
1616
import processTransform from '../../StyleSheet/processTransform';
17+
import processTransformOrigin from '../../StyleSheet/processTransformOrigin';
1718
import sizesDiffer from '../../Utilities/differ/sizesDiffer';
1819

1920
const colorAttributes = {process: processColor};
@@ -111,7 +112,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
111112
* Transform
112113
*/
113114
transform: {process: processTransform},
114-
115+
transformOrigin: {process: processTransformOrigin},
115116
/**
116117
* View
117118
*/

packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ const validAttributesForNonEventProps = {
205205
overflow: true,
206206
shouldRasterizeIOS: true,
207207
transform: {diff: require('../Utilities/differ/matricesDiffer')},
208+
transformOrigin: true,
208209
accessibilityRole: true,
209210
accessibilityState: true,
210211
nativeID: true,

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle {
271271
* Controls whether the View can be the target of touch events.
272272
*/
273273
pointerEvents?: 'box-none' | 'none' | 'box-only' | 'auto' | undefined;
274+
transformOrigin?: Array<string | number> | string | undefined;
274275
}
275276

276277
export type FontVariant =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`processTransformOrigin validation only accepts three values 1`] = `"Transform origin must have exactly 3 values."`;
4+
5+
exports[`processTransformOrigin validation only accepts three values 2`] = `"Transform origin must have exactly 3 values."`;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @oncall react_native
9+
*/
10+
11+
import processTransformOrigin from '../processTransformOrigin';
12+
13+
describe('processTransformOrigin', () => {
14+
describe('validation', () => {
15+
it('only accepts three values', () => {
16+
expect(() => {
17+
processTransformOrigin([]);
18+
}).toThrowErrorMatchingSnapshot();
19+
expect(() => {
20+
processTransformOrigin(['50%', '50%']);
21+
}).toThrowErrorMatchingSnapshot();
22+
});
23+
24+
it('should transform a string', () => {
25+
expect(processTransformOrigin('50% 50% 5px')).toEqual(['50%', '50%', 5]);
26+
});
27+
28+
it('should handle one value', () => {
29+
expect(processTransformOrigin('top')).toEqual(['50%', 0, 0]);
30+
expect(processTransformOrigin('right')).toEqual(['100%', '50%', 0]);
31+
expect(processTransformOrigin('bottom')).toEqual(['50%', '100%', 0]);
32+
expect(processTransformOrigin('left')).toEqual([0, '50%', 0]);
33+
});
34+
35+
it('should handle two values', () => {
36+
expect(processTransformOrigin('30% top')).toEqual(['30%', 0, 0]);
37+
expect(processTransformOrigin('right 30%')).toEqual(['100%', '30%', 0]);
38+
expect(processTransformOrigin('30% bottom')).toEqual(['30%', '100%', 0]);
39+
expect(processTransformOrigin('left 30%')).toEqual([0, '30%', 0]);
40+
});
41+
42+
it('should handle two keywords in either order', () => {
43+
expect(processTransformOrigin('right bottom')).toEqual([
44+
'100%',
45+
'100%',
46+
0,
47+
]);
48+
expect(processTransformOrigin('bottom right')).toEqual([
49+
'100%',
50+
'100%',
51+
0,
52+
]);
53+
expect(processTransformOrigin('right bottom 5px')).toEqual([
54+
'100%',
55+
'100%',
56+
5,
57+
]);
58+
expect(processTransformOrigin('bottom right 5px')).toEqual([
59+
'100%',
60+
'100%',
61+
5,
62+
]);
63+
});
64+
65+
it('should not allow specifying same position twice', () => {
66+
expect(() => {
67+
processTransformOrigin('top top');
68+
}).toThrowErrorMatchingInlineSnapshot(
69+
`"Could not parse transform-origin: top top"`,
70+
);
71+
expect(() => {
72+
processTransformOrigin('right right');
73+
}).toThrowErrorMatchingInlineSnapshot(
74+
`"Transform-origin right can only be used for x-position"`,
75+
);
76+
expect(() => {
77+
processTransformOrigin('bottom bottom');
78+
}).toThrowErrorMatchingInlineSnapshot(
79+
`"Could not parse transform-origin: bottom bottom"`,
80+
);
81+
expect(() => {
82+
processTransformOrigin('left left');
83+
}).toThrowErrorMatchingInlineSnapshot(
84+
`"Transform-origin left can only be used for x-position"`,
85+
);
86+
expect(() => {
87+
processTransformOrigin('top bottom');
88+
}).toThrowErrorMatchingInlineSnapshot(
89+
`"Could not parse transform-origin: top bottom"`,
90+
);
91+
expect(() => {
92+
processTransformOrigin('left right');
93+
}).toThrowErrorMatchingInlineSnapshot(
94+
`"Transform-origin right can only be used for x-position"`,
95+
);
96+
});
97+
98+
it('should handle three values', () => {
99+
expect(processTransformOrigin('30% top 10px')).toEqual(['30%', 0, 10]);
100+
expect(processTransformOrigin('right 30% 10px')).toEqual([
101+
'100%',
102+
'30%',
103+
10,
104+
]);
105+
expect(processTransformOrigin('30% bottom 10px')).toEqual([
106+
'30%',
107+
'100%',
108+
10,
109+
]);
110+
expect(processTransformOrigin('left 30% 10px')).toEqual([0, '30%', 10]);
111+
});
112+
113+
it('should enforce two value ordering', () => {
114+
expect(() => {
115+
processTransformOrigin('top 30%');
116+
}).toThrowErrorMatchingInlineSnapshot(
117+
`"Could not parse transform-origin: top 30%"`,
118+
);
119+
});
120+
121+
it('should not allow percents for z-position', () => {
122+
expect(() => {
123+
processTransformOrigin('top 30% 30%');
124+
}).toThrowErrorMatchingInlineSnapshot(
125+
`"Could not parse transform-origin: top 30% 30%"`,
126+
);
127+
expect(() => {
128+
processTransformOrigin('top 30% center');
129+
}).toThrowErrorMatchingInlineSnapshot(
130+
`"Could not parse transform-origin: top 30% center"`,
131+
);
132+
});
133+
});
134+
});

packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,18 @@ export type ____TransformStyle_Internal = $ReadOnly<{|
5252
|},
5353
>
5454
| string,
55+
/**
56+
* `transformOrigin` accepts an array with 3 elements - each element either being
57+
* a number, or a string of a number ending with `%`. The last element cannot be
58+
* a percentage, so must be a number.
59+
*
60+
* E.g. transformOrigin: ['30%', '80%', 15]
61+
*
62+
* Alternatively accepts a string of the CSS syntax. You must use `%` or `px`.
63+
*
64+
* E.g. transformOrigin: '30% 80% 15px'
65+
*/
66+
transformOrigin?:
67+
| [string | number, string | number, string | number]
68+
| string,
5569
|}>;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @flow
9+
*/
10+
11+
import invariant from 'invariant';
12+
13+
const INDEX_X = 0;
14+
const INDEX_Y = 1;
15+
const INDEX_Z = 2;
16+
17+
/* eslint-disable no-labels */
18+
export default function processTransformOrigin(
19+
transformOrigin: Array<string | number> | string,
20+
): Array<string | number> {
21+
if (typeof transformOrigin === 'string') {
22+
const transformOriginString = transformOrigin;
23+
const regex = /(top|bottom|left|right|center|\d+(?:%|px)|0)/gi;
24+
const transformOriginArray: Array<string | number> = ['50%', '50%', 0];
25+
26+
let index = INDEX_X;
27+
let matches;
28+
outer: while ((matches = regex.exec(transformOriginString))) {
29+
let nextIndex = index + 1;
30+
31+
const value = matches[0];
32+
const valueLower = value.toLowerCase();
33+
34+
switch (valueLower) {
35+
case 'left':
36+
case 'right': {
37+
invariant(
38+
index === INDEX_X,
39+
'Transform-origin %s can only be used for x-position',
40+
value,
41+
);
42+
transformOriginArray[INDEX_X] = valueLower === 'left' ? 0 : '100%';
43+
break;
44+
}
45+
case 'top':
46+
case 'bottom': {
47+
invariant(
48+
index !== INDEX_Z,
49+
'Transform-origin %s can only be used for y-position',
50+
value,
51+
);
52+
transformOriginArray[INDEX_Y] = valueLower === 'top' ? 0 : '100%';
53+
54+
// Handle [[ center | left | right ] && [ center | top | bottom ]] <length>?
55+
if (index === INDEX_X) {
56+
const horizontal = regex.exec(transformOriginString);
57+
if (horizontal == null) {
58+
break outer;
59+
}
60+
61+
switch (horizontal[0].toLowerCase()) {
62+
case 'left':
63+
transformOriginArray[INDEX_X] = 0;
64+
break;
65+
case 'right':
66+
transformOriginArray[INDEX_X] = '100%';
67+
break;
68+
case 'center':
69+
transformOriginArray[INDEX_X] = '50%';
70+
break;
71+
default:
72+
invariant(
73+
false,
74+
'Could not parse transform-origin: %s',
75+
transformOriginString,
76+
);
77+
}
78+
nextIndex = INDEX_Z;
79+
}
80+
81+
break;
82+
}
83+
case 'center': {
84+
invariant(
85+
index !== INDEX_Z,
86+
'Transform-origin value %s cannot be used for z-position',
87+
value,
88+
);
89+
transformOriginArray[index] = '50%';
90+
break;
91+
}
92+
default: {
93+
if (value.endsWith('%')) {
94+
transformOriginArray[index] = value;
95+
} else {
96+
transformOriginArray[index] = parseFloat(value); // Remove `px`
97+
}
98+
break;
99+
}
100+
}
101+
102+
index = nextIndex;
103+
}
104+
105+
transformOrigin = transformOriginArray;
106+
}
107+
108+
if (__DEV__) {
109+
_validateTransformOrigin(transformOrigin);
110+
}
111+
112+
return transformOrigin;
113+
}
114+
115+
function _validateTransformOrigin(transformOrigin: Array<string | number>) {
116+
invariant(
117+
transformOrigin.length === 3,
118+
'Transform origin must have exactly 3 values.',
119+
);
120+
const [x, y, z] = transformOrigin;
121+
invariant(
122+
typeof x === 'number' || (typeof x === 'string' && x.endsWith('%')),
123+
'Transform origin x-position must be a number. Passed value: %s.',
124+
x,
125+
);
126+
invariant(
127+
typeof y === 'number' || (typeof y === 'string' && y.endsWith('%')),
128+
'Transform origin y-position must be a number. Passed value: %s.',
129+
y,
130+
);
131+
invariant(
132+
typeof z === 'number',
133+
'Transform origin z-position must be a number. Passed value: %s.',
134+
z,
135+
);
136+
}

packages/react-native/Libraries/StyleSheet/splitLayoutProps.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default function splitLayoutProps(props: ?____ViewStyle_Internal): {
4949
case 'bottom':
5050
case 'top':
5151
case 'transform':
52+
case 'transformOrigin':
5253
case 'rowGap':
5354
case 'columnGap':
5455
case 'gap':

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
261261
}
262262

263263
// `transform`
264-
if (oldViewProps.transform != newViewProps.transform &&
264+
if ((oldViewProps.transform != newViewProps.transform ||
265+
oldViewProps.transformOrigin != newViewProps.transformOrigin) &&
265266
![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) {
266-
self.layer.transform = RCTCATransform3DFromTransformMatrix(newViewProps.transform);
267+
auto newTransform = newViewProps.resolveTransform(_layoutMetrics);
268+
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
267269
self.layer.allowsEdgeAntialiasing = newViewProps.transform != Transform::Identity();
268270
}
269271

@@ -397,6 +399,11 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
397399
if (_contentView) {
398400
_contentView.frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame());
399401
}
402+
403+
if (_props->transformOrigin.isSet()) {
404+
auto newTransform = _props->resolveTransform(layoutMetrics);
405+
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
406+
}
400407
}
401408

402409
- (BOOL)isJSResponder

packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,10 +311,14 @@ - (void)synchronouslyUpdateViewOnUIThread:(ReactTag)reactTag
311311

312312
const auto &newViewProps = static_cast<const ViewProps &>(*newProps);
313313

314-
if (props[@"transform"] &&
315-
!CATransform3DEqualToTransform(
316-
RCTCATransform3DFromTransformMatrix(newViewProps.transform), componentView.layer.transform)) {
317-
componentView.layer.transform = RCTCATransform3DFromTransformMatrix(newViewProps.transform);
314+
if (props[@"transform"]) {
315+
auto layoutMetrics = LayoutMetrics();
316+
layoutMetrics.frame.size.width = componentView.layer.bounds.size.width;
317+
layoutMetrics.frame.size.height = componentView.layer.bounds.size.height;
318+
CATransform3D newTransform = RCTCATransform3DFromTransformMatrix(newViewProps.resolveTransform(layoutMetrics));
319+
if (!CATransform3DEqualToTransform(newTransform, componentView.layer.transform)) {
320+
componentView.layer.transform = newTransform;
321+
}
318322
}
319323
if (props[@"opacity"] && componentView.layer.opacity != (float)newViewProps.opacity) {
320324
componentView.layer.opacity = newViewProps.opacity;

0 commit comments

Comments
 (0)