Skip to content

Commit

Permalink
feat: ios fabric transform origin (#38559)
Browse files Browse the repository at this point in the history
Summary:
This PR adds transform-origin support for iOS fabric. This PR also incorporates feedback/changes suggested by javache in the original [PR.](#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: #38559

Test Plan: Run iOS RNTester app in old architecture and test transform-origin example in `TransformExample.js`.

Reviewed By: NickGerleman

Differential Revision: D48528363

Pulled By: javache

fbshipit-source-id: 347b7c5896ad19ad24278de81b0e055e4cb01016
  • Loading branch information
intergalacticspacehighway authored and facebook-github-bot committed Sep 1, 2023
1 parent a69bc7f commit 177ef21
Show file tree
Hide file tree
Showing 17 changed files with 517 additions and 11 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,8 @@ 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,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`processTransformOrigin validation only accepts three values 1`] = `"Transform origin must have exactly 3 values."`;

exports[`processTransformOrigin validation only accepts three values 2`] = `"Transform origin must have exactly 3 values."`;
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,18 @@ 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?:
| [string | number, string | number, string | number]
| string,
|}>;
136 changes: 136 additions & 0 deletions packages/react-native/Libraries/StyleSheet/processTransformOrigin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* 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 transformOriginString = transformOrigin;
const regex = /(top|bottom|left|right|center|\d+(?:%|px)|0)/gi;
const transformOriginArray: Array<string | number> = ['50%', '50%', 0];

let index = INDEX_X;
let matches;
outer: while ((matches = regex.exec(transformOriginString))) {
let nextIndex = index + 1;

const value = matches[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 = regex.exec(transformOriginString);
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',
transformOriginString,
);
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
}

// `transform`
if (oldViewProps.transform != newViewProps.transform &&
if ((oldViewProps.transform != newViewProps.transform ||
oldViewProps.transformOrigin != newViewProps.transformOrigin) &&
![_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN containsObject:@"transform"]) {
self.layer.transform = RCTCATransform3DFromTransformMatrix(newViewProps.transform);
auto newTransform = newViewProps.resolveTransform(_layoutMetrics);
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
self.layer.allowsEdgeAntialiasing = newViewProps.transform != Transform::Identity();
}

Expand Down Expand Up @@ -397,6 +399,11 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
if (_contentView) {
_contentView.frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame());
}

if (_props->transformOrigin.isSet()) {
auto newTransform = _props->resolveTransform(layoutMetrics);
self.layer.transform = RCTCATransform3DFromTransformMatrix(newTransform);
}
}

- (BOOL)isJSResponder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,14 @@ - (void)synchronouslyUpdateViewOnUIThread:(ReactTag)reactTag

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

if (props[@"transform"] &&
!CATransform3DEqualToTransform(
RCTCATransform3DFromTransformMatrix(newViewProps.transform), componentView.layer.transform)) {
componentView.layer.transform = RCTCATransform3DFromTransformMatrix(newViewProps.transform);
if (props[@"transform"]) {
auto layoutMetrics = LayoutMetrics();
layoutMetrics.frame.size.width = componentView.layer.bounds.size.width;
layoutMetrics.frame.size.height = componentView.layer.bounds.size.height;
CATransform3D newTransform = RCTCATransform3DFromTransformMatrix(newViewProps.resolveTransform(layoutMetrics));
if (!CATransform3DEqualToTransform(newTransform, componentView.layer.transform)) {
componentView.layer.transform = newTransform;
}
}
if (props[@"opacity"] && componentView.layer.opacity != (float)newViewProps.opacity) {
componentView.layer.opacity = newViewProps.opacity;
Expand Down
Loading

0 comments on commit 177ef21

Please sign in to comment.