diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs
index 7949a62949..fa5ab2d45b 100644
--- a/src/Avalonia.Base/Utilities/MathUtilities.cs
+++ b/src/Avalonia.Base/Utilities/MathUtilities.cs
@@ -262,5 +262,35 @@ namespace Avalonia.Utilities
return val;
}
}
+
+ ///
+ /// Converts an angle in degrees to radians.
+ ///
+ /// The angle in degrees.
+ /// The angle in radians.
+ public static double Deg2Rad(double angle)
+ {
+ return angle * (Math.PI / 180d);
+ }
+
+ ///
+ /// Converts an angle in gradians to radians.
+ ///
+ /// The angle in gradians.
+ /// The angle in radians.
+ public static double Grad2Rad(double angle)
+ {
+ return angle * (Math.PI / 200d);
+ }
+
+ ///
+ /// Converts an angle in turns to radians.
+ ///
+ /// The angle in turns.
+ /// The angle in radians.
+ public static double Turn2Rad(double angle)
+ {
+ return angle * 2 * Math.PI;
+ }
}
}
diff --git a/src/Avalonia.Controls/LayoutTransformControl.cs b/src/Avalonia.Controls/LayoutTransformControl.cs
index 8d48f6646d..83ad2b3638 100644
--- a/src/Avalonia.Controls/LayoutTransformControl.cs
+++ b/src/Avalonia.Controls/LayoutTransformControl.cs
@@ -14,8 +14,8 @@ namespace Avalonia.Controls
///
public class LayoutTransformControl : Decorator
{
- public static readonly StyledProperty LayoutTransformProperty =
- AvaloniaProperty.Register(nameof(LayoutTransform));
+ public static readonly StyledProperty LayoutTransformProperty =
+ AvaloniaProperty.Register(nameof(LayoutTransform));
public static readonly StyledProperty UseRenderTransformProperty =
AvaloniaProperty.Register(nameof(LayoutTransform));
@@ -37,7 +37,7 @@ namespace Avalonia.Controls
///
/// Gets or sets a graphics transformation that should apply to this element when layout is performed.
///
- public Transform LayoutTransform
+ public ITransform LayoutTransform
{
get { return GetValue(LayoutTransformProperty); }
set { SetValue(LayoutTransformProperty, value); }
diff --git a/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs
index 1f1590bdcd..bb1c0da902 100644
--- a/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs
+++ b/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs
@@ -1,6 +1,8 @@
using System;
+using System.Reactive.Disposables;
using Avalonia.Logging;
using Avalonia.Media;
+using Avalonia.Media.Transformation;
namespace Avalonia.Animation.Animators
{
@@ -19,6 +21,12 @@ namespace Avalonia.Animation.Animators
// Check if the Target Property is Transform derived.
if (typeof(Transform).IsAssignableFrom(Property.OwnerType))
{
+ if (ctrl.RenderTransform is TransformOperations)
+ {
+ // HACK: This animator cannot reasonably animate CSS transforms at the moment.
+ return Disposable.Empty;
+ }
+
if (ctrl.RenderTransform == null)
{
var normalTransform = new TransformGroup();
@@ -51,7 +59,7 @@ namespace Avalonia.Animation.Animators
// It's a transform object so let's target that.
if (renderTransformType == Property.OwnerType)
{
- return _doubleAnimator.Apply(animation, ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete);
+ return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete);
}
// It's a TransformGroup and try finding the target there.
else if (renderTransformType == typeof(TransformGroup))
diff --git a/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs
new file mode 100644
index 0000000000..f45338122f
--- /dev/null
+++ b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs
@@ -0,0 +1,35 @@
+using System;
+using Avalonia.Media;
+using Avalonia.Media.Transformation;
+
+namespace Avalonia.Animation.Animators
+{
+ public class TransformOperationsAnimator : Animator
+ {
+ public TransformOperationsAnimator()
+ {
+ Validate = ValidateTransform;
+ }
+
+ private void ValidateTransform(AnimatorKeyFrame kf)
+ {
+ if (!(kf.Value is TransformOperations))
+ {
+ throw new InvalidOperationException($"All keyframes must be of type {typeof(TransformOperations)}.");
+ }
+ }
+
+ public override ITransform Interpolate(double progress, ITransform oldValue, ITransform newValue)
+ {
+ var oldTransform = Cast(oldValue);
+ var newTransform = Cast(newValue);
+
+ return TransformOperations.Interpolate(oldTransform, newTransform, progress);
+ }
+
+ private static TransformOperations Cast(ITransform value)
+ {
+ return value as TransformOperations ?? TransformOperations.Identity;
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs
new file mode 100644
index 0000000000..4911b34d91
--- /dev/null
+++ b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Reactive.Linq;
+using Avalonia.Animation.Animators;
+using Avalonia.Media;
+
+namespace Avalonia.Animation
+{
+ public class TransformOperationsTransition : Transition
+ {
+ private static readonly TransformOperationsAnimator _operationsAnimator = new TransformOperationsAnimator();
+
+ public override IObservable DoTransition(IObservable progress,
+ ITransform oldValue,
+ ITransform newValue)
+ {
+ return progress
+ .Select(p =>
+ {
+ var f = Easing.Ease(p);
+
+ return _operationsAnimator.Interpolate(f, oldValue, newValue);
+ });
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Matrix.cs b/src/Avalonia.Visuals/Matrix.cs
index 898c6027a5..e18140aa8d 100644
--- a/src/Avalonia.Visuals/Matrix.cs
+++ b/src/Avalonia.Visuals/Matrix.cs
@@ -319,5 +319,70 @@ namespace Avalonia
);
}
}
+
+ public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed)
+ {
+ decomposed = default;
+
+ var determinant = matrix.GetDeterminant();
+
+ if (determinant == 0)
+ {
+ return false;
+ }
+
+ var m11 = matrix.M11;
+ var m21 = matrix.M21;
+ var m12 = matrix.M12;
+ var m22 = matrix.M22;
+
+ // Translation.
+ decomposed.Translate = new Vector(matrix.M31, matrix.M32);
+
+ // Scale sign.
+ var scaleX = 1d;
+ var scaleY = 1d;
+
+ if (determinant < 0)
+ {
+ if (m11 < m22)
+ {
+ scaleX *= -1d;
+ }
+ else
+ {
+ scaleY *= -1d;
+ }
+ }
+
+ // X Scale.
+ scaleX *= Math.Sqrt(m11 * m11 + m12 * m12);
+
+ m11 /= scaleX;
+ m12 /= scaleX;
+
+ // XY Shear.
+ double scaledShear = m11 * m21 + m12 * m22;
+
+ m21 -= m11 * scaledShear;
+ m22 -= m12 * scaledShear;
+
+ // Y Scale.
+ scaleY *= Math.Sqrt(m21 * m21 + m22 * m22);
+
+ decomposed.Scale = new Vector(scaleX, scaleY);
+ decomposed.Skew = new Vector(scaledShear / scaleY, 0d);
+ decomposed.Angle = Math.Atan2(m12, m11);
+
+ return true;
+ }
+
+ public struct Decomposed
+ {
+ public Vector Translate;
+ public Vector Scale;
+ public Vector Skew;
+ public double Angle;
+ }
}
}
diff --git a/src/Avalonia.Visuals/Media/IMutableTransform.cs b/src/Avalonia.Visuals/Media/IMutableTransform.cs
new file mode 100644
index 0000000000..2033c434c0
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/IMutableTransform.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Avalonia.Media
+{
+ public interface IMutableTransform : ITransform
+ {
+ ///
+ /// Raised when the transform changes.
+ ///
+ event EventHandler Changed;
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/ITransform.cs b/src/Avalonia.Visuals/Media/ITransform.cs
new file mode 100644
index 0000000000..91577fe38e
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/ITransform.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel;
+
+namespace Avalonia.Media
+{
+ [TypeConverter(typeof(TransformConverter))]
+ public interface ITransform
+ {
+ Matrix Value { get; }
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/Transform.cs b/src/Avalonia.Visuals/Media/Transform.cs
index 70ef1eaaf4..7cf1b35ada 100644
--- a/src/Avalonia.Visuals/Media/Transform.cs
+++ b/src/Avalonia.Visuals/Media/Transform.cs
@@ -8,11 +8,12 @@ namespace Avalonia.Media
///
/// Represents a transform on an .
///
- public abstract class Transform : Animatable
+ public abstract class Transform : Animatable, IMutableTransform
{
static Transform()
{
- Animation.Animation.RegisterAnimator(prop => typeof(Transform).IsAssignableFrom(prop.OwnerType));
+ Animation.Animation.RegisterAnimator(prop =>
+ typeof(ITransform).IsAssignableFrom(prop.OwnerType));
}
///
diff --git a/src/Avalonia.Visuals/Media/TransformConverter.cs b/src/Avalonia.Visuals/Media/TransformConverter.cs
new file mode 100644
index 0000000000..e79c0b8b7b
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/TransformConverter.cs
@@ -0,0 +1,23 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using Avalonia.Media.Transformation;
+
+namespace Avalonia.Media
+{
+ ///
+ /// Creates an from a string representation.
+ ///
+ public class TransformConverter : TypeConverter
+ {
+ public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
+ {
+ return sourceType == typeof(string);
+ }
+
+ public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
+ {
+ return TransformOperations.Parse((string)value);
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs b/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs
new file mode 100644
index 0000000000..1e80eabfc8
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs
@@ -0,0 +1,40 @@
+namespace Avalonia.Media.Transformation
+{
+ internal static class InterpolationUtilities
+ {
+ public static double InterpolateScalars(double from, double to, double progress)
+ {
+ return from * (1d - progress) + to * progress;
+ }
+
+ public static Vector InterpolateVectors(Vector from, Vector to, double progress)
+ {
+ var x = InterpolateScalars(from.X, to.X, progress);
+ var y = InterpolateScalars(from.Y, to.Y, progress);
+
+ return new Vector(x, y);
+ }
+
+ public static Matrix ComposeTransform(Matrix.Decomposed decomposed)
+ {
+ // According to https://www.w3.org/TR/css-transforms-1/#recomposing-to-a-2d-matrix
+
+ return Matrix.CreateTranslation(decomposed.Translate) *
+ Matrix.CreateRotation(decomposed.Angle) *
+ Matrix.CreateSkew(decomposed.Skew.X, decomposed.Skew.Y) *
+ Matrix.CreateScale(decomposed.Scale);
+ }
+
+ public static Matrix.Decomposed InterpolateDecomposedTransforms(ref Matrix.Decomposed from, ref Matrix.Decomposed to, double progres)
+ {
+ Matrix.Decomposed result = default;
+
+ result.Translate = InterpolateVectors(from.Translate, to.Translate, progres);
+ result.Scale = InterpolateVectors(from.Scale, to.Scale, progres);
+ result.Skew = InterpolateVectors(from.Skew, to.Skew, progres);
+ result.Angle = InterpolateScalars(from.Angle, to.Angle, progres);
+
+ return result;
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs
new file mode 100644
index 0000000000..cdf31f8e5b
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs
@@ -0,0 +1,203 @@
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Transformation
+{
+ public struct TransformOperation
+ {
+ public OperationType Type;
+ public Matrix Matrix;
+ public DataLayout Data;
+
+ public enum OperationType
+ {
+ Translate,
+ Rotate,
+ Scale,
+ Skew,
+ Matrix,
+ Identity
+ }
+
+ public bool IsIdentity => Matrix.IsIdentity;
+
+ public void Bake()
+ {
+ Matrix = Matrix.Identity;
+
+ switch (Type)
+ {
+ case OperationType.Translate:
+ {
+ Matrix = Matrix.CreateTranslation(Data.Translate.X, Data.Translate.Y);
+
+ break;
+ }
+ case OperationType.Rotate:
+ {
+ Matrix = Matrix.CreateRotation(Data.Rotate.Angle);
+
+ break;
+ }
+ case OperationType.Scale:
+ {
+ Matrix = Matrix.CreateScale(Data.Scale.X, Data.Scale.Y);
+
+ break;
+ }
+ case OperationType.Skew:
+ {
+ Matrix = Matrix.CreateSkew(Data.Skew.X, Data.Skew.Y);
+
+ break;
+ }
+ }
+ }
+
+ public static bool IsOperationIdentity(ref TransformOperation? operation)
+ {
+ return !operation.HasValue || operation.Value.IsIdentity;
+ }
+
+ public static bool TryInterpolate(TransformOperation? from, TransformOperation? to, double progress,
+ ref TransformOperation result)
+ {
+ bool fromIdentity = IsOperationIdentity(ref from);
+ bool toIdentity = IsOperationIdentity(ref to);
+
+ if (fromIdentity && toIdentity)
+ {
+ return true;
+ }
+
+ TransformOperation fromValue = fromIdentity ? default : from.Value;
+ TransformOperation toValue = toIdentity ? default : to.Value;
+
+ var interpolationType = toIdentity ? fromValue.Type : toValue.Type;
+
+ result.Type = interpolationType;
+
+ switch (interpolationType)
+ {
+ case OperationType.Translate:
+ {
+ double fromX = fromIdentity ? 0 : fromValue.Data.Translate.X;
+ double fromY = fromIdentity ? 0 : fromValue.Data.Translate.Y;
+
+ double toX = toIdentity ? 0 : toValue.Data.Translate.X;
+ double toY = toIdentity ? 0 : toValue.Data.Translate.Y;
+
+ result.Data.Translate.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress);
+ result.Data.Translate.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress);
+
+ result.Bake();
+
+ break;
+ }
+ case OperationType.Rotate:
+ {
+ double fromAngle = fromIdentity ? 0 : fromValue.Data.Rotate.Angle;
+
+ double toAngle = toIdentity ? 0 : toValue.Data.Rotate.Angle;
+
+ result.Data.Rotate.Angle = InterpolationUtilities.InterpolateScalars(fromAngle, toAngle, progress);
+
+ result.Bake();
+
+ break;
+ }
+ case OperationType.Scale:
+ {
+ double fromX = fromIdentity ? 1 : fromValue.Data.Scale.X;
+ double fromY = fromIdentity ? 1 : fromValue.Data.Scale.Y;
+
+ double toX = toIdentity ? 1 : toValue.Data.Scale.X;
+ double toY = toIdentity ? 1 : toValue.Data.Scale.Y;
+
+ result.Data.Scale.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress);
+ result.Data.Scale.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress);
+
+ result.Bake();
+
+ break;
+ }
+ case OperationType.Skew:
+ {
+ double fromX = fromIdentity ? 0 : fromValue.Data.Skew.X;
+ double fromY = fromIdentity ? 0 : fromValue.Data.Skew.Y;
+
+ double toX = toIdentity ? 0 : toValue.Data.Skew.X;
+ double toY = toIdentity ? 0 : toValue.Data.Skew.Y;
+
+ result.Data.Skew.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress);
+ result.Data.Skew.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress);
+
+ result.Bake();
+
+ break;
+ }
+ case OperationType.Matrix:
+ {
+ var fromMatrix = fromIdentity ? Matrix.Identity : fromValue.Matrix;
+ var toMatrix = toIdentity ? Matrix.Identity : toValue.Matrix;
+
+ if (!Matrix.TryDecomposeTransform(fromMatrix, out Matrix.Decomposed fromDecomposed) ||
+ !Matrix.TryDecomposeTransform(toMatrix, out Matrix.Decomposed toDecomposed))
+ {
+ return false;
+ }
+
+ var interpolated =
+ InterpolationUtilities.InterpolateDecomposedTransforms(
+ ref fromDecomposed, ref toDecomposed,
+ progress);
+
+ result.Matrix = InterpolationUtilities.ComposeTransform(interpolated);
+
+ break;
+ }
+ case OperationType.Identity:
+ {
+ // Do nothing.
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ public struct DataLayout
+ {
+ [FieldOffset(0)] public SkewLayout Skew;
+
+ [FieldOffset(0)] public ScaleLayout Scale;
+
+ [FieldOffset(0)] public TranslateLayout Translate;
+
+ [FieldOffset(0)] public RotateLayout Rotate;
+
+ public struct SkewLayout
+ {
+ public double X;
+ public double Y;
+ }
+
+ public struct ScaleLayout
+ {
+ public double X;
+ public double Y;
+ }
+
+ public struct TranslateLayout
+ {
+ public double X;
+ public double Y;
+ }
+
+ public struct RotateLayout
+ {
+ public double Angle;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs
new file mode 100644
index 0000000000..9f711a2d63
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using JetBrains.Annotations;
+
+namespace Avalonia.Media.Transformation
+{
+ public sealed class TransformOperations : ITransform
+ {
+ public static TransformOperations Identity { get; } = new TransformOperations(new List());
+
+ private readonly List _operations;
+
+ private TransformOperations(List operations)
+ {
+ _operations = operations ?? throw new ArgumentNullException(nameof(operations));
+
+ IsIdentity = CheckIsIdentity();
+
+ Value = ApplyTransforms();
+ }
+
+ public bool IsIdentity { get; }
+
+ public IReadOnlyList Operations => _operations;
+
+ public Matrix Value { get; }
+
+ public static TransformOperations Parse(string s)
+ {
+ return TransformParser.Parse(s);
+ }
+
+ public static Builder CreateBuilder(int capacity)
+ {
+ return new Builder(capacity);
+ }
+
+ public static TransformOperations Interpolate(TransformOperations from, TransformOperations to, double progress)
+ {
+ TransformOperations result = Identity;
+
+ if (!TryInterpolate(from, to, progress, ref result))
+ {
+ // If the matrices cannot be interpolated, fallback to discrete animation logic.
+ // See https://drafts.csswg.org/css-transforms/#matrix-interpolation
+ result = progress < 0.5 ? from : to;
+ }
+
+ return result;
+ }
+
+ private Matrix ApplyTransforms(int startOffset = 0)
+ {
+ Matrix matrix = Matrix.Identity;
+
+ for (var i = startOffset; i < _operations.Count; i++)
+ {
+ TransformOperation operation = _operations[i];
+ matrix *= operation.Matrix;
+ }
+
+ return matrix;
+ }
+
+ private bool CheckIsIdentity()
+ {
+ foreach (TransformOperation operation in _operations)
+ {
+ if (!operation.IsIdentity)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool TryInterpolate(TransformOperations from, TransformOperations to, double progress, ref TransformOperations result)
+ {
+ bool fromIdentity = from.IsIdentity;
+ bool toIdentity = to.IsIdentity;
+
+ if (fromIdentity && toIdentity)
+ {
+ return true;
+ }
+
+ int matchingPrefixLength = ComputeMatchingPrefixLength(from, to);
+ int fromSize = fromIdentity ? 0 : from._operations.Count;
+ int toSize = toIdentity ? 0 : to._operations.Count;
+ int numOperations = Math.Max(fromSize, toSize);
+
+ var builder = new Builder(matchingPrefixLength);
+
+ for (int i = 0; i < matchingPrefixLength; i++)
+ {
+ TransformOperation interpolated = new TransformOperation
+ {
+ Type = TransformOperation.OperationType.Identity
+ };
+
+ if (!TransformOperation.TryInterpolate(
+ i >= fromSize ? default(TransformOperation?) : from._operations[i],
+ i >= toSize ? default(TransformOperation?) : to._operations[i],
+ progress,
+ ref interpolated))
+ {
+ return false;
+ }
+
+ builder.Append(interpolated);
+ }
+
+ if (matchingPrefixLength < numOperations)
+ {
+ if (!ComputeDecomposedTransform(from, matchingPrefixLength, out Matrix.Decomposed fromDecomposed) ||
+ !ComputeDecomposedTransform(to, matchingPrefixLength, out Matrix.Decomposed toDecomposed))
+ {
+ return false;
+ }
+
+ var transform = InterpolationUtilities.InterpolateDecomposedTransforms(ref fromDecomposed, ref toDecomposed, progress);
+
+ builder.AppendMatrix(InterpolationUtilities.ComposeTransform(transform));
+ }
+
+ result = builder.Build();
+
+ return true;
+ }
+
+ private static bool ComputeDecomposedTransform(TransformOperations operations, int startOffset, out Matrix.Decomposed decomposed)
+ {
+ Matrix transform = operations.ApplyTransforms(startOffset);
+
+ if (!Matrix.TryDecomposeTransform(transform, out decomposed))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static int ComputeMatchingPrefixLength(TransformOperations from, TransformOperations to)
+ {
+ int numOperations = Math.Min(from._operations.Count, to._operations.Count);
+
+ for (int i = 0; i < numOperations; i++)
+ {
+ if (from._operations[i].Type != to._operations[i].Type)
+ {
+ return i;
+ }
+ }
+
+ // If the operations match to the length of the shorter list, then pad its
+ // length with the matching identity operations.
+ // https://drafts.csswg.org/css-transforms/#transform-function-lists
+ return Math.Max(from._operations.Count, to._operations.Count);
+ }
+
+ public readonly struct Builder
+ {
+ private readonly List _operations;
+
+ public Builder(int capacity)
+ {
+ _operations = new List(capacity);
+ }
+
+ public void AppendTranslate(double x, double y)
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Translate;
+ toAdd.Data.Translate.X = x;
+ toAdd.Data.Translate.Y = y;
+
+ toAdd.Bake();
+
+ _operations.Add(toAdd);
+ }
+
+ public void AppendRotate(double angle)
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Rotate;
+ toAdd.Data.Rotate.Angle = angle;
+
+ toAdd.Bake();
+
+ _operations.Add(toAdd);
+ }
+
+ public void AppendScale(double x, double y)
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Scale;
+ toAdd.Data.Scale.X = x;
+ toAdd.Data.Scale.Y = y;
+
+ toAdd.Bake();
+
+ _operations.Add(toAdd);
+ }
+
+ public void AppendSkew(double x, double y)
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Skew;
+ toAdd.Data.Skew.X = x;
+ toAdd.Data.Skew.Y = y;
+
+ toAdd.Bake();
+
+ _operations.Add(toAdd);
+ }
+
+ public void AppendMatrix(Matrix matrix)
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Matrix;
+ toAdd.Matrix = matrix;
+
+ _operations.Add(toAdd);
+ }
+
+ public void AppendIdentity()
+ {
+ var toAdd = new TransformOperation();
+
+ toAdd.Type = TransformOperation.OperationType.Identity;
+
+ _operations.Add(toAdd);
+ }
+
+ public void Append(TransformOperation toAdd)
+ {
+ _operations.Add(toAdd);
+ }
+
+ public TransformOperations Build()
+ {
+ return new TransformOperations(_operations);
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs
new file mode 100644
index 0000000000..2a3912832b
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs
@@ -0,0 +1,463 @@
+using System;
+using System.Globalization;
+using Avalonia.Utilities;
+
+namespace Avalonia.Media.Transformation
+{
+ public static class TransformParser
+ {
+ private static readonly (string, TransformFunction)[] s_functionMapping =
+ {
+ ("translate", TransformFunction.Translate),
+ ("translateX", TransformFunction.TranslateX),
+ ("translateY", TransformFunction.TranslateY),
+ ("scale", TransformFunction.Scale),
+ ("scaleX", TransformFunction.ScaleX),
+ ("scaleY", TransformFunction.ScaleY),
+ ("skew", TransformFunction.Skew),
+ ("skewX", TransformFunction.SkewX),
+ ("skewY", TransformFunction.SkewY),
+ ("rotate", TransformFunction.Rotate),
+ ("matrix", TransformFunction.Matrix)
+ };
+
+ private static readonly (string, Unit)[] s_unitMapping =
+ {
+ ("deg", Unit.Degree),
+ ("grad", Unit.Gradian),
+ ("rad", Unit.Radian),
+ ("turn", Unit.Turn),
+ ("px", Unit.Pixel)
+ };
+
+ public static TransformOperations Parse(string s)
+ {
+ void ThrowInvalidFormat()
+ {
+ throw new FormatException($"Invalid transform string: '{s}'.");
+ }
+
+ if (string.IsNullOrEmpty(s))
+ {
+ throw new ArgumentException(nameof(s));
+ }
+
+ var span = s.AsSpan().Trim();
+
+ if (span.Equals("none".AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ return TransformOperations.Identity;
+ }
+
+ var builder = TransformOperations.CreateBuilder(0);
+
+ while (true)
+ {
+ var beginIndex = span.IndexOf('(');
+ var endIndex = span.IndexOf(')');
+
+ if (beginIndex == -1 || endIndex == -1)
+ {
+ ThrowInvalidFormat();
+ }
+
+ var namePart = span.Slice(0, beginIndex).Trim();
+
+ var function = ParseTransformFunction(in namePart);
+
+ if (function == TransformFunction.Invalid)
+ {
+ ThrowInvalidFormat();
+ }
+
+ var valuePart = span.Slice(beginIndex + 1, endIndex - beginIndex - 1).Trim();
+
+ ParseFunction(in valuePart, function, in builder);
+
+ span = span.Slice(endIndex + 1);
+
+ if (span.IsWhiteSpace())
+ {
+ break;
+ }
+ }
+
+ return builder.Build();
+ }
+
+ private static void ParseFunction(
+ in ReadOnlySpan functionPart,
+ TransformFunction function,
+ in TransformOperations.Builder builder)
+ {
+ static UnitValue ParseValue(ReadOnlySpan part)
+ {
+ int unitIndex = -1;
+
+ for (int i = 0; i < part.Length; i++)
+ {
+ char c = part[i];
+
+ if (char.IsDigit(c) || c == '-' || c == '.')
+ {
+ continue;
+ }
+
+ unitIndex = i;
+ break;
+ }
+
+ Unit unit = Unit.None;
+
+ if (unitIndex != -1)
+ {
+ var unitPart = part.Slice(unitIndex, part.Length - unitIndex);
+
+ unit = ParseUnit(unitPart);
+
+ part = part.Slice(0, unitIndex);
+ }
+
+ var value = double.Parse(part.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture);
+
+ return new UnitValue(unit, value);
+ }
+
+ static int ParseValuePair(
+ in ReadOnlySpan part,
+ ref UnitValue leftValue,
+ ref UnitValue rightValue)
+ {
+ var commaIndex = part.IndexOf(',');
+
+ if (commaIndex != -1)
+ {
+ var leftPart = part.Slice(0, commaIndex).Trim();
+ var rightPart = part.Slice(commaIndex + 1, part.Length - commaIndex - 1).Trim();
+
+ leftValue = ParseValue(leftPart);
+ rightValue = ParseValue(rightPart);
+
+ return 2;
+ }
+
+ leftValue = ParseValue(part);
+
+ return 1;
+ }
+
+ static int ParseCommaDelimitedValues(ReadOnlySpan part, in Span outValues)
+ {
+ int valueIndex = 0;
+
+ while (true)
+ {
+ if (valueIndex >= outValues.Length)
+ {
+ throw new FormatException("Too many provided values.");
+ }
+
+ var commaIndex = part.IndexOf(',');
+
+ if (commaIndex == -1)
+ {
+ if (!part.IsWhiteSpace())
+ {
+ outValues[valueIndex++] = ParseValue(part);
+ }
+
+ break;
+ }
+
+ var valuePart = part.Slice(0, commaIndex).Trim();
+
+ outValues[valueIndex++] = ParseValue(valuePart);
+
+ part = part.Slice(commaIndex + 1, part.Length - commaIndex - 1);
+ }
+
+ return valueIndex;
+ }
+
+ switch (function)
+ {
+ case TransformFunction.Scale:
+ case TransformFunction.ScaleX:
+ case TransformFunction.ScaleY:
+ {
+ var scaleX = UnitValue.One;
+ var scaleY = UnitValue.One;
+
+ int count = ParseValuePair(functionPart, ref scaleX, ref scaleY);
+
+ if (count != 1 && (function == TransformFunction.ScaleX || function == TransformFunction.ScaleY))
+ {
+ ThrowFormatInvalidValueCount(function, 1);
+ }
+
+ VerifyZeroOrUnit(function, in scaleX, Unit.None);
+ VerifyZeroOrUnit(function, in scaleY, Unit.None);
+
+ if (function == TransformFunction.ScaleX)
+ {
+ scaleY = UnitValue.Zero;
+ }
+ else if (function == TransformFunction.ScaleY)
+ {
+ scaleY = scaleX;
+ scaleX = UnitValue.Zero;
+ }
+ else if (count == 1)
+ {
+ scaleY = scaleX;
+ }
+
+ builder.AppendScale(scaleX.Value, scaleY.Value);
+
+ break;
+ }
+ case TransformFunction.Skew:
+ case TransformFunction.SkewX:
+ case TransformFunction.SkewY:
+ {
+ var skewX = UnitValue.Zero;
+ var skewY = UnitValue.Zero;
+
+ int count = ParseValuePair(functionPart, ref skewX, ref skewY);
+
+ if (count != 1 && (function == TransformFunction.SkewX || function == TransformFunction.SkewY))
+ {
+ ThrowFormatInvalidValueCount(function, 1);
+ }
+
+ VerifyZeroOrAngle(function, in skewX);
+ VerifyZeroOrAngle(function, in skewY);
+
+ if (function == TransformFunction.SkewX)
+ {
+ skewY = UnitValue.Zero;
+ }
+ else if (function == TransformFunction.SkewY)
+ {
+ skewY = skewX;
+ skewX = UnitValue.Zero;
+ }
+ else if (count == 1)
+ {
+ skewY = skewX;
+ }
+
+ builder.AppendSkew(ToRadians(in skewX), ToRadians(in skewY));
+
+ break;
+ }
+ case TransformFunction.Rotate:
+ {
+ var angle = UnitValue.Zero;
+ UnitValue _ = default;
+
+ int count = ParseValuePair(functionPart, ref angle, ref _);
+
+ if (count != 1)
+ {
+ ThrowFormatInvalidValueCount(function, 1);
+ }
+
+ VerifyZeroOrAngle(function, in angle);
+
+ builder.AppendRotate(ToRadians(in angle));
+
+ break;
+ }
+ case TransformFunction.Translate:
+ case TransformFunction.TranslateX:
+ case TransformFunction.TranslateY:
+ {
+ var translateX = UnitValue.Zero;
+ var translateY = UnitValue.Zero;
+
+ int count = ParseValuePair(functionPart, ref translateX, ref translateY);
+
+ if (count != 1 && (function == TransformFunction.TranslateX || function == TransformFunction.TranslateY))
+ {
+ ThrowFormatInvalidValueCount(function, 1);
+ }
+
+ VerifyZeroOrUnit(function, in translateX, Unit.Pixel);
+ VerifyZeroOrUnit(function, in translateY, Unit.Pixel);
+
+ if (function == TransformFunction.TranslateX)
+ {
+ translateY = UnitValue.Zero;
+ }
+ else if (function == TransformFunction.TranslateY)
+ {
+ translateY = translateX;
+ translateX = UnitValue.Zero;
+ }
+ else if (count == 1)
+ {
+ translateY = translateX;
+ }
+
+ builder.AppendTranslate(translateX.Value, translateY.Value);
+
+ break;
+ }
+ case TransformFunction.Matrix:
+ {
+ Span values = stackalloc UnitValue[6];
+
+ int count = ParseCommaDelimitedValues(functionPart, in values);
+
+ if (count != 6)
+ {
+ ThrowFormatInvalidValueCount(function, 6);
+ }
+
+ foreach (UnitValue value in values)
+ {
+ VerifyZeroOrUnit(function, value, Unit.None);
+ }
+
+ var matrix = new Matrix(
+ values[0].Value,
+ values[1].Value,
+ values[2].Value,
+ values[3].Value,
+ values[4].Value,
+ values[5].Value);
+
+ builder.AppendMatrix(matrix);
+
+ break;
+ }
+ }
+ }
+
+ private static void VerifyZeroOrUnit(TransformFunction function, in UnitValue value, Unit unit)
+ {
+ bool isZero = value.Unit == Unit.None && value.Value == 0d;
+
+ if (!isZero && value.Unit != unit)
+ {
+ ThrowFormatInvalidValue(function, in value);
+ }
+ }
+
+ private static void VerifyZeroOrAngle(TransformFunction function, in UnitValue value)
+ {
+ if (value.Value != 0d && !IsAngleUnit(value.Unit))
+ {
+ ThrowFormatInvalidValue(function, in value);
+ }
+ }
+
+ private static bool IsAngleUnit(Unit unit)
+ {
+ switch (unit)
+ {
+ case Unit.Radian:
+ case Unit.Degree:
+ case Unit.Turn:
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void ThrowFormatInvalidValue(TransformFunction function, in UnitValue value)
+ {
+ var unitString = value.Unit == Unit.None ? string.Empty : value.Unit.ToString();
+
+ throw new FormatException($"Invalid value {value.Value} {unitString} for {function}");
+ }
+
+ private static void ThrowFormatInvalidValueCount(TransformFunction function, int count)
+ {
+ throw new FormatException($"Invalid format. {function} expects {count} value(s).");
+ }
+
+ private static Unit ParseUnit(in ReadOnlySpan part)
+ {
+ foreach (var (name, unit) in s_unitMapping)
+ {
+ if (part.Equals(name.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ return unit;
+ }
+ }
+
+ throw new FormatException($"Invalid unit: {part.ToString()}");
+ }
+
+ private static TransformFunction ParseTransformFunction(in ReadOnlySpan part)
+ {
+ foreach (var (name, transformFunction) in s_functionMapping)
+ {
+ if (part.Equals(name.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ return transformFunction;
+ }
+ }
+
+ return TransformFunction.Invalid;
+ }
+
+ private static double ToRadians(in UnitValue value)
+ {
+ return value.Unit switch
+ {
+ Unit.Radian => value.Value,
+ Unit.Gradian => MathUtilities.Grad2Rad(value.Value),
+ Unit.Degree => MathUtilities.Deg2Rad(value.Value),
+ Unit.Turn => MathUtilities.Turn2Rad(value.Value),
+ _ => value.Value
+ };
+ }
+
+ private enum Unit
+ {
+ None,
+ Pixel,
+ Radian,
+ Gradian,
+ Degree,
+ Turn
+ }
+
+ private readonly struct UnitValue
+ {
+ public readonly Unit Unit;
+ public readonly double Value;
+
+ public UnitValue(Unit unit, double value)
+ {
+ Unit = unit;
+ Value = value;
+ }
+
+ public static UnitValue Zero => new UnitValue(Unit.None, 0);
+
+ public static UnitValue One => new UnitValue(Unit.None, 1);
+ }
+
+ private enum TransformFunction
+ {
+ Invalid,
+ Translate,
+ TranslateX,
+ TranslateY,
+ Scale,
+ ScaleX,
+ ScaleY,
+ Skew,
+ SkewX,
+ SkewY,
+ Rotate,
+ Matrix
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs
index 6cd6442095..5d802c27b9 100644
--- a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs
+++ b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs
@@ -6,6 +6,7 @@ using Avalonia.Metadata;
[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Animation")]
[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media")]
[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media.Imaging")]
+[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media.Transformation")]
[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")]
[assembly: InternalsVisibleTo("Avalonia.Direct2D1.RenderTests")]
diff --git a/src/Avalonia.Visuals/Visual.cs b/src/Avalonia.Visuals/Visual.cs
index bb9a4cf208..cd6e5bb075 100644
--- a/src/Avalonia.Visuals/Visual.cs
+++ b/src/Avalonia.Visuals/Visual.cs
@@ -68,8 +68,8 @@ namespace Avalonia
///
/// Defines the property.
///
- public static readonly StyledProperty RenderTransformProperty =
- AvaloniaProperty.Register(nameof(RenderTransform));
+ public static readonly StyledProperty RenderTransformProperty =
+ AvaloniaProperty.Register(nameof(RenderTransform));
///
/// Defines the property.
@@ -219,7 +219,7 @@ namespace Avalonia
///
/// Gets the render transform of the control.
///
- public Transform RenderTransform
+ public ITransform RenderTransform
{
get { return GetValue(RenderTransformProperty); }
set { SetValue(RenderTransformProperty, value); }
@@ -391,9 +391,9 @@ namespace Avalonia
_visualRoot = e.Root;
- if (RenderTransform != null)
+ if (RenderTransform is IMutableTransform mutableTransform)
{
- RenderTransform.Changed += RenderTransformChanged;
+ mutableTransform.Changed += RenderTransformChanged;
}
EnableTransitions();
@@ -428,9 +428,9 @@ namespace Avalonia
_visualRoot = null;
- if (RenderTransform != null)
+ if (RenderTransform is IMutableTransform mutableTransform)
{
- RenderTransform.Changed -= RenderTransformChanged;
+ mutableTransform.Changed -= RenderTransformChanged;
}
DisableTransitions();
diff --git a/src/Avalonia.Visuals/VisualTree/IVisual.cs b/src/Avalonia.Visuals/VisualTree/IVisual.cs
index 6f905cc269..50787655d9 100644
--- a/src/Avalonia.Visuals/VisualTree/IVisual.cs
+++ b/src/Avalonia.Visuals/VisualTree/IVisual.cs
@@ -76,7 +76,7 @@ namespace Avalonia.VisualTree
///
/// Gets or sets the render transform of the control.
///
- Transform RenderTransform { get; set; }
+ ITransform RenderTransform { get; set; }
///
/// Gets or sets the render transform origin of the control.
diff --git a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs
index ff1d17164e..44e2e8663b 100644
--- a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs
+++ b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Visuals.UnitTests.Media
@@ -12,5 +13,46 @@ namespace Avalonia.Visuals.UnitTests.Media
var expected = new Matrix(1, 2, 3, -4, 5, 6);
Assert.Equal(expected, matrix);
}
+
+ [Fact]
+ public void Can_Decompose_Translation()
+ {
+ var matrix = Matrix.CreateTranslation(5, 10);
+
+ var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed);
+
+ Assert.Equal(true, result);
+ Assert.Equal(5, decomposed.Translate.X);
+ Assert.Equal(10, decomposed.Translate.Y);
+ }
+
+ [Fact]
+ public void Can_Decompose_Angle()
+ {
+ var angleRad = MathUtilities.Deg2Rad(30);
+
+ var matrix = Matrix.CreateRotation(angleRad);
+
+ var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed);
+
+ Assert.Equal(true, result);
+ Assert.Equal(angleRad, decomposed.Angle);
+ }
+
+ [Theory]
+ [InlineData(1d, 1d)]
+ [InlineData(-1d, 1d)]
+ [InlineData(1d, -1d)]
+ [InlineData(5d, 10d)]
+ public void Can_Decompose_Scale(double x, double y)
+ {
+ var matrix = Matrix.CreateScale(x, y);
+
+ var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed);
+
+ Assert.Equal(true, result);
+ Assert.Equal(x, decomposed.Scale.X);
+ Assert.Equal(y, decomposed.Scale.Y);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs
new file mode 100644
index 0000000000..8e0520a71d
--- /dev/null
+++ b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs
@@ -0,0 +1,128 @@
+using Avalonia.Media.Transformation;
+using Avalonia.Utilities;
+using Xunit;
+
+namespace Avalonia.Visuals.UnitTests.Media
+{
+ public class TransformOperationsTests
+ {
+ [Fact]
+ public void Can_Parse_Compound_Operations()
+ {
+ var data = "scale(1,2) translate(3px,4px) rotate(5deg) skew(6deg,7deg)";
+
+ var transform = TransformOperations.Parse(data);
+
+ var operations = transform.Operations;
+
+ Assert.Equal(TransformOperation.OperationType.Scale, operations[0].Type);
+ Assert.Equal(1, operations[0].Data.Scale.X);
+ Assert.Equal(2, operations[0].Data.Scale.Y);
+
+ Assert.Equal(TransformOperation.OperationType.Translate, operations[1].Type);
+ Assert.Equal(3, operations[1].Data.Translate.X);
+ Assert.Equal(4, operations[1].Data.Translate.Y);
+
+ Assert.Equal(TransformOperation.OperationType.Rotate, operations[2].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(5), operations[2].Data.Rotate.Angle);
+
+ Assert.Equal(TransformOperation.OperationType.Skew, operations[3].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(6), operations[3].Data.Skew.X);
+ Assert.Equal(MathUtilities.Deg2Rad(7), operations[3].Data.Skew.Y);
+ }
+
+ [Fact]
+ public void Can_Parse_Matrix_Operation()
+ {
+ var data = "matrix(1,2,3,4,5,6)";
+
+ var transform = TransformOperations.Parse(data);
+ }
+
+ [Theory]
+ [InlineData(0d, 10d, 0d)]
+ [InlineData(0.5d, 5d, 10d)]
+ [InlineData(1d, 0d, 20d)]
+ public void Can_Interpolate_Translation(double progress, double x, double y)
+ {
+ var from = TransformOperations.Parse("translateX(10px)");
+ var to = TransformOperations.Parse("translateY(20px)");
+
+ var interpolated = TransformOperations.Interpolate(from, to, progress);
+
+ var operations = interpolated.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Translate, operations[0].Type);
+ Assert.Equal(x, operations[0].Data.Translate.X);
+ Assert.Equal(y, operations[0].Data.Translate.Y);
+ }
+
+ [Theory]
+ [InlineData(0d, 10d, 0d)]
+ [InlineData(0.5d, 5d, 10d)]
+ [InlineData(1d, 0d, 20d)]
+ public void Can_Interpolate_Scale(double progress, double x, double y)
+ {
+ var from = TransformOperations.Parse("scaleX(10)");
+ var to = TransformOperations.Parse("scaleY(20)");
+
+ var interpolated = TransformOperations.Interpolate(from, to, progress);
+
+ var operations = interpolated.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Scale, operations[0].Type);
+ Assert.Equal(x, operations[0].Data.Scale.X);
+ Assert.Equal(y, operations[0].Data.Scale.Y);
+ }
+
+ [Theory]
+ [InlineData(0d, 10d, 0d)]
+ [InlineData(0.5d, 5d, 10d)]
+ [InlineData(1d, 0d, 20d)]
+ public void Can_Interpolate_Skew(double progress, double x, double y)
+ {
+ var from = TransformOperations.Parse("skewX(10deg)");
+ var to = TransformOperations.Parse("skewY(20deg)");
+
+ var interpolated = TransformOperations.Interpolate(from, to, progress);
+
+ var operations = interpolated.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Skew, operations[0].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(x), operations[0].Data.Skew.X);
+ Assert.Equal(MathUtilities.Deg2Rad(y), operations[0].Data.Skew.Y);
+ }
+
+ [Theory]
+ [InlineData(0d, 10d)]
+ [InlineData(0.5d, 15d)]
+ [InlineData(1d,20d)]
+ public void Can_Interpolate_Rotation(double progress, double angle)
+ {
+ var from = TransformOperations.Parse("rotate(10deg)");
+ var to = TransformOperations.Parse("rotate(20deg)");
+
+ var interpolated = TransformOperations.Interpolate(from, to, progress);
+
+ var operations = interpolated.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Rotate, operations[0].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(angle), operations[0].Data.Rotate.Angle);
+ }
+
+ [Fact]
+ public void Can_Interpolate_Matrix()
+ {
+ double progress = 0.5d;
+
+ var from = TransformOperations.Parse("rotate(45deg)");
+ var to = TransformOperations.Parse("translate(100px, 100px) rotate(1215deg)");
+
+ var interpolated = TransformOperations.Interpolate(from, to, progress);
+ }
+ }
+}