Skip to content

Commit

Permalink
Implement transform-origin for old arch iOS
Browse files Browse the repository at this point in the history
  • Loading branch information
jacobp100 committed Jul 25, 2023
1 parent e64756a commit cc0307a
Show file tree
Hide file tree
Showing 11 changed files with 432 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import processAspectRatio from '../../StyleSheet/processAspectRatio';
import processColor from '../../StyleSheet/processColor';
import processFontVariant from '../../StyleSheet/processFontVariant';
import processTransform from '../../StyleSheet/processTransform';
import processTransformOrigin from '../../StyleSheet/processTransformOrigin';
import sizesDiffer from '../../Utilities/differ/sizesDiffer';

const colorAttributes = {process: processColor};
Expand Down Expand Up @@ -111,6 +112,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
* Transform
*/
transform: {process: processTransform},
transformOrigin: {process: processTransformOrigin},

/**
* View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ const validAttributesForNonEventProps = {
overflow: true,
shouldRasterizeIOS: true,
transform: {diff: require('../Utilities/differ/matricesDiffer')},
transformOrigin: true,
accessibilityRole: true,
accessibilityState: true,
nativeID: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface TransformsStyle {
>[]
| string
| undefined;
transformOrigin?: Array<string | number> | string | undefined;
/**
* @deprecated Use matrix in transform prop instead.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @oncall react_native
*/

import processTransformOrigin from '../processTransformOrigin';

