diff --git a/docs/api/api-quick-reference.md b/docs/api/api-quick-reference.md index 2998768c..47d8b84a 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 8049f94d..e3f3c38e 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 6f6376eb..8e97cc98 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 5415b2bd..036a3f68 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 @@ -978,19 +978,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 80f5c582..bfe50ecf 100644 --- a/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt +++ b/valdi/src/java/com/snap/valdi/attributes/AttributesBindingContext.kt @@ -373,6 +373,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 1d33c32c..8115520e 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 }, + { this.translationX }, + { this.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, + { this.translationY }, + { this.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, + { this.scaleX }, + { this.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, + { this.scaleY }, + { this.scaleY = it }, + ValdiValueAnimation.MinimumVisibleChange.SCALE_RATIO) setTransformElement(view, - resolvedValue, - animator, - ROTATION_KEY, - { rotation }, - { rotation = it }, - ValdiValueAnimation.MinimumVisibleChange.ROTATION_DEGREES_ANGLE) + rotation, + animator, + ROTATION_KEY, + { this.rotation }, + { this.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 { @@ -618,11 +573,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 bf7f7e59..f0f692fe 100644 --- a/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java +++ b/valdi/src/java/com/snapchat/client/valdi/NativeBridge.java @@ -116,7 +116,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); @@ -211,6 +210,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 6427b337..d75e8e94 100644 --- a/valdi/src/valdi/android/NativeBridge.cpp +++ b/valdi/src/valdi/android/NativeBridge.cpp @@ -1866,6 +1866,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); @@ -2569,6 +2576,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 3022e579..208b896b 100644 --- a/valdi/src/valdi/android/NativeBridge.hpp +++ b/valdi/src/valdi/android/NativeBridge.hpp @@ -199,6 +199,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/runtime/Attributes/TransformAttributes.cpp b/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp index 7c5e750f..2347feee 100644 --- a/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp +++ b/valdi/src/valdi/runtime/Attributes/TransformAttributes.cpp @@ -562,6 +562,7 @@ Value postprocessResolvedTransform(ResolvedTransform resolvedTransform, if (isRightToLeft) { components.translationX *= -1.0; components.rotation *= -1.0; + origin.x = width - origin.x; } if (origin.isCenter) { diff --git a/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp b/valdi/src/valdi/runtime/Attributes/ViewNodeAttributesApplier.cpp index 0c2e2d4f..0aaeb169 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 d6c0583d..547a5e3d 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.cpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.cpp @@ -127,6 +127,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; @@ -165,6 +172,8 @@ constexpr size_t kParentManagesChildFrames = 30; constexpr size_t kManagesChildFrames = 31; constexpr size_t kIsMeasuring = 32; constexpr size_t kManagedChildrenLayoutNeedsCommit = 33; +constexpr size_t kTranslationXIsPercent = 34; +constexpr size_t kTranslationYIsPercent = 35; ViewNode::ViewNode(YGConfig* yogaConfig, AttributeIds& attributeIds, ILogger& logger) : _yogaNode(yogaConfig != nullptr ? Yoga::createNode(yogaConfig) : nullptr), @@ -1385,12 +1394,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 { @@ -2092,7 +2101,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)) { @@ -2545,10 +2554,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; @@ -2556,7 +2564,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; @@ -3977,27 +3985,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) { @@ -4022,9 +4031,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 a569a357..cfd17225 100644 --- a/valdi/src/valdi/runtime/Context/ViewNode.hpp +++ b/valdi/src/valdi/runtime/Context/ViewNode.hpp @@ -91,6 +91,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; @@ -562,7 +568,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. @@ -571,7 +577,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); @@ -644,8 +650,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; @@ -669,7 +675,7 @@ class ViewNode : public SharedPtrRefCountable { float _stickyCachedParentH = 0.0f; float _stickyCachedChildH = 0.0f; - std::bitset<34> _flags; + std::bitset<36> _flags; ViewNodeTree* _viewNodeTree = nullptr; @@ -811,7 +817,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/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 23947451..061f3e17 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -2840,15 +2840,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()); @@ -2859,7 +2871,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()); @@ -2867,7 +2879,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/java/attributes/ViewAttributesBinderTest.kt b/valdi/test/java/attributes/ViewAttributesBinderTest.kt new file mode 100644 index 00000000..15480e20 --- /dev/null +++ b/valdi/test/java/attributes/ViewAttributesBinderTest.kt @@ -0,0 +1,67 @@ +package com.snap.valdi.attributes + +import android.content.Context +import android.view.View +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import com.snap.valdi.attributes.impl.ViewAttributesBinder +import com.snap.valdi.attributes.impl.animations.ValdiAnimator +import com.snap.valdi.attributes.impl.animations.ValdiValueAnimation +import com.snap.valdi.drawables.BoxShadowRendererPool +import com.snap.valdi.logger.Logger +import kotlin.math.PI +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +internal class ViewAttributesBinderTest { + private class CapturingAnimator : ValdiAnimator { + val animations = mutableListOf() + + override val beginFromCurrentState: Boolean = false + + override fun addValueAnimation( + key: Any, + view: View, + valueAnimation: ValdiValueAnimation?, + completion: ((success: Boolean) -> Unit)? + ) { + valueAnimation?.let { animations.add(it) } + } + } + + private object TestLogger : Logger { + override fun log(level: Int, message: String?) = Unit + + override fun log(level: Int, err: Throwable?, message: String?) = Unit + } + + private fun makeBinder(context: Context): ViewAttributesBinder { + return ViewAttributesBinder(context, TestLogger, BoxShadowRendererPool(context, TestLogger)) + } + + @Test + fun applyTransformAnimatesFromCurrentViewProperties() { + val context = getApplicationContext() + val view = View(context) + val animator = CapturingAnimator() + val density = context.resources.displayMetrics.density + + makeBinder(context).applyTransform( + view, + arrayOf(10.0, 20.0, 2.0, 3.0, PI), + animator + ) + + animator.animations.forEach { it.onProgressUpdate(0.5f) } + + assertEquals(5.0f * density, view.translationX, 0.001f) + assertEquals(10.0f * density, view.translationY, 0.001f) + assertEquals(1.5f, view.scaleX, 0.001f) + assertEquals(2.0f, view.scaleY, 0.001f) + assertEquals(90.0f, view.rotation, 0.001f) + } +} diff --git a/valdi/test/runtime/AttributeProcessors_tests.cpp b/valdi/test/runtime/AttributeProcessors_tests.cpp index 929b763e..5734a727 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,170 @@ 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, transformAttributesPostprocessMirrorsNonCenterOriginsInRtl) { + auto result = TransformAttributes::postprocess( + 100, 100, true, 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); + + result = TransformAttributes::postprocess( + 100, + 100, + true, + 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, 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 1e9b1e5a..7e7066e8 100644 --- a/valdi/test/runtime/ViewNode_tests.cpp +++ b/valdi/test/runtime/ViewNode_tests.cpp @@ -789,6 +789,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; @@ -855,8 +938,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); @@ -2426,6 +2509,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; @@ -2729,8 +2849,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; @@ -3146,8 +3266,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()); @@ -3163,7 +3283,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 +}