diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml
index c098ef411e..14ccc82043 100644
--- a/samples/RenderDemo/MainWindow.xaml
+++ b/samples/RenderDemo/MainWindow.xaml
@@ -29,6 +29,9 @@
+
+
+
diff --git a/samples/RenderDemo/Pages/TransitionsPage.xaml b/samples/RenderDemo/Pages/TransitionsPage.xaml
new file mode 100644
index 0000000000..df7130a925
--- /dev/null
+++ b/samples/RenderDemo/Pages/TransitionsPage.xaml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hover to activate Transform Keyframe Animations.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/RenderDemo/Pages/TransitionsPage.xaml.cs b/samples/RenderDemo/Pages/TransitionsPage.xaml.cs
new file mode 100644
index 0000000000..5f446c9e99
--- /dev/null
+++ b/samples/RenderDemo/Pages/TransitionsPage.xaml.cs
@@ -0,0 +1,37 @@
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using RenderDemo.ViewModels;
+
+namespace RenderDemo.Pages
+{
+ public class TransitionsPage : UserControl
+ {
+ public TransitionsPage()
+ {
+ InitializeComponent();
+ this.DataContext = new AnimationsPageViewModel();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private void ToggleClock(object sender, RoutedEventArgs args)
+ {
+ var button = sender as Button;
+ var clock = button.Clock;
+
+ if (clock.PlayState == PlayState.Run)
+ {
+ clock.PlayState = PlayState.Pause;
+ }
+ else if (clock.PlayState == PlayState.Pause)
+ {
+ clock.PlayState = PlayState.Run;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs
index f16af16ed5..06a1cd4ae5 100644
--- a/src/Avalonia.Base/Utilities/MathUtilities.cs
+++ b/src/Avalonia.Base/Utilities/MathUtilities.cs
@@ -267,6 +267,36 @@ namespace Avalonia.Utilities
}
}
+ ///
+ /// 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;
+ }
+
private static void ThrowCannotBeGreaterThanException(double min, double max)
{
throw new ArgumentException($"{min} cannot be greater than {max}.");
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..8e9d20eb8f
--- /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;
+ }
+
+ public override TransformOperations Interpolate(double progress, TransformOperations oldValue, TransformOperations newValue)
+ {
+ var oldTransform = EnsureOperations(oldValue);
+ var newTransform = EnsureOperations(newValue);
+
+ return TransformOperations.Interpolate(oldTransform, newTransform, progress);
+ }
+
+ internal static TransformOperations EnsureOperations(ITransform value)
+ {
+ return value as TransformOperations ?? TransformOperations.Identity;
+ }
+
+ private void ValidateTransform(AnimatorKeyFrame kf)
+ {
+ if (!(kf.Value is TransformOperations))
+ {
+ throw new InvalidOperationException($"All keyframes must be of type {typeof(TransformOperations)}.");
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs
new file mode 100644
index 0000000000..104acb71ad
--- /dev/null
+++ b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs
@@ -0,0 +1,28 @@
+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)
+ {
+ var oldTransform = TransformOperationsAnimator.EnsureOperations(oldValue);
+ var newTransform = TransformOperationsAnimator.EnsureOperations(newValue);
+
+ return progress
+ .Select(p =>
+ {
+ var f = Easing.Ease(p);
+
+ return _operationsAnimator.Interpolate(f, oldTransform, newTransform);
+ });
+ }
+ }
+}
diff --git a/src/Avalonia.Visuals/Matrix.cs b/src/Avalonia.Visuals/Matrix.cs
index 898c6027a5..206b842220 100644
--- a/src/Avalonia.Visuals/Matrix.cs
+++ b/src/Avalonia.Visuals/Matrix.cs
@@ -54,7 +54,7 @@ namespace Avalonia
///
/// HasInverse Property - returns true if this matrix is invertible, false otherwise.
///
- public bool HasInverse => GetDeterminant() != 0;
+ public bool HasInverse => !MathUtilities.IsZero(GetDeterminant());
///
/// The first element of the first row
@@ -286,7 +286,7 @@ namespace Avalonia
{
double d = GetDeterminant();
- if (d == 0)
+ if (MathUtilities.IsZero(d))
{
throw new InvalidOperationException("Transform is not invertible.");
}
@@ -319,5 +319,76 @@ namespace Avalonia
);
}
}
+
+ ///
+ /// Decomposes given matrix into transform operations.
+ ///
+ /// Matrix to decompose.
+ /// Decomposed matrix.
+ /// The status of the operation.
+ public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed)
+ {
+ decomposed = default;
+
+ var determinant = matrix.GetDeterminant();
+
+ if (MathUtilities.IsZero(determinant))
+ {
+ 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..36f5dd98f1
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs
@@ -0,0 +1,230 @@
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Media.Transformation
+{
+ ///
+ /// Represents a single primitive transform (like translation, rotation, scale, etc.).
+ ///
+ public struct TransformOperation
+ {
+ public OperationType Type;
+ public Matrix Matrix;
+ public DataLayout Data;
+
+ public enum OperationType
+ {
+ Translate,
+ Rotate,
+ Scale,
+ Skew,
+ Matrix,
+ Identity
+ }
+
+ ///
+ /// Returns whether operation produces the identity matrix.
+ ///
+ public bool IsIdentity => Matrix.IsIdentity;
+
+ ///
+ /// Bakes this operation to a transform matrix.
+ ///
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// Returns new identity transform operation.
+ ///
+ public static TransformOperation Identity =>
+ new TransformOperation { Matrix = Matrix.Identity, Type = OperationType.Identity };
+
+ ///
+ /// Attempts to interpolate between two transform operations.
+ ///
+ /// Source operation.
+ /// Target operation.
+ /// Interpolation progress.
+ /// Interpolation result that will be filled in when operation was successful.
+ ///
+ /// Based upon https://www.w3.org/TR/css-transforms-1/#interpolation-of-transform-functions.
+ ///
+ 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;
+ }
+
+ // ReSharper disable PossibleInvalidOperationException
+ TransformOperation fromValue = fromIdentity ? Identity : from.Value;
+ TransformOperation toValue = toIdentity ? Identity : to.Value;
+ // ReSharper restore PossibleInvalidOperationException
+
+ 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;
+ }
+
+ private static bool IsOperationIdentity(ref TransformOperation? operation)
+ {
+ return !operation.HasValue || operation.Value.IsIdentity;
+ }
+
+ [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..334bb93562
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Transformation
+{
+ ///
+ /// Contains a list of that represent primitive transforms that will be
+ /// applied in declared order.
+ ///
+ 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();
+ }
+
+ ///
+ /// Returns whether all operations combined together produce the identity matrix.
+ ///
+ 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..85f4f5fec1
--- /dev/null
+++ b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs
@@ -0,0 +1,444 @@
+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.ScaleY)
+ {
+ scaleY = scaleX;
+ scaleX = UnitValue.One;
+ }
+ else if (function == TransformFunction.Scale && 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.SkewY)
+ {
+ skewY = skewX;
+ skewX = UnitValue.Zero;
+ }
+
+ 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.TranslateY)
+ {
+ translateY = translateX;
+ translateX = UnitValue.Zero;
+ }
+
+ 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.Gradian:
+ 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.Benchmarks/Visuals/MatrixBenchmarks.cs b/tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs
new file mode 100644
index 0000000000..17e2237eb0
--- /dev/null
+++ b/tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs
@@ -0,0 +1,16 @@
+using BenchmarkDotNet.Attributes;
+
+namespace Avalonia.Benchmarks.Visuals
+{
+ [MemoryDiagnoser, InProcess]
+ public class MatrixBenchmarks
+ {
+ private static readonly Matrix s_data = Matrix.Identity;
+
+ [Benchmark(Baseline = true)]
+ public bool Decompose()
+ {
+ return Matrix.TryDecomposeTransform(s_data, out Matrix.Decomposed decomposed);
+ }
+ }
+}
diff --git a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs
index ff1d17164e..6ef48b6161 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 System;
+using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Visuals.UnitTests.Media
@@ -6,11 +7,93 @@ namespace Avalonia.Visuals.UnitTests.Media
public class MatrixTests
{
[Fact]
- public void Parse_Parses()
+ public void Can_Parse()
{
var matrix = Matrix.Parse("1,2,3,-4,5 6");
var expected = new Matrix(1, 2, 3, -4, 5, 6);
Assert.Equal(expected, matrix);
}
+
+ [Fact]
+ public void Singular_Has_No_Inverse()
+ {
+ var matrix = new Matrix(0, 0, 0, 0, 0, 0);
+
+ Assert.False(matrix.HasInverse);
+ }
+
+ [Fact]
+ public void Identity_Has_Inverse()
+ {
+ var matrix = Matrix.Identity;
+
+ Assert.True(matrix.HasInverse);
+ }
+
+ [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);
+ }
+
+ [Theory]
+ [InlineData(30d)]
+ [InlineData(0d)]
+ [InlineData(90d)]
+ [InlineData(270d)]
+ public void Can_Decompose_Angle(double angleDeg)
+ {
+ var angleRad = MathUtilities.Deg2Rad(angleDeg);
+
+ var matrix = Matrix.CreateRotation(angleRad);
+
+ var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed);
+
+ Assert.Equal(true, result);
+
+ var expected = NormalizeAngle(angleRad);
+ var actual = NormalizeAngle(decomposed.Angle);
+
+ Assert.Equal(expected, actual, 4);
+ }
+
+ [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);
+ }
+
+ private static double NormalizeAngle(double rad)
+ {
+ double twoPi = 2 * Math.PI;
+
+ while (rad < 0)
+ {
+ rad += twoPi;
+ }
+
+ while (rad > twoPi)
+ {
+ rad -= twoPi;
+ }
+
+ return rad;
+ }
}
-}
\ 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..856b4615a5
--- /dev/null
+++ b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs
@@ -0,0 +1,229 @@
+using Avalonia.Media.Transformation;
+using Avalonia.Utilities;
+using Xunit;
+
+namespace Avalonia.Visuals.UnitTests.Media
+{
+ public class TransformOperationsTests
+ {
+ [Theory]
+ [InlineData("translate(10px)", 10d, 0d)]
+ [InlineData("translate(10px, 10px)", 10d, 10d)]
+ [InlineData("translate(0px, 10px)", 0d, 10d)]
+ [InlineData("translate(10px, 0px)", 10d, 0d)]
+ [InlineData("translateX(10px)", 10d, 0d)]
+ [InlineData("translateY(10px)", 0d, 10d)]
+ public void Can_Parse_Translation(string data, double x, double y)
+ {
+ var transform = TransformOperations.Parse(data);
+
+ var operations = transform.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("rotate(90deg)", 90d)]
+ [InlineData("rotate(0.5turn)", 180d)]
+ [InlineData("rotate(200grad)", 180d)]
+ [InlineData("rotate(3.14159265rad)", 180d)]
+ public void Can_Parse_Rotation(string data, double angleDeg)
+ {
+ var transform = TransformOperations.Parse(data);
+
+ var operations = transform.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Rotate, operations[0].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(angleDeg), operations[0].Data.Rotate.Angle, 4);
+ }
+
+ [Theory]
+ [InlineData("scale(10)", 10d, 10d)]
+ [InlineData("scale(10, 10)", 10d, 10d)]
+ [InlineData("scale(0, 10)", 0d, 10d)]
+ [InlineData("scale(10, 0)", 10d, 0d)]
+ [InlineData("scaleX(10)", 10d, 1d)]
+ [InlineData("scaleY(10)", 1d, 10d)]
+ public void Can_Parse_Scale(string data, double x, double y)
+ {
+ var transform = TransformOperations.Parse(data);
+
+ var operations = transform.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("skew(90deg)", 90d, 0d)]
+ [InlineData("skew(0.5turn)", 180d, 0d)]
+ [InlineData("skew(200grad)", 180d, 0d)]
+ [InlineData("skew(3.14159265rad)", 180d, 0d)]
+ [InlineData("skewX(90deg)", 90d, 0d)]
+ [InlineData("skewX(0.5turn)", 180d, 0d)]
+ [InlineData("skewX(200grad)", 180d, 0d)]
+ [InlineData("skewX(3.14159265rad)", 180d, 0d)]
+ [InlineData("skew(0, 90deg)", 0d, 90d)]
+ [InlineData("skew(0, 0.5turn)", 0d, 180d)]
+ [InlineData("skew(0, 200grad)", 0d, 180d)]
+ [InlineData("skew(0, 3.14159265rad)", 0d, 180d)]
+ [InlineData("skewY(90deg)", 0d, 90d)]
+ [InlineData("skewY(0.5turn)", 0d, 180d)]
+ [InlineData("skewY(200grad)", 0d, 180d)]
+ [InlineData("skewY(3.14159265rad)", 0d, 180d)]
+ [InlineData("skew(90deg, 90deg)", 90d, 90d)]
+ [InlineData("skew(0.5turn, 0.5turn)", 180d, 180d)]
+ [InlineData("skew(200grad, 200grad)", 180d, 180d)]
+ [InlineData("skew(3.14159265rad, 3.14159265rad)", 180d, 180d)]
+ public void Can_Parse_Skew(string data, double x, double y)
+ {
+ var transform = TransformOperations.Parse(data);
+
+ var operations = transform.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Skew, operations[0].Type);
+ Assert.Equal(MathUtilities.Deg2Rad(x), operations[0].Data.Skew.X, 4);
+ Assert.Equal(MathUtilities.Deg2Rad(y), operations[0].Data.Skew.Y, 4);
+ }
+
+ [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);
+
+ var operations = transform.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Matrix, operations[0].Type);
+
+ var expectedMatrix = new Matrix(1, 2, 3, 4, 5, 6);
+
+ Assert.Equal(expectedMatrix, operations[0].Matrix);
+ }
+
+ [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, 1d)]
+ [InlineData(0.5d, 5.5d, 10.5d)]
+ [InlineData(1d, 1d, 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 Interpolation_Fallback_To_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);
+
+ var operations = interpolated.Operations;
+
+ Assert.Single(operations);
+ Assert.Equal(TransformOperation.OperationType.Matrix, operations[0].Type);
+ }
+ }
+}