diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index 13d8db56a5fe67..a170e0b7ea3f65 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -111,6 +111,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { * Transform */ transform: {process: processTransform}, + transformOrigin: true, /** * 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/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/RCTConvert+Transform.h b/packages/react-native/React/Views/RCTConvert+Transform.h index 9ad72aef37e6fa..ea416b8e802870 100644 --- a/packages/react-native/React/Views/RCTConvert+Transform.h +++ b/packages/react-native/React/Views/RCTConvert+Transform.h @@ -10,5 +10,6 @@ @interface RCTConvert (Transform) + (CATransform3D)CATransform3D:(id)json; ++ (CATransform3D)CATransform3D:(id)json viewWidth: (CGFloat) viewWidth viewHeight: (CGFloat) viewHeight transformOrigin: (NSString*) transformOrigin; @end diff --git a/packages/react-native/React/Views/RCTConvert+Transform.m b/packages/react-native/React/Views/RCTConvert+Transform.m index 348d4ec4abe8cc..5e4f86cfc6f9ce 100644 --- a/packages/react-native/React/Views/RCTConvert+Transform.m +++ b/packages/react-native/React/Views/RCTConvert+Transform.m @@ -9,6 +9,46 @@ static const NSUInteger kMatrixArrayLength = 4 * 4; +static NSArray* getTranslateForTransformOrigin(CGFloat viewWidth, CGFloat viewHeight, NSString *transformOrigin) { + if (transformOrigin.length == 0 || (viewWidth == 0 && viewHeight == 0)) { + return nil; + } + + CGFloat viewCenterX = viewWidth / 2; + CGFloat viewCenterY = viewHeight / 2; + + CGFloat origin[3] = {viewCenterX, viewCenterY, 0.0}; + + NSArray *parts = [transformOrigin componentsSeparatedByString:@" "]; + for (NSInteger i = 0; i < parts.count && i < 3; i++) { + NSString *part = parts[i]; + NSRange percentRange = [part rangeOfString:@"%"]; + BOOL isPercent = percentRange.location != NSNotFound; + if (isPercent) { + CGFloat val = [[part substringToIndex:percentRange.location] floatValue]; + origin[i] = (i == 0 ? viewWidth : viewHeight) * val / 100.0; + } else if ([part isEqualToString:@"top"]) { + origin[1] = 0.0; + } else if ([part isEqualToString:@"bottom"]) { + origin[1] = viewHeight; + } else if ([part isEqualToString:@"left"]) { + origin[0] = 0.0; + } else if ([part isEqualToString:@"right"]) { + origin[0] = viewWidth; + } else if ([part isEqualToString:@"center"]) { + continue; + } else { + origin[i] = [part floatValue]; + } + } + + CGFloat newTranslateX = -viewCenterX + origin[0]; + CGFloat newTranslateY = -viewCenterY + origin[1]; + CGFloat newTranslateZ = origin[2]; + + return @[@(newTranslateX), @(newTranslateY), @(newTranslateZ)]; +} + @implementation RCTConvert (Transform) + (CGFloat)convertToRadians:(id)json @@ -47,6 +87,12 @@ + (CATransform3D)CATransform3DFromMatrix:(id)json } + (CATransform3D)CATransform3D:(id)json +{ + CATransform3D transform = [self CATransform3D:json viewWidth:0 viewHeight:0 transformOrigin:nil]; + return transform; +} + ++ (CATransform3D)CATransform3D:(id)json viewWidth: (CGFloat) viewWidth viewHeight: (CGFloat) viewHeight transformOrigin: (NSString*) transformOrigin { CATransform3D transform = CATransform3DIdentity; if (!json) { @@ -66,6 +112,12 @@ + (CATransform3D)CATransform3D:(id)json CGFloat zeroScaleThreshold = FLT_EPSILON; CATransform3D next; + + NSArray *offsets = getTranslateForTransformOrigin(viewWidth, viewHeight, transformOrigin); + if (offsets) { + transform = CATransform3DTranslate(transform, [offsets[0] floatValue], [offsets[1] floatValue], [offsets[2] floatValue]); + } + for (NSDictionary *transformConfig in (NSArray *)json) { if (transformConfig.count != 1) { RCTLogConvertError(json, @"a CATransform3D. You must specify exactly one property per transform object."); @@ -141,6 +193,11 @@ + (CATransform3D)CATransform3D:(id)json RCTLogInfo(@"Unsupported transform type for a CATransform3D: %@.", property); } } + + if (offsets) { + transform = CATransform3DTranslate(transform, -[offsets[0] floatValue], -[offsets[1] floatValue], -[offsets[2] floatValue]); + } + return transform; } diff --git a/packages/react-native/React/Views/RCTView.h b/packages/react-native/React/Views/RCTView.h index 200d8b451bf59e..eaaecf665eeff6 100644 --- a/packages/react-native/React/Views/RCTView.h +++ b/packages/react-native/React/Views/RCTView.h @@ -135,4 +135,7 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait; @property (nonatomic, assign) RCTBubblingEventBlock onGotPointerCapture; @property (nonatomic, assign) RCTBubblingEventBlock onLostPointerCapture; +@property (nonatomic, strong) id rawTransform; +@property (nonatomic, copy) NSString* transformOrigin; + @end diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index 5464e06124d7e6..7cc51ba7a70fea 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -18,6 +18,7 @@ #import "RCTLog.h" #import "RCTViewUtils.h" #import "UIView+React.h" +#import "RCTConvert+Transform.h" RCT_MOCK_DEF(RCTView, RCTContentInsets); #define RCTContentInsets RCT_MOCK_USE(RCTView, RCTContentInsets) @@ -785,6 +786,10 @@ - (void)reactSetFrame:(CGRect)frame [super reactSetFrame:frame]; if (!CGSizeEqualToSize(self.bounds.size, oldSize)) { [self.layer setNeedsDisplay]; + // Update transform for transform origin due to change in view dimension + if (self.transformOrigin.length > 0) { + self.layer.transform = [RCTConvert CATransform3D:self.rawTransform viewWidth:self.bounds.size.width viewHeight:self.bounds.size.height transformOrigin: self.transformOrigin]; + } } } diff --git a/packages/react-native/React/Views/RCTViewManager.m b/packages/react-native/React/Views/RCTViewManager.m index eb3ff9c00d882e..b940b25dcd8844 100644 --- a/packages/react-native/React/Views/RCTViewManager.m +++ b/packages/react-native/React/Views/RCTViewManager.m @@ -218,12 +218,18 @@ - (RCTShadowView *)shadowView RCT_CUSTOM_VIEW_PROPERTY(transform, CATransform3D, RCTView) { - view.layer.transform = json ? [RCTConvert CATransform3D:json] : defaultView.layer.transform; + view.rawTransform = json; + view.layer.transform = json ? [RCTConvert CATransform3D:view.rawTransform viewWidth:view.bounds.size.width viewHeight:view.bounds.size.height transformOrigin: view.transformOrigin] : 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_CUSTOM_VIEW_PROPERTY(transformOrigin, NSString, RCTView){ + view.transformOrigin = json; + view.layer.transform = [RCTConvert CATransform3D:view.rawTransform viewWidth:view.bounds.size.width viewHeight:view.bounds.size.height transformOrigin: view.transformOrigin]; +} + RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) { UIAccessibilityTraits accessibilityRoleTraits = 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 ; + }, + }, ];