Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ios fabric transform origin #38559

Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import processColor from '../../StyleSheet/processColor';
import processFontVariant from '../../StyleSheet/processFontVariant';
import processTransform from '../../StyleSheet/processTransform';
import sizesDiffer from '../../Utilities/differ/sizesDiffer';
import processTransformOrigin from '../../StyleSheet/processTransformOrigin';

const colorAttributes = {process: processColor};

Expand Down Expand Up @@ -111,7 +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 @@ -271,6 +271,7 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle {
* Controls whether the View can be the target of touch events.
*/
pointerEvents?: 'box-none' | 'none' | 'box-only' | 'auto' | undefined;
transformOrigin?: Array<string | number> | string | undefined;
}

export type FontVariant =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{
opacity?: AnimatableNumericValue,
elevation?: number,
pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only',
transformOrigin?: string,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this required? transformOrigin should be included via ____TransformStyle_Internal already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. Updated!

}>;

export type ____ViewStyle_Internal = $ReadOnly<{
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,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,
intergalacticspacehighway marked this conversation as resolved.
Show resolved Hide resolved
|}>;
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,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
}

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

Expand Down Expand Up @@ -397,6 +398,11 @@ - (void)updateLayoutMetrics:(LayoutMetrics const &)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<ViewProps const &>(*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