From 76ee5dd1fe76bc6a6fdc22d295216d729ea239bb Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Thu, 23 Jul 2026 12:27:29 -0500 Subject: [PATCH] Add transform origins and CSS-style composed transforms --- docs/api/api-quick-reference.md | 1 + docs/api/api-reference-elements.md | 13 +- docs/api/api-style-attributes.md | 7 +- .../valdi_tsx/src/NativeTemplateElements.d.ts | 25 +- .../attributes/AttributesBindingContext.kt | 17 + .../AttributesBindingContextNative.kt | 4 + .../attributes/impl/ViewAttributesBinder.kt | 123 ++-- .../snapchat/client/valdi/NativeBridge.java | 2 +- .../AttributesBindingContextWrapper.cpp | 4 + .../AttributesBindingContextWrapper.hpp | 2 + valdi/src/valdi/android/NativeBridge.cpp | 8 + valdi/src/valdi/android/NativeBridge.hpp | 3 + valdi/src/valdi/ios/Categories/UIView+Valdi.m | 48 +- .../src/valdi/ios/SCValdiAttributesBinder.mm | 7 + .../Attributes/AttributesBindingContext.hpp | 2 + .../AttributesBindingContextImpl.cpp | 16 + .../AttributesBindingContextImpl.hpp | 2 + .../Attributes/TransformAttributes.cpp | 628 ++++++++++++++++++ .../Attributes/TransformAttributes.hpp | 21 + .../Attributes/ViewNodeAttributesApplier.cpp | 25 +- valdi/src/valdi/runtime/Context/ViewNode.cpp | 49 +- valdi/src/valdi/runtime/Context/ViewNode.hpp | 18 +- .../Layers/Classes/LayerClass.cpp | 151 +++-- .../Layers/Classes/LayerClass.hpp | 26 +- .../snap_drawing/SnapDrawingViewManager.cpp | 5 + .../Utils/AttributesBinderMacros.hpp | 4 + .../Utils/AttributesBinderUtils.cpp | 7 + .../Utils/AttributesBinderUtils.hpp | 5 + valdi/test/integration/Runtime_tests.cpp | 26 +- .../runtime/AttributeProcessors_tests.cpp | 207 ++++++ valdi/test/runtime/ViewNode_tests.cpp | 134 +++- .../test/src/LimitToViewportTranslate.tsx | 6 +- .../valdi_core/SCValdiAttributesBinderBase.h | 3 + 33 files changed, 1334 insertions(+), 265 deletions(-) create mode 100644 valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp create mode 100644 valdi/src/valdi/runtime/Attributes/TransformAttributes.hpp diff --git a/docs/api/api-quick-reference.md b/docs/api/api-quick-reference.md index 06553490..c8e15ca2 100644 --- a/docs/api/api-quick-reference.md +++ b/docs/api/api-quick-reference.md @@ -96,6 +96,7 @@ accessibilityCategory="button" rotation={0.5} // radians translationX={10} translationY={10} + transformOrigin="top left" > {/* children */} diff --git a/docs/api/api-reference-elements.md b/docs/api/api-reference-elements.md index 92bf059f..c100d1b8 100644 --- a/docs/api/api-reference-elements.md +++ b/docs/api/api-reference-elements.md @@ -526,12 +526,19 @@ All properties from [Layout](#layout), plus: **`rotation`**: `number` - Specifies the rotation component in angle radians of the affine transformation to be applied to the view. -**`translationX`**: `number` +**`translationX`**: `number | string` - Specifies the horizontal translation component of the affine transformation to be applied to the view. -- Note: When the device is in RTL mode, the applied translationX value will be flipped. +- Numeric values are points. Percent strings such as `"50%"` resolve against the view's own calculated width. +- Note: When the device is in RTL mode, the resolved translationX value will be flipped. -**`translationY`**: `number` +**`translationY`**: `number | string` - Specifies the vertical translation component of the affine transformation to be applied to the view. +- Numeric values are points. Percent strings such as `"-50%"` resolve against the view's own calculated height. + +**`transformOrigin`**: `string` +- Specifies the origin used for scale and rotation transforms. +- Supports keywords such as `"center"` and `"top left"`, point pairs such as `"50px 70px"`, and percent pairs such as `"25% 75%"`. +- 3D transform origins are not supported. #### Mask Properties diff --git a/docs/api/api-style-attributes.md b/docs/api/api-style-attributes.md index 10f7d4ff..ebc60967 100644 --- a/docs/api/api-style-attributes.md +++ b/docs/api/api-style-attributes.md @@ -372,8 +372,11 @@ new Style({ rotation: 0, // number (radians) // Translation - translationX: 0, // number (points, flipped in RTL) - translationY: 0, // number (points) + translationX: 0, // number (points) or percent string of own width, flipped in RTL + translationY: 0, // number (points) or percent string of own height + + // Transform origin + transformOrigin: 'center', // keywords, "50px 70px", or "25% 75%" (2D only) }) ``` diff --git a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts index 1a77455f..2b5a4c2d 100644 --- a/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts +++ b/src/valdi_modules/src/valdi/valdi_tsx/src/NativeTemplateElements.d.ts @@ -977,19 +977,38 @@ interface ViewAttributes { /** * Specifies the horizontal translation component of the affine transformation to be applied to the view. + * Numeric values are points. Percent strings (for example, "50%") resolve against the view's own calculated width. * - * NOTE: When the device is in RTL mode, the applied translationX value will be flipped + * NOTE: When the device is in RTL mode, the resolved translationX value will be flipped. * * @see transform for the order in which the transformations are applied */ - translationX?: number; + translationX?: number | string; /** * Specifies the vertical translation component of the affine transformation to be applied to the view. + * Numeric values are points. Percent strings (for example, "-50%") resolve against the view's own calculated height. * * @see transform for the order in which the transformations are applied */ - translationY?: number; + translationY?: number | string; + + /** + * Specifies the origin used for scale and rotation transforms. + * Supports CSS-like keywords (for example, "center" or "top left"), point values (for example, "50px 70px"), + * and percent values (for example, "25% 75%"). 3D transform origins are not supported. + */ + transformOrigin?: string; + + /** + * Specifies a CSS-like transform string. + * Supports translate(), translateX(), translateY(), scale(), scaleX(), scaleY(), rotate(), and rotateZ(). + * + * When set, this value overrides the individual transform attributes (translationX, translationY, scaleX, + * scaleY, and rotation). It does not compose with those values. transformOrigin still applies to the resolved + * transform. + */ + transform?: string; /** * Sets the view's accessibility identifier. diff --git a/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt b/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt index 628a5123..6ee32e9d 100644 --- a/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt +++ b/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt @@ -376,6 +376,23 @@ class AttributesBindingContext(val native: AttributesBindingContextNat native.bindCompositeAttribute(attribute, parts, delegate) } + inline fun bindTransformAttributes( + crossinline apply: (view: T, value: Any?, animator: ValdiAnimator?) -> Unit, + crossinline reset: (view: T, animator: ValdiAnimator?) -> Unit + ) { + val delegate = object: ObjectAttributeHandlerDelegate() { + override fun onApply(view: View, value: Any?, animator: ValdiAnimator?) { + apply(view as T, value, animator) + } + + override fun onReset(view: View, animator: ValdiAnimator?) { + reset(view as T, animator) + } + } + + native.bindTransformAttributes(delegate) + } + inline fun bindDeserializableAttribute( attribute: String, invalidateLayoutOnChange: Boolean, diff --git a/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContextNative.kt b/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContextNative.kt index 8d2ef9b7..28e267c3 100644 --- a/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContextNative.kt +++ b/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContextNative.kt @@ -85,6 +85,10 @@ class AttributesBindingContextNative(viewClass: Class<*>, doBindAttribute(ATTRIBUTE_TYPE_COMPOSITE, name, false, delegate, parts.toTypedArray()) } + fun bindTransformAttributes(delegate: ObjectAttributeHandlerDelegate) { + NativeBridge.bindTransformAttributes(nativeHandle, delegate) + } + fun registerPreprocessor(attributeName: String, enableCache: Boolean, preprocessor: AttributePreprocessor) { NativeBridge.registerAttributePreprocessor(nativeHandle, attributeName, enableCache, preprocessor) } diff --git a/valdi/src/java/com/snap/valdi/attributes/impl/ViewAttributesBinder.kt b/valdi/src/java/com/snap/valdi/attributes/impl/ViewAttributesBinder.kt index 901b22e9..4814d394 100644 --- a/valdi/src/java/com/snap/valdi/attributes/impl/ViewAttributesBinder.kt +++ b/valdi/src/java/com/snap/valdi/attributes/impl/ViewAttributesBinder.kt @@ -286,101 +286,56 @@ class ViewAttributesBinder(private val context: Context, } } - private fun reapplyTranslationXIfNeeded(view: View, value: Float) { - val translationX = ViewUtils.resolveDeltaX(view, value) - val valueAnimator = ViewUtils.getTransitionInfo(view)?.getValueAnimator(TRANSLATION_X_KEY) - - if (valueAnimator == null) { - view.translationX = translationX - } else if (valueAnimator.valueAnimation.additionalData != translationX) { - ViewUtils.cancelAnimation(view, TRANSLATION_X_KEY) - view.translationX = translationX + fun applyTransform(view: View, value: Any?, animator: ValdiAnimator?) { + if (value !is Array<*> || value.size != 5) { + throw AttributeError("transform components should have 5 entries") } - } - fun applyTranslationX(view: View, value: Float, animator: ValdiAnimator?) { - val resolvedValue = coordinateResolver.toPixelF(value) - val resolvedTranslationX = ViewUtils.resolveDeltaX(view, resolvedValue) - - if (value != 0.0f) { - ViewUtils.setDidFinishLayoutForKey(view, "translationX") { - reapplyTranslationXIfNeeded(it, resolvedValue) - } - } else { - ViewUtils.removeDidFinishLayoutForKey(view, "translationX") - } + val translationX = coordinateResolver.toPixelF((value[0] as? Number)?.toDouble() ?: 0.0) + val translationY = coordinateResolver.toPixelF((value[1] as? Number)?.toDouble() ?: 0.0) + val scaleX = (value[2] as? Number)?.toFloat() ?: 1.0f + val scaleY = (value[3] as? Number)?.toFloat() ?: 1.0f + val rotation = Math.toDegrees(((value[4] as? Number)?.toDouble() ?: 0.0)).toFloat() setTransformElement(view, - resolvedTranslationX, + translationX, animator, TRANSLATION_X_KEY, { translationX }, - { translationX = it }, + { view.translationX = it }, ValdiValueAnimation.MinimumVisibleChange.PIXEL) - } - - fun resetTranslationX(view: View, animator: ValdiAnimator?) { - applyTranslationX(view, 0.0f, animator) - } - - fun applyTranslationY(view: View, value: Float, animator: ValdiAnimator?) { - val resolvedValue = coordinateResolver.toPixelF(value) setTransformElement(view, - resolvedValue, - animator, - TRANSLATION_Y_KEY, - { translationY }, - { translationY = it }, - ValdiValueAnimation.MinimumVisibleChange.PIXEL) - } - - fun resetTranslationY(view: View, animator: ValdiAnimator?) { - applyTranslationY(view, 0.0f, animator) - } - - fun applyScaleX(view: View, value: Float, animator: ValdiAnimator?) { + translationY, + animator, + TRANSLATION_Y_KEY, + { translationY }, + { view.translationY = it }, + ValdiValueAnimation.MinimumVisibleChange.PIXEL) setTransformElement(view, - value, - animator, - SCALE_X_KEY, - { scaleX }, - { scaleX = it }, - ValdiValueAnimation.MinimumVisibleChange.SCALE_RATIO) - } - - fun resetScaleX(view: View, animator: ValdiAnimator?) { - applyScaleX(view, 1.0f, animator) - } - - fun applyScaleY(view: View, value: Float, animator: ValdiAnimator?) { + scaleX, + animator, + SCALE_X_KEY, + { scaleX }, + { view.scaleX = it }, + ValdiValueAnimation.MinimumVisibleChange.SCALE_RATIO) setTransformElement(view, - value, - animator, - SCALE_Y_KEY, - { scaleY }, - { scaleY = it }, - ValdiValueAnimation.MinimumVisibleChange.SCALE_RATIO) - } - - fun resetScaleY(view: View, animator: ValdiAnimator?) { - applyScaleY(view, 1.0f, animator) - } - - fun applyRotation(view: View, value: Float, animator: ValdiAnimator?) { - val radianValue = ViewUtils.resolveDeltaX(view, value) - val resolvedValue = Math.toDegrees(radianValue.toDouble()).toFloat() - + scaleY, + animator, + SCALE_Y_KEY, + { scaleY }, + { view.scaleY = it }, + ValdiValueAnimation.MinimumVisibleChange.SCALE_RATIO) setTransformElement(view, - resolvedValue, - animator, - ROTATION_KEY, - { rotation }, - { rotation = it }, - ValdiValueAnimation.MinimumVisibleChange.ROTATION_DEGREES_ANGLE) + rotation, + animator, + ROTATION_KEY, + { rotation }, + { view.rotation = it }, + ValdiValueAnimation.MinimumVisibleChange.ROTATION_DEGREES_ANGLE) } - fun resetRotation(view: View, animator: ValdiAnimator?) { - applyRotation(view, 0.0f, animator) + fun resetTransform(view: View, animator: ValdiAnimator?) { + applyTransform(view, arrayOf(0.0, 0.0, 1.0, 1.0, 0.0), animator) } private fun getResourceIdForGeneratedValdiId(value: String): Int { @@ -610,11 +565,7 @@ class ViewAttributesBinder(private val context: Context, CompositeAttributePart("borderColor", AttributeType.COLOR, true, false) ), this::applyBorderComposite, this::resetBorder) - attributesBindingContext.bindFloatAttribute("translationX", false, this::applyTranslationX, this::resetTranslationX) - attributesBindingContext.bindFloatAttribute("translationY", false, this::applyTranslationY, this::resetTranslationY) - attributesBindingContext.bindFloatAttribute("scaleX", false, this::applyScaleX, this::resetScaleX) - attributesBindingContext.bindFloatAttribute("scaleY", false, this::applyScaleY, this::resetScaleY) - attributesBindingContext.bindFloatAttribute("rotation", false, this::applyRotation, this::resetRotation) + attributesBindingContext.bindTransformAttributes(this::applyTransform, this::resetTransform) attributesBindingContext.bindUntypedAttribute("maskPath", false, this::applyMaskPath, this::resetMaskPath) attributesBindingContext.bindFloatAttribute("maskOpacity", false, this::applyMaskOpacity, this::resetMaskOpacity) diff --git a/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java b/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java index 0eaaeb70..7de04419 100644 --- a/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java +++ b/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java @@ -112,7 +112,6 @@ public static native boolean notifyAssetLoaderCompleted(long callbackHandle, public static native void registerModuleFactoriesProvider(long runtimeManagerHandle, Object moduleFactoriesProvider); public static native long getViewNodePoint(long runtimeHandle, long viewNodeHandle, int x, int y, int mode, boolean fromBoundsOrigin); public static native long getViewNodeSize(long runtimeHandle, long viewNodeHandle, int mode); - public static native boolean canViewNodeScroll(long runtimeHandle, long viewNodeHandler, int x, int y, int direction); public static native boolean isViewNodeScrollingOrAnimating(long viewNodeHandle); @@ -199,6 +198,7 @@ public static native int bindAttribute(long bindingContextHandle, boolean invalidateLayoutOnChange, Object delegate, Object compositeParts); + public static native void bindTransformAttributes(long bindingContextHandle, Object delegate); public static native void bindScrollAttributes(long bindingContextHandle); public static native void bindAssetAttributes(long bindingContextHandle, int outputType); public static native void setMeasureDelegate(long bindingContextHandle, Object measureDelegate); diff --git a/valdi/src/valdi/android/AttributesBindingContextWrapper.cpp b/valdi/src/valdi/android/AttributesBindingContextWrapper.cpp index 911cd1d1..86c50417 100644 --- a/valdi/src/valdi/android/AttributesBindingContextWrapper.cpp +++ b/valdi/src/valdi/android/AttributesBindingContextWrapper.cpp @@ -416,6 +416,10 @@ jint AttributesBindingContextWrapper::bindAttributes( return static_cast(attributeId); } +void AttributesBindingContextWrapper::bindTransformAttributes(jobject delegate) { + _bindingContext.bindTransformAttributes(Valdi::makeShared(delegate)); +} + void AttributesBindingContextWrapper::bindScrollAttributes() { _bindingContext.bindScrollAttributes(); } diff --git a/valdi/src/valdi/android/AttributesBindingContextWrapper.hpp b/valdi/src/valdi/android/AttributesBindingContextWrapper.hpp index 31fd44da..d2e7e79e 100644 --- a/valdi/src/valdi/android/AttributesBindingContextWrapper.hpp +++ b/valdi/src/valdi/android/AttributesBindingContextWrapper.hpp @@ -24,6 +24,8 @@ class AttributesBindingContextWrapper : public Valdi::SimpleRefCountable { jint bindAttributes(jint type, jstring name, jboolean invalidateLayoutOnChange, jobject delegate, jobject parts); + void bindTransformAttributes(jobject delegate); + void bindScrollAttributes(); void bindAssetAttributes(snap::valdi_core::AssetOutputType assetOutputType); diff --git a/valdi/src/valdi/android/NativeBridge.cpp b/valdi/src/valdi/android/NativeBridge.cpp index 784c3d8b..93421772 100644 --- a/valdi/src/valdi/android/NativeBridge.cpp +++ b/valdi/src/valdi/android/NativeBridge.cpp @@ -1757,6 +1757,13 @@ jint ValdiAndroid::NativeBridge::bindAttribute(fbjni::alias_ref c return wrapper->bindAttributes(type, name, invalidateLayoutOnChange, delegate, compositeParts); } +void ValdiAndroid::NativeBridge::bindTransformAttributes(fbjni::alias_ref clazz, // NOLINT + jlong bindingContextHandle, + jobject delegate) { + auto wrapper = getBindingContextWrapper(bindingContextHandle); + wrapper->bindTransformAttributes(delegate); +} + void ValdiAndroid::NativeBridge::bindScrollAttributes(fbjni::alias_ref clazz, // NOLINT jlong bindingContextHandle) { auto wrapper = getBindingContextWrapper(bindingContextHandle); @@ -2397,6 +2404,7 @@ void ValdiAndroid::NativeBridge::registerNatives() { makeNativeMethod("destroyContext", ValdiAndroid::NativeBridge::destroyContext), makeNativeMethod("forceBindAttributes", ValdiAndroid::NativeBridge::forceBindAttributes), makeNativeMethod("bindAttribute", ValdiAndroid::NativeBridge::bindAttribute), + makeNativeMethod("bindTransformAttributes", ValdiAndroid::NativeBridge::bindTransformAttributes), makeNativeMethod("bindScrollAttributes", ValdiAndroid::NativeBridge::bindScrollAttributes), makeNativeMethod("bindAssetAttributes", ValdiAndroid::NativeBridge::bindAssetAttributes), makeNativeMethod("setPlaceholderViewMeasureDelegate", diff --git a/valdi/src/valdi/android/NativeBridge.hpp b/valdi/src/valdi/android/NativeBridge.hpp index 9b3a9150..50aae6b5 100644 --- a/valdi/src/valdi/android/NativeBridge.hpp +++ b/valdi/src/valdi/android/NativeBridge.hpp @@ -194,6 +194,9 @@ class NativeBridge : public fbjni::JavaClass { jboolean invalidateLayoutOnChange, jobject delegate, jobject compositeParts); + static void bindTransformAttributes(fbjni::alias_ref clazz, + jlong bindingContextHandle, + jobject delegate); static void bindScrollAttributes(fbjni::alias_ref clazz, jlong bindingContextHandle); static void bindAssetAttributes(fbjni::alias_ref clazz, jlong bindingContextHandle, jint outputType); static void setMeasureDelegate(fbjni::alias_ref clazz, diff --git a/valdi/src/valdi/ios/Categories/UIView+Valdi.m b/valdi/src/valdi/ios/Categories/UIView+Valdi.m index 63bd06b7..a6e9ab23 100644 --- a/valdi/src/valdi/ios/Categories/UIView+Valdi.m +++ b/valdi/src/valdi/ios/Categories/UIView+Valdi.m @@ -1041,30 +1041,26 @@ + (void)bindAttributes:(id)attributesBinder [view valdi_resetOnTouchGestures]; }]; - [attributesBinder bindCompositeAttribute:@"transform" - parts:[self _valdiTransformComponents] - withUntypedBlock:^BOOL(__kindof UIView *view, id attributeValue, id animator) { + [attributesBinder bindTransformAttributesWithUntypedBlock:^BOOL(__kindof UIView *view, id attributeValue, id animator) { NSArray *attributeValueArray = ObjectAs(attributeValue, NSArray); if (attributeValueArray.count != 5) { return NO; } - id viewNode = view.valdiViewNode; - - CGFloat translationX = [viewNode resolveDeltaX:ObjectAs(attributeValueArray[0], NSNumber).doubleValue directionAgnostic:NO]; - CGFloat translationY = ObjectAs(attributeValueArray[1], NSNumber).doubleValue; + CGFloat translationX = (ObjectAs(attributeValueArray[0], NSNumber) ?: @(0.0)).doubleValue; + CGFloat translationY = (ObjectAs(attributeValueArray[1], NSNumber) ?: @(0.0)).doubleValue; CGFloat scaleX = (ObjectAs(attributeValueArray[2], NSNumber) ?: @(1.0)).doubleValue; CGFloat scaleY = (ObjectAs(attributeValueArray[3], NSNumber) ?: @(1.0)).doubleValue; - CGFloat rotation = [viewNode resolveDeltaX:ObjectAs(attributeValueArray[4], NSNumber).doubleValue directionAgnostic:NO]; + CGFloat rotation = (ObjectAs(attributeValueArray[4], NSNumber) ?: @(0.0)).doubleValue; return [view valdi_setTranslationX:translationX - translationY:translationY - scaleX:scaleX - scaleY:scaleY - rotation:rotation - animator:animator]; + translationY:translationY + scaleX:scaleX + scaleY:scaleY + rotation:rotation + animator:animator]; } resetBlock:^(__kindof UIView *view, id animator) { [view valdi_setTranslationX:0 translationY:0 scaleX:1.0 scaleY:1.0 rotation:0.0 animator:animator]; @@ -1088,32 +1084,6 @@ + (void)bindAttributes:(id)attributesBinder }]; } -+ (NSArray *)_valdiTransformComponents -{ - return @[ - [[SCNValdiCoreCompositeAttributePart alloc] initWithAttribute:@"translationX" - type:SCNValdiCoreAttributeTypeDouble - optional:YES - invalidateLayoutOnChange:NO], - [[SCNValdiCoreCompositeAttributePart alloc] initWithAttribute:@"translationY" - type:SCNValdiCoreAttributeTypeDouble - optional:YES - invalidateLayoutOnChange:NO], - [[SCNValdiCoreCompositeAttributePart alloc] initWithAttribute:@"scaleX" - type:SCNValdiCoreAttributeTypeDouble - optional:YES - invalidateLayoutOnChange:NO], - [[SCNValdiCoreCompositeAttributePart alloc] initWithAttribute:@"scaleY" - type:SCNValdiCoreAttributeTypeDouble - optional:YES - invalidateLayoutOnChange:NO], - [[SCNValdiCoreCompositeAttributePart alloc] initWithAttribute:@"rotation" - type:SCNValdiCoreAttributeTypeDouble - optional:YES - invalidateLayoutOnChange:NO], - ]; -} - + (NSArray *)_valdiTouchAreaExtensionComponents { NSMutableArray *parts = [NSMutableArray arrayWithCapacity:5]; diff --git a/valdi/src/valdi/ios/SCValdiAttributesBinder.mm b/valdi/src/valdi/ios/SCValdiAttributesBinder.mm index c95756f2..3e1c2bcb 100644 --- a/valdi/src/valdi/ios/SCValdiAttributesBinder.mm +++ b/valdi/src/valdi/ios/SCValdiAttributesBinder.mm @@ -552,6 +552,13 @@ - (void)bindCompositeAttribute:(NSString *)attributeName Valdi::makeShared(untypedBlock, resetBlock)); } +- (void)bindTransformAttributesWithUntypedBlock:(SCValdiAttributeBindMethodUntyped)untypedBlock + resetBlock:(SCValdiAttributeBindMethodReset)resetBlock +{ + _bindingContext->bindTransformAttributes( + Valdi::makeShared(untypedBlock, resetBlock)); +} + - (void)bindAttribute:(NSString *)attributeName invalidateLayoutOnChange:(BOOL)invalidateLayoutOnChange deserializer:(SCValdiAttributeDeserializer)deserializer diff --git a/valdi/src/valdi/runtime/Attributes/AttributesBindingContext.hpp b/valdi/src/valdi/runtime/Attributes/AttributesBindingContext.hpp index 549bf82d..b1068061 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesBindingContext.hpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesBindingContext.hpp @@ -69,6 +69,8 @@ class AttributesBindingContext { const std::vector& parts, const Ref& delegate) = 0; + virtual AttributeId bindTransformAttributes(const Ref& delegate) = 0; + virtual void bindScrollAttributes() = 0; virtual void bindAssetAttributes(snap::valdi_core::AssetOutputType assetOutputType) = 0; diff --git a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.cpp b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.cpp index 5da698d9..9067e118 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.cpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.cpp @@ -17,6 +17,7 @@ #include "valdi/runtime/Attributes/CompositeAttribute.hpp" #include "valdi/runtime/Attributes/ScrollAttributes.hpp" #include "valdi/runtime/Attributes/TextAttributeValueParser.hpp" +#include "valdi/runtime/Attributes/TransformAttributes.hpp" #include "valdi/runtime/Attributes/ValueConverters.hpp" #include "valdi/runtime/Views/MeasureDelegate.hpp" @@ -207,6 +208,21 @@ AttributeId AttributesBindingContextImpl::bindCompositeAttribute( return attributeId; } +AttributeId AttributesBindingContextImpl::bindTransformAttributes(const Ref& delegate) { + std::vector parts; + parts.emplace_back(STRING_LITERAL("transformOrigin"), snap::valdi_core::AttributeType::String, true, false); + parts.emplace_back(STRING_LITERAL("transform"), snap::valdi_core::AttributeType::String, true, false); + parts.emplace_back(STRING_LITERAL("translationX"), snap::valdi_core::AttributeType::Untyped, true, false); + parts.emplace_back(STRING_LITERAL("translationY"), snap::valdi_core::AttributeType::Untyped, true, false); + parts.emplace_back(STRING_LITERAL("scaleX"), snap::valdi_core::AttributeType::Double, true, false); + parts.emplace_back(STRING_LITERAL("scaleY"), snap::valdi_core::AttributeType::Double, true, false); + parts.emplace_back(STRING_LITERAL("rotation"), snap::valdi_core::AttributeType::Double, true, false); + + auto attributeId = bindCompositeAttribute(STRING_LITERAL("transformComposite"), parts, delegate); + _handlers[attributeId].appendPostprocessor(&TransformAttributes::postprocessViewNode); + return attributeId; +} + void AttributesBindingContextImpl::bindScrollAttributes() { ScrollAttributes scrollAttributes(_attributeIds); scrollAttributes.bind(_handlers); diff --git a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp index 4cb4c38c..373fbf25 100644 --- a/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp +++ b/valdi/src/valdi/runtime/Attributes/AttributesBindingContextImpl.hpp @@ -68,6 +68,8 @@ class AttributesBindingContextImpl : public AttributesBindingContext { const std::vector& parts, const Ref& delegate) override; + AttributeId bindTransformAttributes(const Ref& delegate) override; + void bindScrollAttributes() override; void bindAssetAttributes(snap::valdi_core::AssetOutputType assetOutputType) override; diff --git a/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp b/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp new file mode 100644 index 00000000..12e2f709 --- /dev/null +++ b/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp @@ -0,0 +1,628 @@ +// +// TransformAttributes.cpp +// valdi +// + +#include "valdi/runtime/Attributes/TransformAttributes.hpp" +#include "valdi/runtime/Attributes/ValueConverters.hpp" +#include "valdi/runtime/Context/ViewNode.hpp" +#include "valdi_core/cpp/Attributes/AttributeUtils.hpp" +#include "valdi_core/cpp/Utils/Format.hpp" +#include "valdi_core/cpp/Utils/StringBox.hpp" +#include "valdi_core/cpp/Utils/ValueArray.hpp" + +#include +#include +#include + +namespace Valdi { + +namespace { + +constexpr size_t kTransformPartOrigin = 0; +constexpr size_t kTransformPartTransform = 1; +constexpr size_t kTransformPartTranslationX = 2; +constexpr size_t kTransformPartTranslationY = 3; +constexpr size_t kTransformPartScaleX = 4; +constexpr size_t kTransformPartScaleY = 5; +constexpr size_t kTransformPartRotation = 6; +constexpr size_t kTransformPartsSize = 7; + +struct TransformOrigin { + double x = 0; + double y = 0; + bool isCenter = false; + + TransformOrigin() = default; + TransformOrigin(double x, double y, bool isCenter) : x(x), y(y), isCenter(isCenter) {} +}; + +enum class OriginKeywordAxis { Horizontal, Vertical, Center }; + +struct OriginKeyword { + OriginKeywordAxis axis; + double ratio; +}; + +struct TransformComponents { + double translationX = 0.0; + double translationY = 0.0; + double scaleX = 1.0; + double scaleY = 1.0; + double rotation = 0.0; + + TransformComponents() = default; + TransformComponents(double translationX, double translationY, double scaleX, double scaleY, double rotation) + : translationX(translationX), + translationY(translationY), + scaleX(scaleX), + scaleY(scaleY), + rotation(rotation) {} +}; + +struct TransformMatrix { + double a = 1.0; + double b = 0.0; + double c = 0.0; + double d = 1.0; + double e = 0.0; + double f = 0.0; + + TransformMatrix() = default; + TransformMatrix(double a, double b, double c, double d, double e, double f) + : a(a), b(b), c(c), d(d), e(e), f(f) {} + + static TransformMatrix translate(double x, double y); + static TransformMatrix scale(double x, double y); + static TransformMatrix rotate(double radians); + + TransformMatrix concat(const TransformMatrix& other) const; + Result decompose() const; +}; + +struct ResolvedTransform { + TransformComponents components; + TransformOrigin origin; + + ResolvedTransform(TransformComponents components, TransformOrigin origin) + : components(components), origin(origin) {} +}; + +Value makeTransformValue(double translationX, double translationY, double scaleX, double scaleY, double rotation); +Result parseTransformOrigin(const Value& value, double width, double height); + +std::optional parseOriginKeyword(std::string_view token) { + if (token == "left") { + return OriginKeyword{OriginKeywordAxis::Horizontal, 0}; + } else if (token == "right") { + return OriginKeyword{OriginKeywordAxis::Horizontal, 1}; + } else if (token == "top") { + return OriginKeyword{OriginKeywordAxis::Vertical, 0}; + } else if (token == "bottom") { + return OriginKeyword{OriginKeywordAxis::Vertical, 1}; + } else if (token == "center") { + return OriginKeyword{OriginKeywordAxis::Center, 0.5}; + } + + return std::nullopt; +} + +TransformMatrix TransformMatrix::translate(double x, double y) { + return TransformMatrix(1.0, 0.0, 0.0, 1.0, x, y); +} + +TransformMatrix TransformMatrix::scale(double x, double y) { + return TransformMatrix(x, 0.0, 0.0, y, 0.0, 0.0); +} + +TransformMatrix TransformMatrix::rotate(double radians) { + auto cosValue = std::cos(radians); + auto sinValue = std::sin(radians); + return TransformMatrix(cosValue, sinValue, -sinValue, cosValue, 0.0, 0.0); +} + +TransformMatrix TransformMatrix::concat(const TransformMatrix& other) const { + return TransformMatrix( + a * other.a + c * other.b, + b * other.a + d * other.b, + a * other.c + c * other.d, + b * other.c + d * other.d, + a * other.e + c * other.f + e, + b * other.e + d * other.f + f); +} + +Result TransformMatrix::decompose() const { + constexpr auto epsilon = 0.00001; + auto resolvedScaleX = std::hypot(a, b); + if (std::abs(resolvedScaleX) < epsilon) { + return Error("transform scaleX must not resolve to zero"); + } + + auto shear = a * c + b * d; + if (std::abs(shear) > epsilon) { + return Error("transform strings cannot resolve to skewed matrices"); + } + + auto determinant = a * d - b * c; + auto resolvedScaleY = determinant / resolvedScaleX; + auto resolvedRotation = std::atan2(b, a); + + return TransformComponents(e, f, resolvedScaleX, resolvedScaleY, resolvedRotation); +} + +Result parseOriginDimension(AttributeParser& parser) { + auto dimension = parser.parseDimension(); + if (!dimension) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transformOrigin dimension")); + } + + if (dimension->unit == Dimension::Unit::None) { + return Error("transformOrigin dimensions require px, pt, or percent units"); + } + + return *dimension; +} + +double resolveDimension(const Dimension& dimension, double referenceLength) { + if (dimension.unit == Dimension::Unit::Percent) { + return referenceLength * dimension.value / 100.0; + } + + return dimension.value; +} + +Result parseTransformLength(AttributeParser& parser, double referenceLength) { + auto dimension = parser.parseDimension(); + if (!dimension) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform length")); + } + + return resolveDimension(dimension.value(), referenceLength); +} + +Result parseTransformNumber(AttributeParser& parser) { + auto number = parser.parseDouble(); + if (!number) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform number")); + } + + return number.value(); +} + +Result parseTransformAngle(AttributeParser& parser) { + auto angle = parser.parseAngle(); + if (!angle) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform angle")); + } + + return angle.value(); +} + +bool parseOptionalArgumentSeparator(AttributeParser& parser) { + auto hadWhitespace = parser.tryParseWhitespaces(); + if (parser.tryParse(',')) { + parser.tryParseWhitespaces(); + return true; + } + return hadWhitespace && !parser.peek(')'); +} + +Result parseTranslateFunction(AttributeParser& parser, double width, double height) { + auto x = parseTransformLength(parser, width); + if (!x) { + return x.moveError(); + } + + auto y = 0.0; + if (parseOptionalArgumentSeparator(parser)) { + auto parsedY = parseTransformLength(parser, height); + if (!parsedY) { + return parsedY.moveError(); + } + y = parsedY.value(); + } + + return TransformMatrix::translate(x.value(), y); +} + +Result parseTranslateXFunction(AttributeParser& parser, double width) { + auto x = parseTransformLength(parser, width); + if (!x) { + return x.moveError(); + } + + return TransformMatrix::translate(x.value(), 0.0); +} + +Result parseTranslateYFunction(AttributeParser& parser, double height) { + auto y = parseTransformLength(parser, height); + if (!y) { + return y.moveError(); + } + + return TransformMatrix::translate(0.0, y.value()); +} + +Result parseScaleFunction(AttributeParser& parser) { + auto x = parseTransformNumber(parser); + if (!x) { + return x.moveError(); + } + + auto y = x.value(); + if (parseOptionalArgumentSeparator(parser)) { + auto parsedY = parseTransformNumber(parser); + if (!parsedY) { + return parsedY.moveError(); + } + y = parsedY.value(); + } + + return TransformMatrix::scale(x.value(), y); +} + +Result parseScaleXFunction(AttributeParser& parser) { + auto x = parseTransformNumber(parser); + if (!x) { + return x.moveError(); + } + + return TransformMatrix::scale(x.value(), 1.0); +} + +Result parseScaleYFunction(AttributeParser& parser) { + auto y = parseTransformNumber(parser); + if (!y) { + return y.moveError(); + } + + return TransformMatrix::scale(1.0, y.value()); +} + +Result parseRotateFunction(AttributeParser& parser) { + auto angle = parseTransformAngle(parser); + if (!angle) { + return angle.moveError(); + } + + return TransformMatrix::rotate(angle.value()); +} + +Result parseTransformFunction(AttributeParser& parser, + std::string_view functionName, + double width, + double height) { + if (functionName == "translate") { + return parseTranslateFunction(parser, width, height); + } else if (functionName == "translateX") { + return parseTranslateXFunction(parser, width); + } else if (functionName == "translateY") { + return parseTranslateYFunction(parser, height); + } else if (functionName == "scale") { + return parseScaleFunction(parser); + } else if (functionName == "scaleX") { + return parseScaleXFunction(parser); + } else if (functionName == "scaleY") { + return parseScaleYFunction(parser); + } else if (functionName == "rotate") { + return parseRotateFunction(parser); + } else if (functionName == "rotateZ") { + return parseRotateFunction(parser); + } + + return Error(STRING_FORMAT("Unsupported transform function '{}'", functionName)); +} + +Result resolveTransformFromString(const Value& transformValue, + const TransformOrigin& origin, + double width, + double height) { + auto transformString = transformValue.toStringBox(); + AttributeParser parser(transformString.toStringView()); + parser.tryParseWhitespaces(); + + if (parser.tryParse("none")) { + parser.tryParseWhitespaces(); + if (!parser.ensureIsAtEnd()) { + return parser.getError(); + } + return ResolvedTransform(TransformComponents(), origin); + } + + auto matrix = TransformMatrix(); + auto parsedFunctionCount = 0; + while (!parser.isAtEnd()) { + auto functionName = parser.parseIdentifier(); + if (!functionName) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform function")); + } + + if (!parser.parse('(')) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform function")); + } + parser.tryParseWhitespaces(); + + auto parsedMatrix = parseTransformFunction(parser, functionName.value(), width, height); + if (!parsedMatrix) { + return parsedMatrix.moveError(); + } + + parser.tryParseWhitespaces(); + if (!parser.parse(')')) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transform function arguments")); + } + + matrix = matrix.concat(parsedMatrix.value()); + parsedFunctionCount++; + parser.tryParseWhitespaces(); + } + + if (parsedFunctionCount == 0) { + return Error("transform string must contain at least one function"); + } + + auto components = matrix.decompose(); + if (!components) { + return components.moveError(); + } + + return ResolvedTransform(components.moveValue(), origin); +} + +Result parseDimensionOrigin(AttributeParser& parser, double width, double height) { + auto xDimension = parseOriginDimension(parser); + if (!xDimension) { + return xDimension.moveError(); + } + + if (!parser.tryParseWhitespaces()) { + return Error("transformOrigin length and percent values require exactly two components"); + } + + auto yDimension = parseOriginDimension(parser); + if (!yDimension) { + return yDimension.moveError(); + } + + parser.tryParseWhitespaces(); + if (!parser.ensureIsAtEnd()) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transformOrigin dimension pair")); + } + + auto x = resolveDimension(xDimension.value(), width); + auto y = resolveDimension(yDimension.value(), height); + return TransformOrigin(x, y, x == width / 2.0 && y == height / 2.0); +} + +Result parseKeywordOrigin(AttributeParser& parser, double width, double height) { + auto xRatio = 0.5; + auto yRatio = 0.5; + auto hasHorizontal = false; + auto hasVertical = false; + auto parsedKeywords = 0; + + while (!parser.isAtEnd()) { + auto token = parser.parseIdentifier(); + if (!token) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transformOrigin keyword")); + } + + auto keyword = parseOriginKeyword(token.value()); + if (!keyword) { + return Error(STRING_FORMAT("Invalid transformOrigin keyword '{}'", token.value())); + } + + parsedKeywords++; + if (parsedKeywords > 2) { + return Error("transformOrigin keyword values require one or two components"); + } + + switch (keyword->axis) { + case OriginKeywordAxis::Horizontal: + if (hasHorizontal) { + return Error("transformOrigin cannot contain two horizontal keywords"); + } + xRatio = keyword->ratio; + hasHorizontal = true; + break; + case OriginKeywordAxis::Vertical: + if (hasVertical) { + return Error("transformOrigin cannot contain two vertical keywords"); + } + yRatio = keyword->ratio; + hasVertical = true; + break; + case OriginKeywordAxis::Center: + break; + } + + if (!parser.tryParseWhitespaces()) { + break; + } + } + + if (parsedKeywords == 0) { + return Error("transformOrigin keyword values require one or two components"); + } + + if (!parser.ensureIsAtEnd()) { + return parser.getError().rethrow(STRING_LITERAL("Invalid transformOrigin keyword value")); + } + + return TransformOrigin(width * xRatio, height * yRatio, xRatio == 0.5 && yRatio == 0.5); +} + +Result resolveTranslation(const Value& value, double referenceLength, bool flip) { + if (value.isNullOrUndefined()) { + return 0.0; + } + + auto percent = ValueConverter::toPercent(value); + if (!percent) { + return percent.moveError(); + } + + auto resolvedValue = percent.value().isPercent ? referenceLength * percent.value().value / 100.0 + : percent.value().value; + return flip ? -resolvedValue : resolvedValue; +} + +Result resolveDouble(const Value& value, double defaultValue) { + if (value.isNullOrUndefined()) { + return defaultValue; + } + + return ValueConverter::toDouble(value); +} + +Result parseTransformOrigin(const Value& value, double width, double height) { + if (value.isNullOrUndefined()) { + return TransformOrigin(width / 2.0, height / 2.0, true); + } + + if (!value.isString()) { + return Error("transformOrigin must be a string"); + } + + auto originString = value.toStringBox(); + AttributeParser parser(originString.toStringView()); + parser.tryParseWhitespaces(); + if (parser.isAtEnd()) { + return Error("transformOrigin cannot be empty"); + } + + if (parser.peekPredicate([](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); })) { + return parseKeywordOrigin(parser, width, height); + } + + return parseDimensionOrigin(parser, width, height); +} + +Value makeTransformValue(double translationX, double translationY, double scaleX, double scaleY, double rotation) { + auto out = ValueArray::make(5); + (*out)[0] = Value(translationX); + (*out)[1] = Value(translationY); + (*out)[2] = Value(scaleX); + (*out)[3] = Value(scaleY); + (*out)[4] = Value(rotation); + return Value(out); +} + +Result resolveTransformFromAttributeParts(const Value& value, + const TransformOrigin& origin, + double width, + double height) { + const auto* array = value.getArray(); + if (array == nullptr || array->size() != kTransformPartsSize) { + return Error("Expected 7 transform components"); + } + + TransformComponents components; + + auto translationX = resolveTranslation((*array)[kTransformPartTranslationX], width, false); + if (!translationX) { + return translationX.moveError(); + } + components.translationX = translationX.value(); + + auto translationY = resolveTranslation((*array)[kTransformPartTranslationY], height, false); + if (!translationY) { + return translationY.moveError(); + } + components.translationY = translationY.value(); + + auto scaleX = resolveDouble((*array)[kTransformPartScaleX], 1.0); + if (!scaleX) { + return scaleX.moveError(); + } + components.scaleX = scaleX.value(); + + auto scaleY = resolveDouble((*array)[kTransformPartScaleY], 1.0); + if (!scaleY) { + return scaleY.moveError(); + } + components.scaleY = scaleY.value(); + + auto rotation = resolveDouble((*array)[kTransformPartRotation], 0.0); + if (!rotation) { + return rotation.moveError(); + } + components.rotation = rotation.value(); + + return ResolvedTransform(components, origin); +} + +Value postprocessResolvedTransform(ResolvedTransform resolvedTransform, + double width, + double height, + bool isRightToLeft) { + auto components = resolvedTransform.components; + auto origin = resolvedTransform.origin; + + if (isRightToLeft) { + components.translationX *= -1.0; + components.rotation *= -1.0; + } + + if (origin.isCenter) { + return makeTransformValue(components.translationX, + components.translationY, + components.scaleX, + components.scaleY, + components.rotation); + } + + auto centerX = width / 2.0; + auto centerY = height / 2.0; + auto deltaX = centerX - origin.x; + auto deltaY = centerY - origin.y; + + auto cosRotation = std::cos(components.rotation); + auto sinRotation = std::sin(components.rotation); + auto transformedDeltaX = cosRotation * components.scaleX * deltaX - sinRotation * components.scaleY * deltaY; + auto transformedDeltaY = sinRotation * components.scaleX * deltaX + cosRotation * components.scaleY * deltaY; + + auto adjustedTranslationX = components.translationX + origin.x - centerX + transformedDeltaX; + auto adjustedTranslationY = components.translationY + origin.y - centerY + transformedDeltaY; + + return makeTransformValue(adjustedTranslationX, + adjustedTranslationY, + components.scaleX, + components.scaleY, + components.rotation); +} + +} // namespace + +Result TransformAttributes::postprocess(float width, float height, bool isRightToLeft, const Value& value) { + auto resolvedWidth = static_cast(width); + auto resolvedHeight = static_cast(height); + + const auto* array = value.getArray(); + if (array == nullptr || array->size() != kTransformPartsSize) { + return Error("Expected 7 transform components"); + } + + auto origin = parseTransformOrigin((*array)[kTransformPartOrigin], resolvedWidth, resolvedHeight); + if (!origin) { + return origin.moveError(); + } + + auto resolvedTransform = (*array)[kTransformPartTransform].isString() + ? resolveTransformFromString( + (*array)[kTransformPartTransform], origin.value(), resolvedWidth, resolvedHeight) + : resolveTransformFromAttributeParts(value, origin.value(), resolvedWidth, resolvedHeight); + + if (!resolvedTransform) { + return resolvedTransform.moveError(); + } + + return postprocessResolvedTransform(resolvedTransform.moveValue(), resolvedWidth, resolvedHeight, isRightToLeft); +} + +Result TransformAttributes::postprocessViewNode(ViewNode& viewNode, const Value& value) { + auto frame = viewNode.getCalculatedFrame(); + return postprocess(frame.width, frame.height, viewNode.isRightToLeft(), value); +} + +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/Attributes/TransformAttributes.hpp b/valdi/src/valdi/runtime/Attributes/TransformAttributes.hpp new file mode 100644 index 00000000..7384b33a --- /dev/null +++ b/valdi/src/valdi/runtime/Attributes/TransformAttributes.hpp @@ -0,0 +1,21 @@ +// +// TransformAttributes.hpp +// valdi +// + +#pragma once + +#include "valdi_core/cpp/Utils/Result.hpp" +#include "valdi_core/cpp/Utils/Value.hpp" + +namespace Valdi { + +class ViewNode; + +class TransformAttributes { +public: + static Result postprocess(float width, float height, bool isRightToLeft, const Value& value); + static Result postprocessViewNode(ViewNode& viewNode, const Value& value); +}; + +} // namespace Valdi diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp index 047351c6..df192fc4 100644 --- a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp +++ b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp @@ -9,6 +9,7 @@ #include "valdi/runtime/Attributes/AttributeHandler.hpp" #include "valdi/runtime/Attributes/AttributesManager.hpp" #include "valdi/runtime/Attributes/BoundAttributes.hpp" +#include "valdi/runtime/Attributes/ValueConverters.hpp" #include "valdi/runtime/Attributes/ViewNodeAttribute.hpp" #include "valdi/runtime/Context/ViewNode.hpp" @@ -181,9 +182,29 @@ void ViewNodeAttributesApplier::processAttributeChange(ViewTransactionScope& vie } if (id == DefaultAttributeTranslationX) { - _viewNode->setTranslationX(attribute.getResolvedValue().toFloat()); + auto value = attribute.getResolvedValue(); + if (value.isNullOrUndefined()) { + _viewNode->setTranslationX(0, false); + } else { + auto translation = ValueConverter::toPercent(value); + if (!translation) { + onApplyAttributeFailed(id, translation.error()); + return; + } + _viewNode->setTranslationX(static_cast(translation.value().value), translation.value().isPercent); + } } else if (id == DefaultAttributeTranslationY) { - _viewNode->setTranslationY(attribute.getResolvedValue().toFloat()); + auto value = attribute.getResolvedValue(); + if (value.isNullOrUndefined()) { + _viewNode->setTranslationY(0, false); + } else { + auto translation = ValueConverter::toPercent(value); + if (!translation) { + onApplyAttributeFailed(id, translation.error()); + return; + } + _viewNode->setTranslationY(static_cast(translation.value().value), translation.value().isPercent); + } } else if (id == DefaultAttributeScaleX) { auto value = attribute.getResolvedValue(); _viewNode->setScaleX(value.isNullOrUndefined() ? 1.0f : value.toFloat()); diff --git a/valdi/src/valdi/runtime/Context/ViewNode.cpp b/valdi/src/valdi/runtime/Context/ViewNode.cpp index 2635e75b..7694453b 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.cpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.cpp @@ -124,6 +124,13 @@ void LazyLayoutData::destroyNode() { } } +float ViewNodeTranslation::getResolvedValue(float referenceLength, bool isPercent) const { + if (isPercent) { + return referenceLength * (value / 100.0f); + } + return value; +} + const SharedAnimator& nullAnimator() { static SharedAnimator nullAnimator; return nullAnimator; @@ -158,6 +165,8 @@ constexpr size_t kHasChildWithAccessibilityId = 26; constexpr size_t kCanAlwaysScrollHorizontal = 27; constexpr size_t kCanAlwaysScrollVertical = 28; constexpr size_t kAccessibilityTreeNeedsUpdate = 29; +constexpr size_t kTranslationXIsPercent = 30; +constexpr size_t kTranslationYIsPercent = 31; ViewNode::ViewNode(YGConfig* yogaConfig, AttributeIds& attributeIds, ILogger& logger) : _yogaNode(yogaConfig != nullptr ? Yoga::createNode(yogaConfig) : nullptr), @@ -1343,12 +1352,12 @@ Point ViewNode::convertSelfVisualToRootVisual(const Point& selfDirectionDependen Point ViewNode::getDirectionDependentTransform() const { auto directionDependentFrame = getCalculatedFrame(); return Point(directionDependentFrame.x + getDirectionDependentTranslationX(), - directionDependentFrame.y + _translationY); + directionDependentFrame.y + getTranslationY()); } Point ViewNode::getDirectionAgnosticTransform() const { auto directionAgnosticFrame = getDirectionAgnosticFrame(); - return Point(directionAgnosticFrame.x + _translationX, directionAgnosticFrame.y + _translationY); + return Point(directionAgnosticFrame.x + getTranslationX(), directionAgnosticFrame.y + getTranslationY()); } Point ViewNode::getBoundsOriginPoint() const { @@ -1939,7 +1948,7 @@ void ViewNode::setHasParent(bool hasParent) { Frame ViewNode::calculateSelfViewport() const { auto tx = getDirectionDependentTranslationX(); - auto ty = _translationY; + auto ty = getTranslationY(); auto& f = _calculatedFrame; Frame bounds; if (VALDI_LIKELY(_scaleX == 1.0f && _scaleY == 1.0f)) { @@ -2391,10 +2400,9 @@ bool ViewNode::updateCalculatedFrame(float viewOffsetX, auto hasNewLayout = resolveYogaNode(_yogaNode)->getHasNewLayout(); auto hadLayout = _flags[kLayoutDidCompleteOnceFlag]; auto layoutIsRightToLeft = isRightToLeft(); - + auto layoutDirectionDidChange = layoutIsRightToLeft != _flags[kLayoutIsRightToLeft]; if (!hadLayout || _calculatedFrame != newFrame) { - *calculatedSizeDidChange = - _calculatedFrame.width != newFrame.width || _calculatedFrame.height != newFrame.height; + *calculatedSizeDidChange = _calculatedFrame.size() != newFrame.size(); *calculatedFrameDidChange = true; _calculatedFrame = newFrame; @@ -2402,7 +2410,7 @@ bool ViewNode::updateCalculatedFrame(float viewOffsetX, hasNewLayout = true; } - if (!hadLayout || _viewFrame != newViewFrame || layoutIsRightToLeft != _flags[kLayoutIsRightToLeft]) { + if (!hadLayout || _viewFrame != newViewFrame || layoutDirectionDidChange) { _previousViewFrame = _viewFrame; _viewFrame = newViewFrame; _flags[kLayoutIsRightToLeft] = layoutIsRightToLeft; @@ -3799,27 +3807,28 @@ bool ViewNode::ignoreParentViewport() const { } float ViewNode::getTranslationX() const { - return _translationX; + return _translationX.getResolvedValue(_calculatedFrame.width, _flags[kTranslationXIsPercent]); } -void ViewNode::setTranslationX(float translationX) { - updateTranslation(translationX, &_translationX); +void ViewNode::setTranslationX(float translationX, bool isPercent) { + updateTranslation(translationX, isPercent, &_translationX, kTranslationXIsPercent); } float ViewNode::getDirectionDependentTranslationX() const { + auto translationX = getTranslationX(); if (isRightToLeft()) { - return _translationX * -1; + return translationX * -1; } else { - return _translationX; + return translationX; } } float ViewNode::getTranslationY() const { - return _translationY; + return _translationY.getResolvedValue(_calculatedFrame.height, _flags[kTranslationYIsPercent]); } -void ViewNode::setTranslationY(float translationY) { - updateTranslation(translationY, &_translationY); +void ViewNode::setTranslationY(float translationY, bool isPercent) { + updateTranslation(translationY, isPercent, &_translationY, kTranslationYIsPercent); } void ViewNode::setScaleX(float scaleX) { @@ -3844,9 +3853,13 @@ void ViewNode::setScaleY(float scaleY) { } } -void ViewNode::updateTranslation(float translation, float* outValue) { - if (*outValue != translation) { - *outValue = translation; +void ViewNode::updateTranslation(float translation, + bool isPercent, + ViewNodeTranslation* outTranslation, + size_t percentFlag) { + if (outTranslation->value != translation || _flags[percentFlag] != isPercent) { + outTranslation->value = translation; + _flags[percentFlag] = isPercent; setCalculatedViewportNeedsUpdate(); auto parent = getParent(); diff --git a/valdi/src/valdi/runtime/Context/ViewNode.hpp b/valdi/src/valdi/runtime/Context/ViewNode.hpp index 1423cbae..0e337d17 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.hpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.hpp @@ -89,6 +89,12 @@ struct LazyLayoutData { void destroyNode(); }; +struct ViewNodeTranslation { + float value = 0.0f; + + float getResolvedValue(float referenceLength, bool isPercent) const; +}; + struct ViewNodeUpdateViewTreeResult { int visitedNodes = 0; int reinsertedViews = 0; @@ -549,7 +555,7 @@ class ViewNode : public SharedPtrRefCountable { void setIgnoreParentViewport(bool ignoreParentViewport); float getTranslationX() const; - void setTranslationX(float translationX); + void setTranslationX(float translationX, bool isPercent); /** * Returns the effective translation X that should be used for the backing view. @@ -558,7 +564,7 @@ class ViewNode : public SharedPtrRefCountable { float getDirectionDependentTranslationX() const; float getTranslationY() const; - void setTranslationY(float translationY); + void setTranslationY(float translationY, bool isPercent); void setScaleX(float scaleX); void setScaleY(float scaleY); @@ -628,8 +634,8 @@ class ViewNode : public SharedPtrRefCountable { Frame _calculatedFrame; Frame _viewFrame; Frame _previousViewFrame; - float _translationX = 0; - float _translationY = 0; + ViewNodeTranslation _translationX; + ViewNodeTranslation _translationY; float _scaleX = 1.0f; float _scaleY = 1.0f; std::unique_ptr _scrollState; @@ -653,7 +659,7 @@ class ViewNode : public SharedPtrRefCountable { float _stickyCachedParentH = 0.0f; float _stickyCachedChildH = 0.0f; - std::bitset<30> _flags; + std::bitset<32> _flags; ViewNodeTree* _viewNodeTree = nullptr; @@ -791,7 +797,7 @@ class ViewNode : public SharedPtrRefCountable { void onChildrenChanged(); void setChildrenIndexerNeedsUpdate(); - void updateTranslation(float translation, float* outValue); + void updateTranslation(float translation, bool isPercent, ViewNodeTranslation* outTranslation, size_t percentFlag); void setCalculatedViewportHasChildNeedsUpdate(); diff --git a/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.cpp b/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.cpp index 09767906..d05d268d 100644 --- a/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.cpp +++ b/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.cpp @@ -137,11 +137,7 @@ void LayerClass::bindAttributes(Valdi::AttributesBindingContext& binder) { BIND_BORDER_ATTRIBUTE(Layer, borderRadius, false); BIND_DOUBLE_ATTRIBUTE(Layer, opacity, false); - BIND_DOUBLE_ATTRIBUTE(Layer, translationX, false); - BIND_DOUBLE_ATTRIBUTE(Layer, translationY, false); - BIND_DOUBLE_ATTRIBUTE(Layer, scaleX, false); - BIND_DOUBLE_ATTRIBUTE(Layer, scaleY, false); - BIND_DOUBLE_ATTRIBUTE(Layer, rotation, false); + BIND_TRANSFORM_ATTRIBUTES(Layer, transform); BIND_DOUBLE_ATTRIBUTE(Layer, borderWidth, false); BIND_DOUBLE_ATTRIBUTE(Layer, onTouchDelayDuration, false); @@ -248,74 +244,97 @@ void LayerClass::reset_opacity(Layer& view, const AttributeContext& context) { context.setAnimatableAttribute(view, &Layer::getOpacity, &Layer::setOpacity, MIN_VISIBLE_CHANGE_COLOR, 1.0f); } -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -Valdi::Result LayerClass::apply_translationX(Layer& view, double value, const AttributeContext& context) { - auto translationX = resolveDeltaX(view, static_cast(value)); - context.setAnimatableAttribute( - view, &Layer::getTranslationX, &Layer::setTranslationX, MIN_VISIBLE_CHANGE_PIXEL, translationX); - return Valdi::Void(); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -void LayerClass::reset_translationX(Layer& view, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getTranslationX, &Layer::setTranslationX, MIN_VISIBLE_CHANGE_PIXEL, static_cast(0)); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -Valdi::Result LayerClass::apply_translationY(Layer& view, double value, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getTranslationY, &Layer::setTranslationY, MIN_VISIBLE_CHANGE_PIXEL, static_cast(value)); - return Valdi::Void(); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -void LayerClass::reset_translationY(Layer& view, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getTranslationY, &Layer::setTranslationY, MIN_VISIBLE_CHANGE_PIXEL, static_cast(0)); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -Valdi::Result LayerClass::apply_scaleX(Layer& view, double value, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getScaleX, &Layer::setScaleX, MIN_VISIBLE_CHANGE_SCALE_RATIO, static_cast(value)); - return Valdi::Void(); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -void LayerClass::reset_scaleX(Layer& view, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getScaleX, &Layer::setScaleX, MIN_VISIBLE_CHANGE_SCALE_RATIO, static_cast(1)); -} - -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -Valdi::Result LayerClass::apply_scaleY(Layer& view, double value, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getScaleY, &Layer::setScaleY, MIN_VISIBLE_CHANGE_SCALE_RATIO, static_cast(value)); - return Valdi::Void(); -} +namespace { + +template +void setAnimatableTransformAttribute(T& view, + const AttributeContext& context, + V (T::*getter)() const, + void (T::*setter)(V), + double minVisibleChange, + V value) { + context.setAnimatableAttribute(view, getter, setter, minVisibleChange, value); +} + +void setTransformAttributes(Layer& view, + const AttributeContext& context, + Scalar translationX, + Scalar translationY, + Scalar scaleX, + Scalar scaleY, + Scalar rotation) { + static auto kTranslationXName = STRING_LITERAL("translationX"); + static auto kTranslationYName = STRING_LITERAL("translationY"); + static auto kScaleXName = STRING_LITERAL("scaleX"); + static auto kScaleYName = STRING_LITERAL("scaleY"); + static auto kRotationName = STRING_LITERAL("rotation"); + + setAnimatableTransformAttribute(view, + context.withAttributeName(kTranslationXName), + &Layer::getTranslationX, + &Layer::setTranslationX, + MIN_VISIBLE_CHANGE_PIXEL, + translationX); + + setAnimatableTransformAttribute(view, + context.withAttributeName(kTranslationYName), + &Layer::getTranslationY, + &Layer::setTranslationY, + MIN_VISIBLE_CHANGE_PIXEL, + translationY); + + setAnimatableTransformAttribute(view, + context.withAttributeName(kScaleXName), + &Layer::getScaleX, + &Layer::setScaleX, + MIN_VISIBLE_CHANGE_SCALE_RATIO, + scaleX); + + setAnimatableTransformAttribute(view, + context.withAttributeName(kScaleYName), + &Layer::getScaleY, + &Layer::setScaleY, + MIN_VISIBLE_CHANGE_SCALE_RATIO, + scaleY); + + setAnimatableTransformAttribute(view, + context.withAttributeName(kRotationName), + &Layer::getRotation, + &Layer::setRotation, + MIN_VISIBLE_CHANGE_ROTATION_DEGREES_ANGLE, + rotation); +} + +} // namespace // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -void LayerClass::reset_scaleY(Layer& view, const AttributeContext& context) { - context.setAnimatableAttribute( - view, &Layer::getScaleY, &Layer::setScaleY, MIN_VISIBLE_CHANGE_SCALE_RATIO, static_cast(1)); -} +Valdi::Result LayerClass::apply_transform(Layer& view, + const Valdi::Value& value, + const AttributeContext& context) { + const auto* array = value.getArray(); + if (array == nullptr || array->size() != 5) { + return Valdi::Error("Expected 5 transform components"); + } -// NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -Valdi::Result LayerClass::apply_rotation(Layer& view, double value, const AttributeContext& context) { - auto rotationAngle = resolveDeltaX(view, static_cast(value)); - context.setAnimatableAttribute( - view, &Layer::getRotation, &Layer::setRotation, MIN_VISIBLE_CHANGE_ROTATION_DEGREES_ANGLE, rotationAngle); + setTransformAttributes(view, + context, + static_cast((*array)[0].toDouble()), + static_cast((*array)[1].toDouble()), + static_cast((*array)[2].toDouble()), + static_cast((*array)[3].toDouble()), + static_cast((*array)[4].toDouble())); return Valdi::Void(); } // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) -void LayerClass::reset_rotation(Layer& view, const AttributeContext& context) { - context.setAnimatableAttribute(view, - &Layer::getRotation, - &Layer::setRotation, - MIN_VISIBLE_CHANGE_ROTATION_DEGREES_ANGLE, - static_cast(0)); +void LayerClass::reset_transform(Layer& view, const AttributeContext& context) { + setTransformAttributes(view, + context, + static_cast(0), + static_cast(0), + static_cast(1), + static_cast(1), + static_cast(0)); } // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) diff --git a/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.hpp b/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.hpp index bf9c238b..9e5ac142 100644 --- a/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.hpp +++ b/valdi/src/valdi/snap_drawing/Layers/Classes/LayerClass.hpp @@ -48,29 +48,11 @@ class LayerClass : public ILayerClass { void reset_opacity(Layer& view, const AttributeContext& context); // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - Valdi::Result apply_translationX(Layer& view, double value, const AttributeContext& context); + Valdi::Result apply_transform(Layer& view, + const Valdi::Value& value, + const AttributeContext& context); // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - void reset_translationX(Layer& view, const AttributeContext& context); - - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - Valdi::Result apply_translationY(Layer& view, double value, const AttributeContext& context); - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - void reset_translationY(Layer& view, const AttributeContext& context); - - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - Valdi::Result apply_scaleX(Layer& view, double value, const AttributeContext& context); - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - void reset_scaleX(Layer& view, const AttributeContext& context); - - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - Valdi::Result apply_scaleY(Layer& view, double value, const AttributeContext& context); - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - void reset_scaleY(Layer& view, const AttributeContext& context); - - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - Valdi::Result apply_rotation(Layer& view, double value, const AttributeContext& context); - // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) - void reset_rotation(Layer& view, const AttributeContext& context); + void reset_transform(Layer& view, const AttributeContext& context); // NOLINTNEXTLINE(readability-identifier-naming, readability-convert-member-functions-to-static) Valdi::Result apply_slowClipping(Layer& view, bool value, const AttributeContext& context); diff --git a/valdi/src/valdi/snap_drawing/SnapDrawingViewManager.cpp b/valdi/src/valdi/snap_drawing/SnapDrawingViewManager.cpp index 6880d4e9..cd402fb6 100644 --- a/valdi/src/valdi/snap_drawing/SnapDrawingViewManager.cpp +++ b/valdi/src/valdi/snap_drawing/SnapDrawingViewManager.cpp @@ -253,6 +253,11 @@ class AttributesBindingContextProxy : public Valdi::AttributesBindingContext { attribute, parts, Valdi::makeShared(delegate, _hostViewManager)); } + Valdi::AttributeId bindTransformAttributes(const Valdi::Ref& delegate) override { + return _sourceBinder.bindTransformAttributes( + Valdi::makeShared(delegate, _hostViewManager)); + } + void setDefaultDelegate(const Valdi::Ref& delegate) override {} void setMeasureDelegate(const Valdi::Ref& measureDelegate) override { diff --git a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderMacros.hpp b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderMacros.hpp index 74547bde..4b3aaaee 100644 --- a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderMacros.hpp +++ b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderMacros.hpp @@ -57,6 +57,10 @@ struct AttributeContext; __parts__, \ __MAKE_SIMPLE_ATTRIBUTE__(__viewClass__, __attribute__, makeUntypedAttribute)) +#define BIND_TRANSFORM_ATTRIBUTES(__viewClass__, __attribute__) \ + __MAKE_ATTRIBUTE_CONSTANT(__attribute__); \ + binder.bindTransformAttributes(__MAKE_SIMPLE_ATTRIBUTE__(__viewClass__, __attribute__, makeUntypedAttribute)) + #define BIND_BORDER_ATTRIBUTE(__viewClass__, __attribute__, invalidateLayoutOnChange) \ __BIND_ATTRIBUTE__(__viewClass__, __attribute__, invalidateLayoutOnChange, Border) diff --git a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.cpp b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.cpp index 5487985f..c0cbf175 100644 --- a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.cpp +++ b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.cpp @@ -16,8 +16,15 @@ AttributeContext::AttributeContext(const Valdi::Ref& animator, : animator(Valdi::castOrNull(animator != nullptr ? animator->getNativeAnimator() : nullptr)), attributeName(attributeName) {} +AttributeContext::AttributeContext(const Valdi::Shared& animator, const Valdi::StringBox& attributeName) + : animator(animator), attributeName(attributeName) {} + AttributeContext::~AttributeContext() = default; +AttributeContext AttributeContext::withAttributeName(const Valdi::StringBox& attributeName) const { + return AttributeContext(animator, attributeName); +} + class SnapDrawingAttributeHandlerDelegate : public Valdi::ViewAttributeHandlerDelegate { public: explicit SnapDrawingAttributeHandlerDelegate(AttributeResetter&& resetter) : _resetter(std::move(resetter)) {} diff --git a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.hpp b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.hpp index d7e07325..8d001c7b 100644 --- a/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.hpp +++ b/valdi/src/valdi/snap_drawing/Utils/AttributesBinderUtils.hpp @@ -37,6 +37,8 @@ struct AttributeContext { AttributeContext(const Valdi::Ref& animator, const Valdi::StringBox& attributeName); ~AttributeContext(); + AttributeContext withAttributeName(const Valdi::StringBox& attributeName) const; + template void setAnimatableAttribute(T& view, V (T::*getter)() const, @@ -75,6 +77,9 @@ struct AttributeContext { return interpolateValue(from, to, ratio); }); } + +private: + AttributeContext(const Valdi::Shared& animator, const Valdi::StringBox& attributeName); }; template diff --git a/valdi/test/integration/Runtime_tests.cpp b/valdi/test/integration/Runtime_tests.cpp index 6fbb3373..75546490 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -2839,15 +2839,27 @@ TEST_P(RuntimeFixture, handlesTranslationsInLimitToViewport) { ASSERT_EQ(0.0, childViewNode->getTranslationY()); ASSERT_TRUE(childViewNode->isVisibleInViewport()); - auto renderFunc = Function([&](double translationX, double translationY) { + auto renderFunc = [&](Value translationX, Value translationY) { auto viewModel = makeShared(); - (*viewModel)[STRING_LITERAL("translationX")] = Value(translationX); - (*viewModel)[STRING_LITERAL("translationY")] = Value(translationY); + (*viewModel)[STRING_LITERAL("translationX")] = std::move(translationX); + (*viewModel)[STRING_LITERAL("translationY")] = std::move(translationY); wrapper.setViewModel(tree->getContext(), Value(std::move(viewModel))); wrapper.waitUntilAllUpdatesCompleted(); - }); + }; + + renderFunc(Value(STRING_LITERAL("40%")), Value(25.0)); + + ASSERT_EQ(20.0, childViewNode->getTranslationX()); + ASSERT_EQ(25.0, childViewNode->getTranslationY()); + ASSERT_TRUE(childViewNode->isVisibleInViewport()); + + renderFunc(Value(25.0), Value(STRING_LITERAL("-40%"))); + + ASSERT_EQ(25.0, childViewNode->getTranslationX()); + ASSERT_EQ(-20.0, childViewNode->getTranslationY()); + ASSERT_TRUE(childViewNode->isVisibleInViewport()); - renderFunc(50, 50); + renderFunc(Value(50.0), Value(50.0)); ASSERT_EQ(50.0, childViewNode->getTranslationX()); ASSERT_EQ(50.0, childViewNode->getTranslationY()); @@ -2858,7 +2870,7 @@ TEST_P(RuntimeFixture, handlesTranslationsInLimitToViewport) { .addChild(DummyView("SCValdiView").addAttribute("translationX", 50.0).addAttribute("translationY", 50.0)), getRootView(tree)); - renderFunc(100, 100); + renderFunc(Value(100.0), Value(100.0)); ASSERT_EQ(100.0, childViewNode->getTranslationX()); ASSERT_EQ(100.0, childViewNode->getTranslationY()); @@ -2866,7 +2878,7 @@ TEST_P(RuntimeFixture, handlesTranslationsInLimitToViewport) { ASSERT_EQ(DummyView("SCValdiView"), getRootView(tree)); - renderFunc(99, 99); + renderFunc(Value(99.0), Value(99.0)); ASSERT_EQ(99.0, childViewNode->getTranslationX()); ASSERT_EQ(99.0, childViewNode->getTranslationY()); diff --git a/valdi/test/runtime/AttributeProcessors_tests.cpp b/valdi/test/runtime/AttributeProcessors_tests.cpp index 929b763e..1fcb98c4 100644 --- a/valdi/test/runtime/AttributeProcessors_tests.cpp +++ b/valdi/test/runtime/AttributeProcessors_tests.cpp @@ -1,4 +1,5 @@ #include "valdi/runtime/Attributes/DefaultAttributeProcessors.hpp" +#include "valdi/runtime/Attributes/TransformAttributes.hpp" #include "valdi/runtime/Attributes/ValueConverters.hpp" #include "valdi_core/cpp/Attributes/AttributeUtils.hpp" #include "valdi_core/cpp/Utils/StringCache.hpp" @@ -26,6 +27,61 @@ static Value makeGradientValue(std::vector colors, std::vector lo return Value(ValueArray::make({Value(outColors), Value(outLocations), Value(angle), Value(radial)})); } +static Value makeTransformValue(const Value& translationX, + const Value& translationY, + const Value& scaleX, + const Value& scaleY, + const Value& rotation, + const Value& transformOrigin) { + return Value(ValueArray::make({transformOrigin, + Value::undefinedRef(), + translationX, + translationY, + scaleX, + scaleY, + rotation})); +} + +static Value makeTransformValue(double translationX, + double translationY, + double scaleX, + double scaleY, + double rotation, + const Value& transformOrigin = Value::undefinedRef()) { + return makeTransformValue(Value(translationX), + Value(translationY), + Value(scaleX), + Value(scaleY), + Value(rotation), + transformOrigin); +} + +static Value makeTransformStringValue(const Value& transform, const Value& transformOrigin = Value::undefinedRef()) { + return Value(ValueArray::make({transformOrigin, + transform, + Value::undefinedRef(), + Value::undefinedRef(), + Value::undefinedRef(), + Value::undefinedRef(), + Value::undefinedRef()})); +} + +static void expectTransformValues(const Value& value, + double translationX, + double translationY, + double scaleX, + double scaleY, + double rotation) { + const auto* values = value.getArray(); + ASSERT_NE(values, nullptr); + ASSERT_EQ(values->size(), 5); + EXPECT_NEAR((*values)[0].toDouble(), translationX, 0.00001); + EXPECT_NEAR((*values)[1].toDouble(), translationY, 0.00001); + EXPECT_NEAR((*values)[2].toDouble(), scaleX, 0.00001); + EXPECT_NEAR((*values)[3].toDouble(), scaleY, 0.00001); + EXPECT_NEAR((*values)[4].toDouble(), rotation, 0.00001); +} + TEST(AttributeProcessor, canParseSimpleBackground) { auto result = preprocessGradient(kColorPalette, Value(STRING_LITERAL("red"))); @@ -348,4 +404,155 @@ TEST(AttributeProcessor, flipsHorizontalBordersOnRTL) { ASSERT_EQ(borderRadius->getBottomRight(), rtlBorderRadius->getBottomLeft()); } +TEST(AttributeProcessor, transformAttributesPostprocessKeepsCenterOriginTransforms) { + auto result = TransformAttributes::postprocess(100, 80, false, makeTransformValue(10, 20, 2, 3, 0.5)); + ASSERT_TRUE(result.success()) << result.description(); + + expectTransformValues(result.value(), 10, 20, 2, 3, 0.5); +} + +TEST(AttributeProcessor, transformAttributesPostprocessResolvesKeywordOrigins) { + auto result = + TransformAttributes::postprocess(100, + 80, + false, + makeTransformValue(0, 0, 2, 3, 0, Value(STRING_LITERAL("top left")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 50, 80, 2, 3, 0); + + result = + TransformAttributes::postprocess(100, + 80, + false, + makeTransformValue(0, 0, 2, 2, 0, Value(STRING_LITERAL("right bottom")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), -50, -40, 2, 2, 0); +} + +TEST(AttributeProcessor, transformAttributesPostprocessResolvesLengthAndPercentOrigins) { + auto result = + TransformAttributes::postprocess(100, + 80, + false, + makeTransformValue(0, 0, 2, 2, 0, Value(STRING_LITERAL("50px 70px")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 0, -30, 2, 2, 0); + + result = + TransformAttributes::postprocess(100, + 80, + false, + makeTransformValue(0, 0, 2, 2, 0, Value(STRING_LITERAL("25% 75%")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 25, -20, 2, 2, 0); +} + +TEST(AttributeProcessor, transformAttributesPostprocessResolvesPercentOriginAgainstFrame) { + auto result = + TransformAttributes::postprocess(200, + 80, + false, + makeTransformValue(0, 0, 1.5, 0.5, 0, Value(STRING_LITERAL("10% 25%")))); + ASSERT_TRUE(result.success()) << result.description(); + + expectTransformValues(result.value(), 40, -10, 1.5, 0.5, 0); +} + +TEST(AttributeProcessor, transformAttributesPostprocessResolvesTranslationPercentagesAgainstFrame) { + auto result = TransformAttributes::postprocess(100, + 80, + false, + makeTransformValue(Value(STRING_LITERAL("10%")), + Value(STRING_LITERAL("25%")), + Value(1.0), + Value(1.0), + Value(0.0), + Value::undefinedRef())); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 10, 20, 1, 1, 0); +} + +TEST(AttributeProcessor, transformAttributesPostprocessParsesWebTransformStrings) { + auto result = TransformAttributes::postprocess( + 100, + 80, + false, + makeTransformStringValue(Value(STRING_LITERAL("translate(50%, -25%) translateX(10px) translateY(5pt)")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 60, -15, 1, 1, 0); + + result = TransformAttributes::postprocess( + 100, + 80, + false, + makeTransformStringValue(Value(STRING_LITERAL("translate(10px, 20px) rotate(90deg) scale(2, 3)")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), 10, 20, 2, 3, M_PI_2); +} + +TEST(AttributeProcessor, transformAttributesPostprocessAppliesOriginToWebTransformStrings) { + auto result = TransformAttributes::postprocess( + 100, + 100, + false, + makeTransformStringValue(Value(STRING_LITERAL("rotate(90deg)")), Value(STRING_LITERAL("top left")))); + ASSERT_TRUE(result.success()) << result.description(); + + expectTransformValues(result.value(), -100, 0, 1, 1, M_PI_2); +} + +TEST(AttributeProcessor, transformAttributesPostprocessAppliesRtlToWebTransformStrings) { + auto result = + TransformAttributes::postprocess( + 100, 80, true, makeTransformStringValue(Value(STRING_LITERAL("translateX(50%) rotate(90deg)")))); + ASSERT_TRUE(result.success()) << result.description(); + expectTransformValues(result.value(), -50, 0, 1, 1, -M_PI_2); +} + +TEST(AttributeProcessor, transformAttributesPostprocessRejectsInvalidWebTransformStrings) { + auto result = TransformAttributes::postprocess( + 100, 80, false, makeTransformStringValue(Value(STRING_LITERAL("translateX(50%) nope(1)")))); + ASSERT_FALSE(result.success()) << result.description(); + + result = TransformAttributes::postprocess( + 100, 80, false, makeTransformStringValue(Value(STRING_LITERAL("translateX(10px")))); + ASSERT_FALSE(result.success()) << result.description(); +} + +TEST(AttributeProcessor, transformAttributesPostprocessFoldsRotationAroundNonCenterOrigin) { + auto result = TransformAttributes::postprocess( + 100, 100, false, makeTransformValue(0, 0, 1, 1, M_PI_2, Value(STRING_LITERAL("top left")))); + ASSERT_TRUE(result.success()) << result.description(); + + expectTransformValues(result.value(), -100, 0, 1, 1, M_PI_2); +} + +TEST(AttributeProcessor, transformAttributesPostprocessPreservesExistingRtlBehavior) { + auto result = TransformAttributes::postprocess(100, 100, true, makeTransformValue(10, 20, 1, 1, M_PI_2)); + ASSERT_TRUE(result.success()) << result.description(); + + expectTransformValues(result.value(), -10, 20, 1, 1, -M_PI_2); +} + +TEST(AttributeProcessor, transformAttributesPostprocessRejectsInvalidOrigins) { + auto result = + TransformAttributes::postprocess(100, + 100, + false, + makeTransformValue(0, 0, 1, 1, 0, Value(STRING_LITERAL("top bottom")))); + ASSERT_FALSE(result.success()) << result.description(); + + result = TransformAttributes::postprocess(100, + 100, + false, + makeTransformValue(0, 0, 1, 1, 0, Value(STRING_LITERAL("10px 20px 0")))); + ASSERT_FALSE(result.success()) << result.description(); + + result = TransformAttributes::postprocess(100, + 100, + false, + makeTransformValue(0, 0, 1, 1, 0, Value(STRING_LITERAL("10 20")))); + ASSERT_FALSE(result.success()) << result.description(); +} + } // namespace ValdiTest diff --git a/valdi/test/runtime/ViewNode_tests.cpp b/valdi/test/runtime/ViewNode_tests.cpp index f4f12bd8..484da868 100644 --- a/valdi/test/runtime/ViewNode_tests.cpp +++ b/valdi/test/runtime/ViewNode_tests.cpp @@ -585,6 +585,89 @@ TEST(ViewNode, canCalculateViewportWithTranslateInRTL) { ASSERT_EQ(Frame(24, 0, 76, 77), child->getCalculatedViewport()); } +TEST(ViewNode, canResolvePercentTranslations) { + ViewNodeTestsDependencies utils; + + auto root = utils.createRootView(); + auto child = utils.createView(); + + root->appendChild(utils.getViewTransactionScope(), child); + + utils.setViewNodeFrame(child, 20, 20, 100, 80); + utils.setViewNodeAttribute(child, "translationX", Value(STRING_LITERAL("50%"))); + utils.setViewNodeAttribute(child, "translationY", Value(STRING_LITERAL("-50%"))); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_FLOAT_EQ(50.0f, child->getTranslationX()); + ASSERT_FLOAT_EQ(50.0f, child->getDirectionDependentTranslationX()); + ASSERT_FLOAT_EQ(-40.0f, child->getTranslationY()); +} + +TEST(ViewNode, flipsResolvedPercentTranslationXInRTL) { + ViewNodeTestsDependencies utils; + + auto root = utils.createRootView(); + auto child = utils.createView(); + + root->appendChild(utils.getViewTransactionScope(), child); + + utils.setViewNodeFrame(child, 20, 20, 100, 100); + utils.setViewNodeAttribute(child, "translationX", Value(STRING_LITERAL("50%"))); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionRTL); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_FLOAT_EQ(50.0f, child->getTranslationX()); + ASSERT_FLOAT_EQ(-50.0f, child->getDirectionDependentTranslationX()); +} + +TEST(ViewNode, canCalculateViewportWithPercentTranslate) { + ViewNodeTestsDependencies utils; + + auto root = utils.createRootView(); + auto child = utils.createView(); + + root->appendChild(utils.getViewTransactionScope(), child); + + utils.setViewNodeFrame(child, 20, 20, 100, 100); + utils.setViewNodeAttribute(child, "translationX", Value(STRING_LITERAL("50%"))); + utils.setViewNodeAttribute(child, "translationY", Value(STRING_LITERAL("50%"))); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_TRUE(child->isVisibleInViewport()); + ASSERT_EQ(Frame(0, 0, 30, 30), child->getCalculatedViewport()); +} + +TEST(ViewNode, updatesPercentTranslateWhenFrameSizeChanges) { + ViewNodeTestsDependencies utils; + + auto root = utils.createRootView(); + auto child = utils.createView(); + + root->appendChild(utils.getViewTransactionScope(), child); + + utils.setViewNodeFrame(child, 20, 20, 40, 40); + utils.setViewNodeAttribute(child, "translationX", Value(STRING_LITERAL("50%"))); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_FLOAT_EQ(20.0f, child->getTranslationX()); + ASSERT_EQ(Frame(0, 0, 40, 40), child->getCalculatedViewport()); + + utils.setViewNodeAttribute(child, "width", Value(120.0)); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_FLOAT_EQ(60.0f, child->getTranslationX()); + ASSERT_EQ(Frame(0, 0, 20, 40), child->getCalculatedViewport()); +} + TEST(ViewNode, canUseUserDefinedViewport) { ViewNodeTestsDependencies utils; @@ -651,8 +734,8 @@ TEST(ViewNode, canExtendViewportWithChildren) { utils.setViewNodeFrame(container, 0, 0, 100, 100); utils.setViewNodeFrame(child, 100, 100, 50, 50); - child->setTranslationX(10); - child->setTranslationY(20); + child->setTranslationX(10, false); + child->setTranslationY(20, false); root->appendChild(utils.getViewTransactionScope(), container); container->appendChild(utils.getViewTransactionScope(), child); @@ -2222,6 +2305,43 @@ TEST(ViewNode, invalidateChildrenIndexerWhenTranslateChanges) { ASSERT_TRUE(root->getChildrenIndexer()->needsUpdate()); } +TEST(ViewNode, childrenIndexerUsesUpdatedPercentTranslateAfterFrameSizeChanges) { + ViewNodeTestsDependencies utils; + + auto root = utils.createRootView(); + + std::vector> children; + for (size_t i = 0; i < kMaxChildrenBeforeIndexing + 1; i++) { + auto newChild = utils.createView(); + utils.setViewNodeFrame(newChild, 0, static_cast(i) * 20, 20, 20); + root->appendChild(utils.getViewTransactionScope(), newChild); + + // Put them in an array as we currently don't retain children automatically. + // The ViewNodeTree is responsible for retaining the nodes. + children.emplace_back(std::move(newChild)); + } + + auto translatedChild = children[6]; + utils.setViewNodeAttribute(translatedChild, "translationY", Value(STRING_LITERAL("-50%"))); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_TRUE(root->getChildrenIndexer() != nullptr); + ASSERT_FALSE(root->getChildrenIndexer()->needsUpdate()); + ASSERT_FALSE(translatedChild->isVisibleInViewport()); + ASSERT_FLOAT_EQ(-10.0f, translatedChild->getTranslationY()); + + utils.setViewNodeAttribute(translatedChild, "height", Value(60.0)); + + root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); + root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); + + ASSERT_FALSE(root->getChildrenIndexer()->needsUpdate()); + ASSERT_TRUE(translatedChild->isVisibleInViewport()); + ASSERT_FLOAT_EQ(-30.0f, translatedChild->getTranslationY()); +} + TEST(ViewNode, childrenAreUpdatedWhenTheyConsumeNoSpaceInChildrenIndexer) { ViewNodeTestsDependencies utils; @@ -2525,8 +2645,8 @@ TEST(ViewNode, canResolveVisualPoints) { root->appendChild(utils.getViewTransactionScope(), mainContainer); mainContainer->appendChild(utils.getViewTransactionScope(), scrollContainer); - mainContainer->setTranslationX(5); - mainContainer->setTranslationY(3); + mainContainer->setTranslationX(5, false); + mainContainer->setTranslationY(3, false); std::vector> items; std::vector> nesteds; @@ -2942,8 +3062,8 @@ TEST(ViewNode, handlesLimitToViewportDisabledOnInvisibleParentLayout) { view->setLimitToViewport(LimitToViewportDisabled); - scrollChild->setTranslationX(9999999); - scroll->setTranslationX(200); + scrollChild->setTranslationX(9999999, false); + scroll->setTranslationX(200, false); root->performLayout(utils.getViewTransactionScope(), Size(100, 100), LayoutDirectionLTR); root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); @@ -2959,7 +3079,7 @@ TEST(ViewNode, handlesLimitToViewportDisabledOnInvisibleParentLayout) { // The view should not have a parent, since there are no available parents before it ASSERT_FALSE(view->isIncludedInViewParent()); - scroll->setTranslationX(0); + scroll->setTranslationX(0, false); root->updateVisibilityAndPerformUpdates(utils.getViewTransactionScope()); ASSERT_TRUE(container->isVisibleInViewport()); diff --git a/valdi/testdata/resources/modules/test/src/LimitToViewportTranslate.tsx b/valdi/testdata/resources/modules/test/src/LimitToViewportTranslate.tsx index 3ad26d4e..25641279 100644 --- a/valdi/testdata/resources/modules/test/src/LimitToViewportTranslate.tsx +++ b/valdi/testdata/resources/modules/test/src/LimitToViewportTranslate.tsx @@ -1,8 +1,8 @@ import { Component } from 'valdi_core/src/Component'; interface ViewModel { - translationX: number; - translationY: number; + translationX: number | string; + translationY: number | string; } export class TestComponent extends Component { @@ -13,4 +13,4 @@ export class TestComponent extends Component { } -} \ No newline at end of file +} diff --git a/valdi_core/src/valdi_core/ios/valdi_core/SCValdiAttributesBinderBase.h b/valdi_core/src/valdi_core/ios/valdi_core/SCValdiAttributesBinderBase.h index 7c3ad937..7d7d7a2b 100644 --- a/valdi_core/src/valdi_core/ios/valdi_core/SCValdiAttributesBinderBase.h +++ b/valdi_core/src/valdi_core/ios/valdi_core/SCValdiAttributesBinderBase.h @@ -188,6 +188,9 @@ typedef NSDictionary SCAttributeAp resetBlock:(SCValdiAttributeBindMethodReset)resetBlock NS_SWIFT_NAME(bindCompositeAttribute(_:parts:withUntypedBlock:resetBlock:)); +- (void)bindTransformAttributesWithUntypedBlock:(SCValdiAttributeBindMethodUntyped)untypedBlock + resetBlock:(SCValdiAttributeBindMethodReset)resetBlock; + - (void)bindAttribute:(NSString*)attributeName invalidateLayoutOnChange:(BOOL)invalidateLayoutOnChange deserializer:(SCValdiAttributeDeserializer)deserializer