diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index 13d8db56a5fe67..79dbeddb741e39 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -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}; @@ -111,6 +112,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { * Transform */ transform: {process: processTransform}, + transformOrigin: {process: processTransformOrigin}, /** * View diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index 92b2959aad14d6..fb02a6990d1f63 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -205,6 +205,7 @@ const validAttributesForNonEventProps = { overflow: true, shouldRasterizeIOS: true, transform: {diff: require('../Utilities/differ/matricesDiffer')}, + transformOrigin: true, accessibilityRole: true, accessibilityState: true, nativeID: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 7956766b34367a..ef9c0f90c56ee1 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -196,6 +196,7 @@ export interface TransformsStyle { >[] | string | undefined; + transformOrigin?: Array | string | undefined; /** * @deprecated Use matrix in transform prop instead. */ diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/__snapshots__/processTransformOrigin-test.js.snap b/packages/react-native/Libraries/StyleSheet/__tests__/__snapshots__/processTransformOrigin-test.js.snap new file mode 100644 index 00000000000000..82adceb24180b9 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/__snapshots__/processTransformOrigin-test.js.snap @@ -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."`; diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-test.js new file mode 100644 index 00000000000000..5a1b06d289cc5a --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-test.js @@ -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"`, + ); + }); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js index 8fa2b271d1cf4e..8e08b0335738f4 100644 --- a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js +++ b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js @@ -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, |}>; diff --git a/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js b/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js new file mode 100644 index 00000000000000..5cad4f6ef57b5a --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js @@ -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, +): Array { + if (typeof transformOrigin === 'string') { + const transformOriginString = transformOrigin; + const regex = /(top|bottom|left|right|center|\d+(?:%|px)|0)/gi; + const transformOriginArray: Array = ['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 ]] ? + 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) { + 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, + ); +} diff --git a/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js b/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js index b92f86d3ad2559..2a1215a0ffd9ca 100644 --- a/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js +++ b/packages/react-native/Libraries/StyleSheet/splitLayoutProps.js @@ -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': diff --git a/packages/react-native/React/Views/RCTTransformOrigin.h b/packages/react-native/React/Views/RCTTransformOrigin.h new file mode 100644 index 00000000000000..1823fd91f1ebfe --- /dev/null +++ b/packages/react-native/React/Views/RCTTransformOrigin.h @@ -0,0 +1,15 @@ +/* + * 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. + */ + +#import +#import + +typedef struct { + YGValue x; + YGValue y; + CGFloat z; +} RCTTransformOrigin; diff --git a/packages/react-native/React/Views/RCTViewManager.m b/packages/react-native/React/Views/RCTViewManager.m index eb3ff9c00d882e..b59b9a566ddf84 100644 --- a/packages/react-native/React/Views/RCTViewManager.m +++ b/packages/react-native/React/Views/RCTViewManager.m @@ -15,6 +15,7 @@ #import "RCTConvert.h" #import "RCTLog.h" #import "RCTShadowView.h" +#import "RCTTransformOrigin.h" #import "RCTUIManager.h" #import "RCTUIManagerUtils.h" #import "RCTUtils.h" @@ -121,6 +122,16 @@ @implementation RCTConvert (UIAccessibilityTraits) UIAccessibilityTraitNone, unsignedLongLongValue) ++ (RCTTransformOrigin)RCTTransformOrigin:(id)json + { + RCTTransformOrigin transformOrigin = { + [RCTConvert YGValue:json[0]], + [RCTConvert YGValue:json[1]], + [RCTConvert CGFloat:json[2]] + }; + return transformOrigin; + } + @end @implementation RCTViewManager @@ -216,13 +227,8 @@ - (RCTShadowView *)shadowView view.layer.shouldRasterize ? [UIScreen mainScreen].scale : defaultView.layer.rasterizationScale; } -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; -} +RCT_REMAP_VIEW_PROPERTY(transform, reactTransform, CATransform3D) +RCT_REMAP_VIEW_PROPERTY(transformOrigin, reactTransformOrigin, RCTTransformOrigin) RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) { diff --git a/packages/react-native/React/Views/UIView+React.h b/packages/react-native/React/Views/UIView+React.h index d378a8320ba1d3..46bd1660aae98d 100644 --- a/packages/react-native/React/Views/UIView+React.h +++ b/packages/react-native/React/Views/UIView+React.h @@ -8,6 +8,7 @@ #import #import +#import #import @class RCTShadowView; @@ -104,6 +105,13 @@ @property (nonatomic, readonly) UIEdgeInsets reactCompoundInsets; @property (nonatomic, readonly) CGRect reactContentFrame; +/** + * The anchorPoint property doesn't work in the same way as on web - updating it updates the frame. + * To work around this, we take both the transform and the transform-origin, and compute it ourselves + */ +@property (nonatomic, assign) CATransform3D reactTransform; +@property (nonatomic, assign) RCTTransformOrigin reactTransformOrigin; + /** * The (sub)view which represents this view in terms of accessibility. * ViewManager will apply all accessibility properties directly to this view. diff --git a/packages/react-native/React/Views/UIView+React.m b/packages/react-native/React/Views/UIView+React.m index 7c6c71829ff69d..7ff7c22f268262 100644 --- a/packages/react-native/React/Views/UIView+React.m +++ b/packages/react-native/React/Views/UIView+React.m @@ -203,6 +203,79 @@ - (void)reactSetFrame:(CGRect)frame self.center = position; self.bounds = bounds; + + updateTransform(self); +} + +#pragma mark - Transforms + +- (CATransform3D)reactTransform +{ + id obj = objc_getAssociatedObject(self, _cmd); + return obj != nil ? [obj CATransform3DValue] : CATransform3DIdentity; +} + +- (void)setReactTransform:(CATransform3D)reactTransform +{ + objc_setAssociatedObject( + self, @selector(reactTransform), @(reactTransform), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + updateTransform(self); +} + +- (RCTTransformOrigin)reactTransformOrigin +{ + id obj = objc_getAssociatedObject(self, _cmd); + if (obj != nil) { + RCTTransformOrigin transformOrigin; + [obj getValue:&transformOrigin]; + return transformOrigin; + } else { + return (RCTTransformOrigin) { + (YGValue){50, YGUnitPercent}, + (YGValue){50, YGUnitPercent}, + 0 + }; + } +} + +- (void)setReactTransformOrigin:(RCTTransformOrigin)reactTransformOrigin +{ + id obj = [NSValue value:&reactTransformOrigin withObjCType:@encode(RCTTransformOrigin)]; + objc_setAssociatedObject( + self, @selector(reactTransformOrigin), obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + updateTransform(self); +} + +static void updateTransform(UIView *view) +{ + CGSize size = view.bounds.size; + RCTTransformOrigin transformOrigin = view.reactTransformOrigin; + + CGFloat anchorPointX = 0; + CGFloat anchorPointY = 0; + CGFloat anchorPointZ = 0; + + if (transformOrigin.x.unit == YGUnitPoint) { + anchorPointX = transformOrigin.x.value - size.width * 0.5; + } else if (transformOrigin.x.unit == YGUnitPercent) { + anchorPointX = (transformOrigin.x.value * 0.01 - 0.5) * size.width; + } + + if (transformOrigin.y.unit == YGUnitPoint) { + anchorPointY = transformOrigin.y.value - size.height * 0.5; + } else if (transformOrigin.y.unit == YGUnitPercent) { + anchorPointY = (transformOrigin.y.value * 0.01 - 0.5) * size.height; + } + + anchorPointZ = transformOrigin.z; + + CATransform3D transform = CATransform3DMakeTranslation(anchorPointX, anchorPointY, anchorPointZ); + transform = CATransform3DConcat(view.reactTransform, transform); + transform = CATransform3DTranslate(transform, -anchorPointX, -anchorPointY, -anchorPointZ); + + view.layer.transform = transform; + view.layer.allowsEdgeAntialiasing = + transform.m12 != 0.0f || transform.m21 != 0.0f || transform.m34 != 0.0f; } - (UIViewController *)reactViewController diff --git a/packages/rn-tester/js/examples/Transform/TransformExample.js b/packages/rn-tester/js/examples/Transform/TransformExample.js index 1358005c94c314..74ca54358c4db5 100644 --- a/packages/rn-tester/js/examples/Transform/TransformExample.js +++ b/packages/rn-tester/js/examples/Transform/TransformExample.js @@ -9,7 +9,7 @@ */ import React, {useEffect, useState} from 'react'; -import {Animated, StyleSheet, Text, View} from 'react-native'; +import {Animated, StyleSheet, Text, View, Easing} from 'react-native'; import type {Node, Element} from 'react'; @@ -50,6 +50,39 @@ function AnimateTransformSingleProp() { ); } +function TransformOriginExample() { + const rotateAnim = React.useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.timing(rotateAnim, { + toValue: 1, + duration: 5000, + easing: Easing.linear, + useNativeDriver: true, + }), + ).start(); + }, [rotateAnim]); + + const spin = rotateAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + + + + ); +} + function Flip() { const [theta] = useState(new Animated.Value(45)); const animate = () => { @@ -234,6 +267,15 @@ const styles = StyleSheet.create({ color: 'white', fontWeight: 'bold', }, + transformOriginWrapper: { + alignItems: 'center', + }, + transformOriginView: { + backgroundColor: 'pink', + width: 100, + height: 100, + transformOrigin: 'top', + }, }); exports.title = 'Transforms'; @@ -346,4 +388,11 @@ exports.examples = [ ); }, }, + { + title: 'Transform origin', + description: "transformOrigin: 'top'", + render(): Node { + return ; + }, + }, ];