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