diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index 13d8db56a5fe67..43d2b4f9905f54 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,7 +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 61628802a857f2..2d49c0290d84d1 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 8339fe2d20a52a..59849dfec5b4ce 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -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 | undefined; } export type FontVariant = 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..8d6455d0b55cbc --- /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."`; \ No newline at end of file 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..4b30b4d6ec1f12 100644 --- a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js +++ b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js @@ -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, |}>; 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/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index ed5257f5c99689..38f3da01ca769c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -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(); } @@ -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 diff --git a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm index 93a3945568931e..837925d2a4f3af 100644 --- a/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm +++ b/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm @@ -311,10 +311,14 @@ - (void)synchronouslyUpdateViewOnUIThread:(ReactTag)reactTag const auto &newViewProps = static_cast(*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; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 1a0b002b24d50c..c0ff185f27a92b 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -18,6 +18,36 @@ namespace facebook::react { +namespace { + +std::array getTranslateForTransformOrigin( + float viewWidth, + float viewHeight, + TransformOrigin transformOrigin) { + float viewCenterX = viewWidth / 2; + float viewCenterY = viewHeight / 2; + + std::array origin = {viewCenterX, viewCenterY, transformOrigin.z}; + + for (size_t i = 0; i < transformOrigin.xy.size(); ++i) { + auto ¤tOrigin = transformOrigin.xy[i]; + if (currentOrigin.unit == UnitType::Point) { + origin[i] = currentOrigin.value; + } else if (currentOrigin.unit == UnitType::Percent) { + origin[i] = + ((i == 0) ? viewWidth : viewHeight) * currentOrigin.value / 100.0f; + } + } + + float newTranslateX = -viewCenterX + origin[0]; + float newTranslateY = -viewCenterY + origin[1]; + float newTranslateZ = origin[2]; + + return std::array{newTranslateX, newTranslateY, newTranslateZ}; +} + +} // namespace + BaseViewProps::BaseViewProps( const PropsParserContext &context, const BaseViewProps &sourceProps, @@ -119,6 +149,15 @@ BaseViewProps::BaseViewProps( "transform", sourceProps.transform, {})), + transformOrigin( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.transformOrigin + : convertRawProp( + context, + rawProps, + "transformOrigin", + sourceProps.transformOrigin, + {})), backfaceVisibility( CoreFeatures::enablePropIteratorSetter ? sourceProps.backfaceVisibility @@ -346,6 +385,25 @@ BorderMetrics BaseViewProps::resolveBorderMetrics( }; } +Transform BaseViewProps::resolveTransform( + LayoutMetrics const &layoutMetrics) const { + float viewWidth = layoutMetrics.frame.size.width; + float viewHeight = layoutMetrics.frame.size.height; + if (!transformOrigin.isSet() || (viewWidth == 0 && viewHeight == 0)) { + return transform; + } + std::array translateOffsets = + getTranslateForTransformOrigin(viewWidth, viewHeight, transformOrigin); + auto newTransform = Transform::Translate( + translateOffsets[0], translateOffsets[1], translateOffsets[2]); + newTransform = newTransform * transform; + newTransform = + newTransform * + Transform::Translate( + -translateOffsets[0], -translateOffsets[1], -translateOffsets[2]); + return newTransform; +} + bool BaseViewProps::getClipsContentToBounds() const { return yogaStyle.overflow() != YGOverflowVisible; } diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index d79a50db7d0b3a..31fd1007964fef 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -55,6 +55,7 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { // Transform Transform transform{}; + TransformOrigin transformOrigin{}; BackfaceVisibility backfaceVisibility{}; bool shouldRasterize{}; std::optional zIndex{}; @@ -75,10 +76,9 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { #pragma mark - Convenience Methods BorderMetrics resolveBorderMetrics(const LayoutMetrics &layoutMetrics) const; + Transform resolveTransform(const LayoutMetrics &layoutMetrics) const; bool getClipsContentToBounds() const; -#pragma mark - DebugStringConvertible - #if RN_DEBUG_STRING_CONVERTIBLE SharedDebugStringConvertibleList getDebugProps() const override; #endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ConcreteViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/view/ConcreteViewShadowNode.h index 18df072df5d945..a1c07685564663 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ConcreteViewShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ConcreteViewShadowNode.h @@ -80,7 +80,8 @@ class ConcreteViewShadowNode : public ConcreteShadowNode< } Transform getTransform() const override { - return BaseShadowNode::getConcreteProps().transform; + auto layoutMetrics = BaseShadowNode::getLayoutMetrics(); + return BaseShadowNode::getConcreteProps().resolveTransform(layoutMetrics); } private: diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h index f9f9e4a2f98958..ea9d3b576649b8 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h @@ -16,10 +16,11 @@ #include #include #include +#include #include #include -#include #include +#include #include #include @@ -556,6 +557,47 @@ inline void fromRawValue( result = transformMatrix; } +inline void fromRawValue( + const PropsParserContext & /*context*/, + const RawValue &value, + TransformOrigin &result) { + react_native_expect(value.hasType>()); + auto origins = (std::vector)value; + + TransformOrigin transformOrigin; + + const size_t maxIndex = 2; + + for (size_t i = 0; i < std::min(origins.size(), maxIndex); i++) { + const auto &origin = origins[i]; + if (origin.hasType()) { + auto originFloat = (float)origin; + if (std::isfinite(originFloat)) { + transformOrigin.xy[i] = ValueUnit(originFloat, UnitType::Point); + } else { + transformOrigin.xy[i] = ValueUnit(0.0f, UnitType::Undefined); + } + } else if (origin.hasType()) { + const auto stringValue = (std::string)origin; + + if (stringValue.back() == '%') { + auto tryValue = folly::tryTo( + std::string_view(stringValue).substr(0, stringValue.length() - 1)); + if (tryValue.hasValue()) { + transformOrigin.xy[i] = + ValueUnit(tryValue.value(), UnitType::Percent); + } + } + } + } + + if (origins.size() >= 3 && origins[2].hasType()) { + transformOrigin.z = (Float)origins[2]; + } + + result = transformOrigin; +} + inline void fromRawValue( const PropsParserContext &context, const RawValue &value, diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/Transform.h b/packages/react-native/ReactCommon/react/renderer/graphics/Transform.h index 16ef5f4056e7ce..4df5e1fb3dad16 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/Transform.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/Transform.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #ifdef ANDROID @@ -43,6 +44,7 @@ enum class TransformOperationType { Rotate, Skew }; + struct TransformOperation { TransformOperationType type; Float x; @@ -50,6 +52,22 @@ struct TransformOperation { Float z; }; +struct TransformOrigin { + std::array xy; + float z = 0.0f; + + bool operator==(const TransformOrigin &other) const { + return xy[0] == other.xy[0] && xy[1] == other.xy[1] && z == other.z; + } + bool operator!=(const TransformOrigin &other) const { + return !(*this == other); + } + bool isSet() const { + return xy[0].value != 0.0f || xy[0].unit != UnitType::Undefined || + xy[1].value != 0.0f || xy[1].unit != UnitType::Undefined || z != 0.0f; + } +}; + /* * Defines transform matrix to apply affine transformations. */ diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/ValueUnit.h b/packages/react-native/ReactCommon/react/renderer/graphics/ValueUnit.h new file mode 100644 index 00000000000000..73b55ee10dca97 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/graphics/ValueUnit.h @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#pragma once + +namespace facebook::react { + +enum class UnitType { + Undefined, + Point, + Percent, +}; + +struct ValueUnit { + float value{0.0f}; + UnitType unit{UnitType::Undefined}; + + ValueUnit() = default; + ValueUnit(float v, UnitType u) : value(v), unit(u) {} + + bool operator==(const ValueUnit &other) const { + return value == other.value && unit == other.unit; + } + bool operator!=(const ValueUnit &other) const { + return !(*this == other); + } +}; +} // namespace facebook::react diff --git a/packages/rn-tester/js/examples/Transform/TransformExample.js b/packages/rn-tester/js/examples/Transform/TransformExample.js index 1358005c94c314..d0a2e7113c5e79 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 left', + }, }); exports.title = 'Transforms'; @@ -346,4 +388,11 @@ exports.examples = [ ); }, }, + { + title: 'Transform origin', + description: "transformOrigin: 'top left'", + render(): Node { + return ; + }, + }, ];