describe('processTransformOrigin', () => {
describe('validation', () => {
it('only accepts three values', () => {
expect(() => {
processTransformOrigin([]);
}).toThrowErrorMatchingSnapshot();
expect(() => {
processTransformOrigin(['50%', '50%']);
}).toThrowErrorMatchingSnapshot();
});

it('should transform a string', () => {
expect(processTransformOrigin('50% 50% 5px')).toEqual(['50%', '50%', 5]);
});

it('should handle one value', () => {
expect(processTransformOrigin('top')).toEqual(['50%', 0, 0]);
expect(processTransformOrigin('right')).toEqual(['100%', '50%', 0]);
expect(processTransformOrigin('bottom')).toEqual(['50%', '100%', 0]);
expect(processTransformOrigin('left')).toEqual([0, '50%', 0]);
});

it('should handle two values', () => {
expect(processTransformOrigin('30% top')).toEqual(['30%', 0, 0]);
expect(processTransformOrigin('right 30%')).toEqual(['100%', '30%', 0]);
expect(processTransformOrigin('30% bottom')).toEqual(['30%', '100%', 0]);
expect(processTransformOrigin('left 30%')).toEqual([0, '30%', 0]);
});

it('should handle two keywords in either order', () => {
expect(processTransformOrigin('right bottom')).toEqual([
'100%',
'100%',
0,
]);
expect(processTransformOrigin('bottom right')).toEqual([
'100%',
'100%',
0,
]);
expect(processTransformOrigin('right bottom 5px')).toEqual([
'100%',
'100%',
5,
]);
expect(processTransformOrigin('bottom right 5px')).toEqual([
'100%',
'100%',
5,
]);
});

it('should not allow specifying same position twice', () => {
expect(() => {
processTransformOrigin('top top');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: top top"`,
);
expect(() => {
processTransformOrigin('right right');
}).toThrowErrorMatchingInlineSnapshot(
`"Transform-origin right can only be used for x-position"`,
);
expect(() => {
processTransformOrigin('bottom bottom');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: bottom bottom"`,
);
expect(() => {
processTransformOrigin('left left');
}).toThrowErrorMatchingInlineSnapshot(
`"Transform-origin left can only be used for x-position"`,
);
expect(() => {
processTransformOrigin('top bottom');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: top bottom"`,
);
expect(() => {
processTransformOrigin('left right');
}).toThrowErrorMatchingInlineSnapshot(
`"Transform-origin right can only be used for x-position"`,
);
});

it('should handle three values', () => {
expect(processTransformOrigin('30% top 10px')).toEqual(['30%', 0, 10]);
expect(processTransformOrigin('right 30% 10px')).toEqual([
'100%',
'30%',
10,
]);
expect(processTransformOrigin('30% bottom 10px')).toEqual([
'30%',
'100%',
10,
]);
expect(processTransformOrigin('left 30% 10px')).toEqual([0, '30%', 10]);
});

it('should enforce two value ordering', () => {
expect(() => {
processTransformOrigin('top 30%');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: top 30%"`,
);
});

it('should not allow percents for z-position', () => {
expect(() => {
processTransformOrigin('top 30% 30%');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: top 30% 30%"`,
);
expect(() => {
processTransformOrigin('top 30% center');
}).toThrowErrorMatchingInlineSnapshot(
`"Could not parse transform-origin: top 30% center"`,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,16 @@ export type ____TransformStyle_Internal = $ReadOnly<{|
|},
>
| string,
/**
* `transformOrigin` accepts an array with 3 elements - each element either being
* a number, or a string of a number ending with `%`. The last element cannot be
* a percentage, so must be a number.
*
* E.g. transformOrigin: ['30%', '80%', 15]
*
* Alternatively accepts a string of the CSS syntax. You must use `%` or `px`.
*
* E.g. transformOrigin: '30% 80% 15px'
*/
transformOrigin: Array<string | number> | string,
|}>;
135 changes: 135 additions & 0 deletions packages/react-native/Libraries/StyleSheet/processTransformOrigin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/

import invariant from 'invariant';

const INDEX_X = 0;
const INDEX_Y = 1;
const INDEX_Z = 2;

/* eslint-disable no-labels */
export default function processTransformOrigin(
transformOrigin: Array<string | number> | string,
): Array<string | number> {
if (typeof transformOrigin === 'string') {
const regExp = /(top|bottom|left|right|center|\d+(?:%|px)|0)/gi;
const transformOriginArray: Array<string | number> = ['50%', '50%', 0];

let index = INDEX_X;
let match;
outer: while ((match = regExp.exec(transformOrigin))) {
let nextIndex = index + 1;

const value = match[0];
const valueLower = value.toLowerCase();

switch (valueLower) {
case 'left':
case 'right': {
invariant(
index === INDEX_X,
'Transform-origin %s can only be used for x-position',
value,
);
transformOriginArray[INDEX_X] = valueLower === 'left' ? 0 : '100%';
break;
}
case 'top':
case 'bottom': {
invariant(
index !== INDEX_Z,
'Transform-origin %s can only be used for y-position',
value,
);
transformOriginArray[INDEX_Y] = valueLower === 'top' ? 0 : '100%';

// Handle [[ center | left | right ] && [ center | top | bottom ]] <length>?
if (index === INDEX_X) {
const horizontal = regExp.exec(transformOrigin);
if (horizontal == null) {
break outer;
}

switch (horizontal[0].toLowerCase()) {
case 'left':
transformOriginArray[INDEX_X] = 0;
break;
case 'right':
transformOriginArray[INDEX_X] = '100%';
break;
case 'center':
transformOriginArray[INDEX_X] = '50%';
break;
default:
invariant(
false,
'Could not parse transform-origin: %s',
transformOrigin,
);
}
nextIndex = INDEX_Z;
}

break;
}
case 'center': {
invariant(
index !== INDEX_Z,
'Transform-origin value %s cannot be used for z-position',
value,
);
transformOriginArray[index] = '50%';
break;
}
default: {
if (value.endsWith('%')) {
transformOriginArray[index] = value;
} else {
transformOriginArray[index] = parseFloat(value); // Remove `px`
}
break;
}
}

index = nextIndex;
}

transformOrigin = transformOriginArray;
}

if (__DEV__) {
_validateTransformOrigin(transformOrigin);
}

return transformOrigin;
}

function _validateTransformOrigin(transformOrigin: Array<string | number>) {
invariant(
transformOrigin.length === 3,
'Transform origin must have exactly 3 values.',
);
const [x, y, z] = transformOrigin;
invariant(
typeof x === 'number' || (typeof x === 'string' && x.endsWith('%')),
'Transform origin x-position must be a number. Passed value: %s.',
x,
);
invariant(
typeof y === 'number' || (typeof y === 'string' && y.endsWith('%')),
'Transform origin y-position must be a number. Passed value: %s.',
y,
);
invariant(
typeof z === 'number',
'Transform origin z-position must be a number. Passed value: %s.',
z,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function splitLayoutProps(props: ?____ViewStyle_Internal): {
case 'bottom':
case 'top':
case 'transform':
case 'transformOrigin':
case 'rowGap':
case 'columnGap':
case 'gap':
Expand Down
40 changes: 34 additions & 6 deletions packages/react-native/React/Views/RCTViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ @implementation RCTConvert (UIAccessibilityTraits)
UIAccessibilityTraitNone,
unsignedLongLongValue)

+ (CATransform3D)transformOrigin:(id)json
{
CATransform3D transformOrigin = CATransform3DMakeScale(0, 0, 0);
id anchorPointX = json[0];
id anchorPointY = json[1];
id anchorPointZ = json[2];

if ([anchorPointX isKindOfClass:NSString.class] && [(NSString *)anchorPointX hasSuffix:@"%"]) {
transformOrigin.m11 = [anchorPointX doubleValue] / 100;
} else {
transformOrigin.m14 = [RCTConvert CGFloat:anchorPointX];
}

if ([anchorPointY isKindOfClass:NSString.class] && [(NSString *)anchorPointY hasSuffix:@"%"]) {
transformOrigin.m22 = [anchorPointY doubleValue] / 100;
} else {
transformOrigin.m24 = [RCTConvert CGFloat:anchorPointY];
}

transformOrigin.m34 = [RCTConvert CGFloat:anchorPointZ];

return transformOrigin;
}

@end

@implementation RCTViewManager
Expand Down Expand Up @@ -217,12 +241,16 @@ - (RCTShadowView *)shadowView
}

RCT_CUSTOM_VIEW_PROPERTY(transform, CATransform3D, RCTView)
{
view.layer.transform = json ? [RCTConvert CATransform3D:json] : defaultView.layer.transform;
// Enable edge antialiasing in rotation, skew, or perspective transforms
view.layer.allowsEdgeAntialiasing =
view.layer.transform.m12 != 0.0f || view.layer.transform.m21 != 0.0f || view.layer.transform.m34 != 0.0f;
}
{
CATransform3D transform = json ? [RCTConvert CATransform3D:json] : defaultView.reactTransformOrigin;
[view setReactTransform:transform];
}

RCT_CUSTOM_VIEW_PROPERTY(transformOrigin, NSArray, RCTView)
{
CATransform3D transformOrigin = json ? [RCTConvert transformOrigin:json] : defaultView.reactTransformOrigin;
[view setReactTransformOrigin:transformOrigin];
}

RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView)
{
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native/React/Views/UIView+React.h
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@
@property (nonatomic, readonly) UIEdgeInsets reactCompoundInsets;
@property (nonatomic, readonly) CGRect reactContentFrame;

@property (nonatomic, assign) CATransform3D reactTransform;
/**
* Matrix form of transform-origin.
* Vector form is calculated by multiplying matrix with the vector `[width, height, 0]`.
*/
@property (nonatomic, assign) CATransform3D reactTransformOrigin;

/**
* The (sub)view which represents this view in terms of accessibility.
* ViewManager will apply all accessibility properties directly to this view.
Expand Down
Loading

0 comments on commit cc0307a

Please sign in to comment.