Skip to content

Commit 60803e0

Browse files
committed
Implement transform-origin for old arch iOS
1 parent e64756a commit 60803e0

File tree

12 files changed

+431
-5
lines changed

12 files changed

+431
-5
lines changed

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

+2
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,6 +112,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
111112
* Transform
112113
*/
113114
transform: {process: processTransform},
115+
transformOrigin: {process: processTransformOrigin},
114116

115117
/**
116118
* View

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

+1
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

+1
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export interface TransformsStyle {
196196
>[]
197197
| string
198198
| undefined;
199+
transformOrigin?: Array<string | number> | string | undefined;
199200
/**
200201
* @deprecated Use matrix in transform prop instead.
201202
*/
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."`;
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

+12
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,16 @@ 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?: Array<string | number> | string,
5567
|}>;
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

+1
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/Views/RCTViewManager.m

+32-4
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,30 @@ @implementation RCTConvert (UIAccessibilityTraits)
121121
UIAccessibilityTraitNone,
122122
unsignedLongLongValue)
123123

124+
+ (CATransform3D)transformOrigin:(id)json
125+
{
126+
CATransform3D transformOrigin = CATransform3DMakeScale(0, 0, 0);
127+
id anchorPointX = json[0];
128+
id anchorPointY = json[1];
129+
id anchorPointZ = json[2];
130+
131+
if ([anchorPointX isKindOfClass:NSString.class] && [(NSString *)anchorPointX hasSuffix:@"%"]) {
132+
transformOrigin.m11 = [anchorPointX doubleValue] / 100;
133+
} else {
134+
transformOrigin.m14 = [RCTConvert CGFloat:anchorPointX];
135+
}
136+
137+
if ([anchorPointY isKindOfClass:NSString.class] && [(NSString *)anchorPointY hasSuffix:@"%"]) {
138+
transformOrigin.m22 = [anchorPointY doubleValue] / 100;
139+
} else {
140+
transformOrigin.m24 = [RCTConvert CGFloat:anchorPointY];
141+
}
142+
143+
transformOrigin.m34 = [RCTConvert CGFloat:anchorPointZ];
144+
145+
return transformOrigin;
146+
}
147+
124148
@end
125149

126150
@implementation RCTViewManager
@@ -218,10 +242,14 @@ - (RCTShadowView *)shadowView
218242

219243
RCT_CUSTOM_VIEW_PROPERTY(transform, CATransform3D, RCTView)
220244
{
221-
view.layer.transform = json ? [RCTConvert CATransform3D:json] : defaultView.layer.transform;
222-
// Enable edge antialiasing in rotation, skew, or perspective transforms
223-
view.layer.allowsEdgeAntialiasing =
224-
view.layer.transform.m12 != 0.0f || view.layer.transform.m21 != 0.0f || view.layer.transform.m34 != 0.0f;
245+
CATransform3D transform = json ? [RCTConvert CATransform3D:json] : defaultView.reactTransformOrigin;
246+
[view setReactTransform:transform];
247+
}
248+
249+
RCT_CUSTOM_VIEW_PROPERTY(transformOrigin, NSArray, RCTView)
250+
{
251+
CATransform3D transformOrigin = json ? [RCTConvert transformOrigin:json] : defaultView.reactTransformOrigin;
252+
[view setReactTransformOrigin:transformOrigin];
225253
}
226254

227255
RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView)

packages/react-native/React/Views/UIView+React.h

+7
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@
104104
@property (nonatomic, readonly) UIEdgeInsets reactCompoundInsets;
105105
@property (nonatomic, readonly) CGRect reactContentFrame;
106106

107+
@property (nonatomic, assign) CATransform3D reactTransform;
108+
/**
109+
* Matrix form of transform-origin.
110+
* Vector form is calculated by multiplying matrix with the vector `[width, height, 0]`.
111+
*/
112+
@property (nonatomic, assign) CATransform3D reactTransformOrigin;
113+
107114
/**
108115
* The (sub)view which represents this view in terms of accessibility.
109116
* ViewManager will apply all accessibility properties directly to this view.

0 commit comments

Comments
 (0)