diff --git a/Directory.packages.props b/Directory.packages.props
index 8c3acea706..f0b00bc73c 100644
--- a/Directory.packages.props
+++ b/Directory.packages.props
@@ -32,5 +32,6 @@
+
diff --git a/MaterialDesignToolkit.Full.sln b/MaterialDesignToolkit.Full.sln
index 8810cebe10..af6bccf7f0 100644
--- a/MaterialDesignToolkit.Full.sln
+++ b/MaterialDesignToolkit.Full.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31612.314
MinimumVisualStudioVersion = 10.0.40219.1
@@ -67,6 +67,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialColorUtilities.Tests", "tests\MaterialColorUtilities.Tests\MaterialColorUtilities.Tests.csproj", "{91485BEA-759F-406E-87B7-68D94CF66DE4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaterialDesignThemes.Motion", "src\MaterialDesign3.Motion\Motion.csproj", "{3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -287,6 +289,22 @@ Global
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x64.Build.0 = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.ActiveCfg = Release|Any CPU
{91485BEA-759F-406E-87B7-68D94CF66DE4}.Release|x86.Build.0 = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|ARM.Build.0 = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|x64.Build.0 = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Debug|x86.Build.0 = Debug|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|ARM.ActiveCfg = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|ARM.Build.0 = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|x64.ActiveCfg = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|x64.Build.0 = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|x86.ActiveCfg = Release|Any CPU
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -300,6 +318,7 @@ Global
{B39795A7-D66A-4F2F-9F41-050838D14048} = {D34BE232-DE51-43C1-ABDC-B69003BB50FF}
{2C29B80E-1689-43CE-85AC-71799666B4AC} = {9E303A4A-3712-44B9-91EE-830FDC087795}
{91485BEA-759F-406E-87B7-68D94CF66DE4} = {9E303A4A-3712-44B9-91EE-830FDC087795}
+ {3F7E3CFD-BAE6-4DC4-9C8D-A001138BD87F} = {9E303A4A-3712-44B9-91EE-830FDC087795}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {730B2F9E-74AE-46CE-9E61-89AA5C6D5DD3}
diff --git a/src/MaterialDesign3.Motion/AnimationEndReason.cs b/src/MaterialDesign3.Motion/AnimationEndReason.cs
new file mode 100644
index 0000000000..f7be316e3b
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationEndReason.cs
@@ -0,0 +1,21 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Possible reasons for s to end.
+///
+public enum AnimationEndReason
+{
+ ///
+ /// Animation will be forced to end when its value reaches upper/lower bound (if they have been
+ /// defined, e.g. via Animatable.updateBounds).
+ /// Unlike , when an animation ends due to , it often falls
+ /// short from its initial target, and the remaining velocity is often non-zero. Both the end value and the
+ /// remaining velocity can be obtained via AnimationResult.
+ ///
+ BoundReached,
+
+ ///
+ /// Animation has finished successfully without any interruption.
+ ///
+ Finished,
+}
diff --git a/src/MaterialDesign3.Motion/AnimationParameters.cs b/src/MaterialDesign3.Motion/AnimationParameters.cs
new file mode 100644
index 0000000000..92e7cafcb6
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationParameters.cs
@@ -0,0 +1,33 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Animation specs of duration, easing and repeat delay.
+///
+public sealed class AnimationParameters
+{
+ ///
+ /// The duration of the animation.
+ ///
+ ///
+ /// If not set, defaults to 300ms.
+ ///
+ public TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(300);
+
+ ///
+ /// The easing to be used for adjusting an animation's fraction.
+ ///
+ ///
+ /// If not set, defaults to Linear Interpolation.
+ ///
+ public Easing? Easing { get; set; }
+
+ ///
+ /// Animation delay in millis.
+ ///
+ ///
+ /// When used outside repeatable, this is the delay to start the animation.
+ /// When set inside repeatable, this is the delay before repeating animation.
+ /// If not set, no delay will be applied.
+ ///
+ public TimeSpan? Delay { get; set; } = TimeSpan.FromMilliseconds(3);
+}
diff --git a/src/MaterialDesign3.Motion/AnimationSpec.cs b/src/MaterialDesign3.Motion/AnimationSpec.cs
new file mode 100644
index 0000000000..22d7c22495
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationSpec.cs
@@ -0,0 +1,20 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Animation parameters that can be added to any animatable node.
+///
+public sealed class AnimationSpec
+{
+ ///
+ /// Animation parameters including duration, easing and repeat delay.
+ ///
+ public AnimationParameters AnimationParameters { get; set; } = new();
+
+ ///
+ /// The repeatable mode to be used for specifying repetition parameters for the animation.
+ ///
+ ///
+ /// If not set, animation won't be repeated.
+ ///
+ public Repeatable? Repeatable { get; set; }
+}
diff --git a/src/MaterialDesign3.Motion/AnimationVector.cs b/src/MaterialDesign3.Motion/AnimationVector.cs
new file mode 100644
index 0000000000..05e494577c
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVector.cs
@@ -0,0 +1,26 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// class that is the base class of ,
+/// , and .
+/// In order to animate any arbitrary type, it is required to provide a
+/// that defines how to convert that arbitrary type T to an , and vice versa.
+///
+public abstract class AnimationVector
+{
+ internal abstract void Reset();
+
+ internal abstract AnimationVector NewVector();
+
+ internal abstract float GetValue(int index);
+
+ internal abstract void SetValue(int index, float value);
+
+ internal abstract int Size { get; }
+
+ public float this[int index]
+ {
+ get => GetValue(index);
+ set => SetValue(index, value);
+ }
+}
diff --git a/src/MaterialDesign3.Motion/AnimationVector1D.cs b/src/MaterialDesign3.Motion/AnimationVector1D.cs
new file mode 100644
index 0000000000..f9cec489e7
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVector1D.cs
@@ -0,0 +1,42 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// This class defines a 1D vector. It contains only one Float value that is initialized in the constructor.
+///
+public sealed class AnimationVector1D : AnimationVector
+{
+ public AnimationVector1D()
+ : this(0f)
+ {
+ }
+
+ public AnimationVector1D(float value)
+ {
+ Value = value;
+ }
+
+ public float Value { get; internal set; }
+
+ internal override void Reset() => Value = 0f;
+
+ internal override AnimationVector NewVector() => new AnimationVector1D(0f);
+
+ internal override float GetValue(int index) => index == 0 ? Value : 0f;
+
+ internal override void SetValue(int index, float value)
+ {
+ if (index == 0)
+ {
+ Value = value;
+ }
+ }
+
+ internal override int Size => 1;
+
+ public override string ToString() => $"AnimationVector1D(Value = {Value})";
+
+ public override bool Equals(object? obj) =>
+ obj is AnimationVector1D other && other.Value.Equals(Value);
+
+ public override int GetHashCode() => Value.GetHashCode();
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/AnimationVector2D.cs b/src/MaterialDesign3.Motion/AnimationVector2D.cs
new file mode 100644
index 0000000000..fa89034633
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVector2D.cs
@@ -0,0 +1,59 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// This class defines a 2D vector that contains two Float value fields.
+///
+public sealed class AnimationVector2D : AnimationVector
+{
+ public AnimationVector2D()
+ : this(0f, 0f)
+ {
+ }
+
+ public AnimationVector2D(float v1, float v2)
+ {
+ V1 = v1;
+ V2 = v2;
+ }
+
+ public float V1 { get; internal set; }
+
+ public float V2 { get; internal set; }
+
+ internal override void Reset()
+ {
+ V1 = 0f;
+ V2 = 0f;
+ }
+
+ internal override AnimationVector NewVector() => new AnimationVector2D(0f, 0f);
+
+ internal override float GetValue(int index) => index switch
+ {
+ 0 => V1,
+ 1 => V2,
+ _ => 0f,
+ };
+
+ internal override void SetValue(int index, float value)
+ {
+ switch (index)
+ {
+ case 0:
+ V1 = value;
+ break;
+ case 1:
+ V2 = value;
+ break;
+ }
+ }
+
+ internal override int Size => 2;
+
+ public override string ToString() => $"AnimationVector2D(V1 = {V1}, V2 = {V2})";
+
+ public override bool Equals(object? obj) =>
+ obj is AnimationVector2D other && other.V1.Equals(V1) && other.V2.Equals(V2);
+
+ public override int GetHashCode() => HashCode.Combine(V1, V2);
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/AnimationVector3D.cs b/src/MaterialDesign3.Motion/AnimationVector3D.cs
new file mode 100644
index 0000000000..74bda432e3
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVector3D.cs
@@ -0,0 +1,70 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// This class defines a 3D vector that contains three Float value fields for the three dimensions.
+///
+public sealed class AnimationVector3D : AnimationVector
+{
+ public AnimationVector3D()
+ : this(0f, 0f, 0f)
+ {
+ }
+
+ public AnimationVector3D(float v1, float v2, float v3)
+ {
+ V1 = v1;
+ V2 = v2;
+ V3 = v3;
+ }
+
+ public float V1 { get; internal set; }
+
+ public float V2 { get; internal set; }
+
+ public float V3 { get; internal set; }
+
+ internal override void Reset()
+ {
+ V1 = 0f;
+ V2 = 0f;
+ V3 = 0f;
+ }
+
+ internal override AnimationVector NewVector() => new AnimationVector3D(0f, 0f, 0f);
+
+ internal override float GetValue(int index) => index switch
+ {
+ 0 => V1,
+ 1 => V2,
+ 2 => V3,
+ _ => 0f,
+ };
+
+ internal override void SetValue(int index, float value)
+ {
+ switch (index)
+ {
+ case 0:
+ V1 = value;
+ break;
+ case 1:
+ V2 = value;
+ break;
+ case 2:
+ V3 = value;
+ break;
+ }
+ }
+
+ internal override int Size => 3;
+
+ public override string ToString() => $"AnimationVector3D(V1 = {V1}, V2 = {V2}, V3 = {V3})";
+
+ public override bool Equals(object? obj) =>
+ obj is AnimationVector3D other &&
+ other.V1.Equals(V1) &&
+ other.V2.Equals(V2) &&
+ other.V3.Equals(V3);
+
+ public override int GetHashCode() => HashCode.Combine(V1, V2, V3);
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/AnimationVector4D.cs b/src/MaterialDesign3.Motion/AnimationVector4D.cs
new file mode 100644
index 0000000000..5ce949ae34
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVector4D.cs
@@ -0,0 +1,79 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// This class defines a 4D vector that contains four Float fields for its four dimensions.
+///
+public sealed class AnimationVector4D : AnimationVector
+{
+ public AnimationVector4D()
+ : this(0f, 0f, 0f, 0f)
+ {
+ }
+
+ public AnimationVector4D(float v1, float v2, float v3, float v4)
+ {
+ V1 = v1;
+ V2 = v2;
+ V3 = v3;
+ V4 = v4;
+ }
+
+ public float V1 { get; internal set; }
+
+ public float V2 { get; internal set; }
+
+ public float V3 { get; internal set; }
+
+ public float V4 { get; internal set; }
+
+ internal override void Reset()
+ {
+ V1 = 0f;
+ V2 = 0f;
+ V3 = 0f;
+ V4 = 0f;
+ }
+
+ internal override AnimationVector NewVector() => new AnimationVector4D(0f, 0f, 0f, 0f);
+
+ internal override float GetValue(int index) => index switch
+ {
+ 0 => V1,
+ 1 => V2,
+ 2 => V3,
+ 3 => V4,
+ _ => 0f,
+ };
+
+ internal override void SetValue(int index, float value)
+ {
+ switch (index)
+ {
+ case 0:
+ V1 = value;
+ break;
+ case 1:
+ V2 = value;
+ break;
+ case 2:
+ V3 = value;
+ break;
+ case 3:
+ V4 = value;
+ break;
+ }
+ }
+
+ internal override int Size => 4;
+
+ public override string ToString() => $"AnimationVector4D(V1 = {V1}, V2 = {V2}, V3 = {V3}, V4 = {V4})";
+
+ public override bool Equals(object? obj) =>
+ obj is AnimationVector4D other &&
+ other.V1.Equals(V1) &&
+ other.V2.Equals(V2) &&
+ other.V3.Equals(V3) &&
+ other.V4.Equals(V4);
+
+ public override int GetHashCode() => HashCode.Combine(V1, V2, V3, V4);
+}
diff --git a/src/MaterialDesign3.Motion/AnimationVectorExtensions.cs b/src/MaterialDesign3.Motion/AnimationVectorExtensions.cs
new file mode 100644
index 0000000000..1b17d56613
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVectorExtensions.cs
@@ -0,0 +1,52 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class AnimationVectorExtensions
+{
+ public static T NewInstance(this T vector)
+ where T : AnimationVector
+ {
+ if (vector is null)
+ {
+ throw new ArgumentNullException(nameof(vector));
+ }
+
+ return (T)vector.NewVector();
+ }
+
+ public static T Copy(this T vector)
+ where T : AnimationVector
+ {
+ if (vector is null)
+ {
+ throw new ArgumentNullException(nameof(vector));
+ }
+
+ var newVector = vector.NewInstance();
+ for (var i = 0; i < newVector.Size; i++)
+ {
+ newVector[i] = vector.GetValue(i);
+ }
+
+ return newVector;
+ }
+
+ public static void CopyFrom(this T vector, T source)
+ where T : AnimationVector
+ {
+ if (vector is null)
+ {
+ throw new ArgumentNullException(nameof(vector));
+ }
+
+ if (source is null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ var size = vector.Size;
+ for (var i = 0; i < size; i++)
+ {
+ vector[i] = source.GetValue(i);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/AnimationVectorFactory.cs b/src/MaterialDesign3.Motion/AnimationVectorFactory.cs
new file mode 100644
index 0000000000..93ff959715
--- /dev/null
+++ b/src/MaterialDesign3.Motion/AnimationVectorFactory.cs
@@ -0,0 +1,12 @@
+namespace MaterialDesignThemes.Motion;
+
+public static class AnimationVectorFactory
+{
+ public static AnimationVector1D Create(float v1) => new(v1);
+
+ public static AnimationVector2D Create(float v1, float v2) => new(v1, v2);
+
+ public static AnimationVector3D Create(float v1, float v2, float v3) => new(v1, v2, v3);
+
+ public static AnimationVector4D Create(float v1, float v2, float v3, float v4) => new(v1, v2, v3, v4);
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/ArcSpline.cs b/src/MaterialDesign3.Motion/ArcSpline.cs
new file mode 100644
index 0000000000..33e8616aa2
--- /dev/null
+++ b/src/MaterialDesign3.Motion/ArcSpline.cs
@@ -0,0 +1,516 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// This provides a curve fit system that stitches the x,y path together with quarter ellipses.
+///
+internal sealed class ArcSpline
+{
+ private static readonly bool IsExtrapolate = true;
+
+ private readonly Arc[][] _arcs;
+
+ ///
+ /// This provides a curve fit system that stitches the x,y path together with quarter ellipses.
+ ///
+ /// Array of arc mode values. Expected to be of size n - 1.
+ /// Array of timestamps. Expected to be of size n. Seconds preferred.
+ ///
+ /// Array of values (of size n), where each value is spread on a [FloatArray] for each of
+ /// its dimensions, expected to be of even size since two values are needed to interpolate arcs.
+ ///
+ public ArcSpline(int[] arcModes, float[] timePoints, float[][] y)
+ {
+ var mode = StartVertical;
+ var last = StartVertical;
+
+ var count = timePoints.Length - 1;
+ _arcs = new Arc[count][];
+
+ for (int i = 0; i < count; i++)
+ {
+ switch (arcModes[i])
+ {
+ case ArcSplineArcStartVertical:
+ mode = StartVertical;
+ last = mode;
+ break;
+ case ArcSplineArcStartHorizontal:
+ mode = StartHorizontal;
+ last = mode;
+ break;
+ case ArcSplineArcStartFlip:
+ mode = last == StartVertical ? StartHorizontal : StartVertical;
+ last = mode;
+ break;
+ case ArcSplineArcStartLinear:
+ mode = StartLinear;
+ break;
+ case ArcSplineArcBelow:
+ mode = DownArc;
+ break;
+ case ArcSplineArcAbove:
+ mode = UpArc;
+ break;
+ }
+
+ var yArray = y[i];
+ var yArray1 = y[i + 1];
+ var time1 = timePoints[i];
+ var time2 = timePoints[i + 1];
+
+ int dim = yArray.Length / 2 + yArray.Length % 2; // matches Kotlin (expects even size)
+ var arcsForSegment = new Arc[dim];
+ for (int j = 0; j < dim; j++)
+ {
+ int k = j * 2;
+ arcsForSegment[j] = new Arc(
+ mode: mode,
+ time1: time1,
+ time2: time2,
+ x1: yArray[k],
+ y1: yArray[k + 1],
+ x2: yArray1[k],
+ y2: yArray1[k + 1]);
+ }
+ _arcs[i] = arcsForSegment;
+ }
+ }
+
+ ///
+ /// get the values of the at t point in time.
+ ///
+ public void GetPos(float time, float[] values) => GetPos(time, values, 0);
+
+ public void GetPos(float time, float[] values, int offset)
+ {
+ var arcs = _arcs;
+ int lastIndex = arcs.Length - 1;
+ float start = arcs[0][0].Time1;
+ float end = arcs[lastIndex][0].Time2;
+ int size = values.Length - offset;
+ if (size <= 0)
+ {
+ return;
+ }
+
+ if (IsExtrapolate)
+ {
+ if (time < start || time > end)
+ {
+ int p;
+ float t0;
+ if (time > end)
+ {
+ p = lastIndex;
+ t0 = end;
+ }
+ else
+ {
+ p = 0;
+ t0 = start;
+ }
+ float dt = time - t0;
+
+ int i = offset;
+ int j = 0;
+ while (i < offset + size - 1 && j < arcs[p].Length)
+ {
+ var arc = arcs[p][j];
+ if (arc.IsLinear)
+ {
+ values[i] = arc.GetLinearX(t0) + dt * arc.LinearDx;
+ values[i + 1] = arc.GetLinearY(t0) + dt * arc.LinearDy;
+ }
+ else
+ {
+ arc.SetPoint(t0);
+ values[i] = arc.CalcX() + dt * arc.CalcDx();
+ values[i + 1] = arc.CalcY() + dt * arc.CalcDy();
+ }
+ i += 2;
+ j++;
+ }
+ return;
+ }
+ }
+ else
+ {
+ if (time < start) time = start;
+ if (time > end) time = end;
+ }
+
+ bool populated = false;
+ for (int seg = 0; seg < arcs.Length; seg++)
+ {
+ int j = 0; // arc index within segment
+ int i = offset; // output index
+ while (i < offset + size - 1 && j < arcs[seg].Length)
+ {
+ var arc = arcs[seg][j];
+ if (time <= arc.Time2)
+ {
+ if (arc.IsLinear)
+ {
+ values[i] = arc.GetLinearX(time);
+ values[i + 1] = arc.GetLinearY(time);
+ }
+ else
+ {
+ arc.SetPoint(time);
+ values[i] = arc.CalcX();
+ values[i + 1] = arc.CalcY();
+ }
+ populated = true;
+ }
+ i += 2;
+ j++;
+ }
+ if (populated)
+ {
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Get the differential of the curves at point t
+ ///
+ public void GetSlope(float time, float[] slope)
+ {
+ var arcs = _arcs;
+ float start = arcs[0][0].Time1;
+ float end = arcs[arcs.Length - 1][0].Time2;
+ if (time < start) time = start; else if (time > end) time = end;
+
+ int size = slope.Length;
+ bool populated = false;
+
+ for (int seg = 0; seg < arcs.Length; seg++)
+ {
+ int i = 0; // output index
+ int j = 0; // arc index
+ while (i < size - 1 && j < arcs[seg].Length)
+ {
+ var arc = arcs[seg][j];
+ if (time <= arc.Time2)
+ {
+ if (arc.IsLinear)
+ {
+ slope[i] = arc.LinearDx;
+ slope[i + 1] = arc.LinearDy;
+ }
+ else
+ {
+ arc.SetPoint(time);
+ slope[i] = arc.CalcDx();
+ slope[i + 1] = arc.CalcDy();
+ }
+ populated = true;
+ }
+ i += 2;
+ j++;
+ }
+ if (populated)
+ {
+ return;
+ }
+ }
+ }
+
+ public float GetSlope(float time, int component)
+ {
+ var arcs = _arcs;
+ float start = arcs[0][0].Time1;
+ float end = arcs[arcs.Length - 1][0].Time2;
+ if (time < start) time = start; else if (time > end) time = end;
+
+ int pair = component / 2;
+ bool xComponent = (component % 2) == 0;
+
+ for (int seg = 0; seg < arcs.Length; seg++)
+ {
+ if (pair >= arcs[seg].Length)
+ {
+ continue;
+ }
+
+ var arc = arcs[seg][pair];
+ if (time <= arc.Time2)
+ {
+ if (arc.IsLinear)
+ {
+ return xComponent ? arc.LinearDx : arc.LinearDy;
+ }
+
+ arc.SetPoint(time);
+ return xComponent ? arc.CalcDx() : arc.CalcDy();
+ }
+ }
+
+ return 0f;
+ }
+
+ private sealed class Arc
+ {
+ private const int LutSize = 101;
+ private const float Epsilon = 0.001f;
+ private const float HalfPi = (float)(Math.PI * 0.5);
+
+ private float _arcDistance;
+ private float _tmpSinAngle;
+ private float _tmpCosAngle;
+
+ private readonly float[] _lut;
+ private readonly float _oneOverDeltaTime;
+ private readonly float _arcVelocity;
+ private readonly float _vertical;
+
+ internal readonly float EllipseA;
+ internal readonly float EllipseB;
+
+ internal readonly bool IsLinear;
+
+ ///
+ /// also used to cache the slope in the unused center
+ ///
+ internal readonly float EllipseCenterX;
+
+ ///
+ /// also used to cache the slope in the unused center
+ ///
+ internal readonly float EllipseCenterY;
+
+ public float LinearDx => EllipseCenterX;
+ public float LinearDy => EllipseCenterY;
+
+ public float Time1 { get; }
+ public float Time2 { get; }
+
+ private readonly float _x1;
+ private readonly float _y1;
+ private readonly float _x2;
+ private readonly float _y2;
+
+ public Arc(int mode, float time1, float time2, float x1, float y1, float x2, float y2)
+ {
+ Time1 = time1;
+ Time2 = time2;
+ _x1 = x1;
+ _y1 = y1;
+ _x2 = x2;
+ _y2 = y2;
+
+ float dx = x2 - x1;
+ float dy = y2 - y1;
+ bool isVertical = mode switch
+ {
+ StartVertical => true,
+ UpArc => dy < 0,
+ DownArc => dy > 0,
+ _ => false
+ };
+
+ _vertical = isVertical ? -1.0f : 1.0f;
+ _oneOverDeltaTime = 1f / (Time2 - Time1);
+ _lut = new float[LutSize];
+
+ bool isLinear = mode == StartLinear;
+ if (isLinear || Math.Abs(dx) < Epsilon || Math.Abs(dy) < Epsilon)
+ {
+ isLinear = true;
+ _arcDistance = Hypot(dy, dx);
+ _arcVelocity = _arcDistance * _oneOverDeltaTime;
+ EllipseCenterX = dx * _oneOverDeltaTime; // cache the slope in the unused center
+ EllipseCenterY = dy * _oneOverDeltaTime; // cache the slope in the unused center
+ EllipseA = float.NaN;
+ EllipseB = float.NaN;
+ }
+ else
+ {
+ EllipseA = dx * _vertical;
+ EllipseB = dy * -_vertical;
+ EllipseCenterX = isVertical ? x2 : x1;
+ EllipseCenterY = isVertical ? y1 : y2;
+ BuildTable(x1, y1, x2, y2);
+ _arcVelocity = _arcDistance * _oneOverDeltaTime;
+ }
+
+ IsLinear = isLinear;
+ }
+
+ public void SetPoint(float time)
+ {
+ float angle = CalcAngle(time);
+ _tmpSinAngle = (float)Math.Sin(angle);
+ _tmpCosAngle = (float)Math.Cos(angle);
+ }
+
+ private float CalcAngle(float time)
+ {
+ float percent = (_vertical < 0f ? Time2 - time : time - Time1) * _oneOverDeltaTime;
+ return HalfPi * Lookup(percent);
+ }
+
+ public float CalcX() => EllipseCenterX + EllipseA * _tmpSinAngle;
+
+ public float CalcY() => EllipseCenterY + EllipseB * _tmpCosAngle;
+
+ public float CalcDx()
+ {
+ float vx = EllipseA * _tmpCosAngle;
+ float vy = -EllipseB * _tmpSinAngle;
+ float norm = _arcVelocity / Hypot(vx, vy);
+ return vx * _vertical * norm;
+ }
+
+ public float CalcDy()
+ {
+ float vx = EllipseA * _tmpCosAngle;
+ float vy = -EllipseB * _tmpSinAngle;
+ float norm = _arcVelocity / Hypot(vx, vy);
+ return vy * _vertical * norm;
+ }
+
+ public float GetLinearX(float time)
+ {
+ float t = (time - Time1) * _oneOverDeltaTime;
+ return _x1 + t * (_x2 - _x1);
+ }
+
+ public float GetLinearY(float time)
+ {
+ float t = (time - Time1) * _oneOverDeltaTime;
+ return _y1 + t * (_y2 - _y1);
+ }
+
+ private float Lookup(float v)
+ {
+ if (v <= 0f) return 0f;
+ if (v >= 1f) return 1f;
+ float pos = v * (LutSize - 1);
+ int iv = (int)pos;
+ float off = pos - iv;
+ return _lut[iv] + off * (_lut[iv + 1] - _lut[iv]);
+ }
+
+ ///
+ /// Build the lookup table for arc traversal
+ ///
+ private void BuildTable(float x1, float y1, float x2, float y2)
+ {
+ float a = x2 - x1;
+ float b = y1 - y2;
+ float lx = 0f;
+ float ly = b; // == b * cos(0)
+ float dist = 0f;
+
+ var ourPercent = OurPercentCache;
+ int lastIndex = ourPercent.Length - 1;
+ float lastIndexFloat = lastIndex;
+
+ for (int i = 1; i <= lastIndex; i++)
+ {
+ float angle = (float)ToRadians(90.0 * i / lastIndex);
+ float s = (float)Math.Sin(angle);
+ float c = (float)Math.Cos(angle);
+ float px = a * s;
+ float py = b * c;
+ dist += Hypot((px - lx), (py - ly));
+ ourPercent[i] = dist;
+ lx = px;
+ ly = py;
+ }
+
+ _arcDistance = dist;
+ for (int i = 1; i <= lastIndex; i++)
+ {
+ ourPercent[i] /= dist;
+ }
+
+ float lutLastIndex = (LutSize - 1);
+ for (int i = 0; i < _lut.Length; i++)
+ {
+ float pos = i / lutLastIndex;
+ int index = BinarySearch(ourPercent, pos);
+ if (index >= 0)
+ {
+ _lut[i] = index / lastIndexFloat;
+ }
+ else if (index == -1)
+ {
+ _lut[i] = 0f;
+ }
+ else
+ {
+ int p1 = -index - 2;
+ int p2 = -index - 1;
+ float ans = (p1 + (pos - ourPercent[p1]) / (ourPercent[p2] - ourPercent[p1])) / lastIndexFloat;
+ _lut[i] = ans;
+ }
+ }
+ }
+
+ private static float Hypot(float x, float y)
+ {
+ x = Math.Abs(x);
+ y = Math.Abs(y);
+ if (x < y)
+ {
+ var t = x; x = y; y = t;
+ }
+ if (x <= 0f)
+ {
+ return 0f;
+ }
+ float r = y / x;
+ return x * (float)Math.Sqrt(1f + r * r);
+ }
+ }
+
+ internal const int ArcSplineArcStartLinear = 0;
+ internal const int ArcSplineArcStartVertical = 1;
+ internal const int ArcSplineArcStartHorizontal = 2;
+ internal const int ArcSplineArcStartFlip = 3;
+ internal const int ArcSplineArcBelow = 4;
+ internal const int ArcSplineArcAbove = 5;
+
+ private const int StartVertical = 1;
+ private const int StartHorizontal = 2;
+ private const int StartLinear = 3;
+ private const int DownArc = 4;
+ private const int UpArc = 5;
+
+ private static readonly float[] OurPercentCache = new float[91];
+
+ internal static double ToRadians(double value) => value * Math.PI / 180.0;
+
+ ///
+ /// Binary search similar to java.util.Arrays.binarySearch
+ /// for floats on a partially filled array starting at 0.
+ ///
+ internal static int BinarySearch(float[] array, float position)
+ {
+ int low = 0;
+ int high = array.Length - 1;
+
+ while (low <= high)
+ {
+ int mid = (low + high) >> 1;
+ float midVal = array[mid];
+
+ if (midVal < position)
+ {
+ low = mid + 1;
+ }
+ else if (midVal > position)
+ {
+ high = mid - 1;
+ }
+ else
+ {
+ return mid; // key found
+ }
+ }
+ return -(low + 1); // key not found
+ }
+}
diff --git a/src/MaterialDesign3.Motion/CubicBezierEasing.cs b/src/MaterialDesign3.Motion/CubicBezierEasing.cs
new file mode 100644
index 0000000000..8fd2ec03c1
--- /dev/null
+++ b/src/MaterialDesign3.Motion/CubicBezierEasing.cs
@@ -0,0 +1,27 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// The cubic polynomial easing that implements third-order Bezier curves.
+/// This is equivalent to the Android PathInterpolator.
+///
+///
+/// The x coordinate of the first control point.
+/// The line through the point (0, 0)
+/// and the first control point is tangent to the easing at the point (0, 0)
+///
+///
+/// The y coordinate of the first control point.
+/// The line through the point (0, 0)
+/// and the first control point is tangent to the easing at the point (0, 0).
+///
+///
+/// The x coordinate of the second control point.
+/// The line through the point (1, 1)
+/// and the second control point is tangent to the easing at the point (1, 1).
+///
+///
+/// The y coordinate of the second control point.
+/// The line through the point (1, 1)
+/// and the second control point is tangent to the easing at the point (1, 1).
+///
+public record CubicBezierEasing(float X1, float Y1, float X2, float Y2);
diff --git a/src/MaterialDesign3.Motion/Easing.cs b/src/MaterialDesign3.Motion/Easing.cs
new file mode 100644
index 0000000000..06d2e898c4
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Easing.cs
@@ -0,0 +1,11 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// The easing to be used for adjusting an animation's fraction. This allows
+/// animation to speed up and slow down, rather than moving at a constant rate.
+/// If not set, defaults to Linear Interpolator.
+///
+///
+/// The cubic polynomial easing that implements third-order Bezier-curves.
+///
+public record Easing(CubicBezierEasing? CubicBezier);
diff --git a/src/MaterialDesign3.Motion/Hermite.cs b/src/MaterialDesign3.Motion/Hermite.cs
new file mode 100644
index 0000000000..5983bfbabf
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Hermite.cs
@@ -0,0 +1,17 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class Hermite
+{
+ public static float Interpolate(float h, float x, float y1, float y2, float t1, float t2)
+ {
+ var x2 = x * x;
+ var x3 = x2 * x;
+ return h * t1 * (x - 2 * x2 + x3) + h * t2 * (x3 - x2) + y1 - (3 * x2 - 2 * x3) * (y1 - y2);
+ }
+
+ public static float Differential(float h, float x, float y1, float y2, float t1, float t2)
+ {
+ var x2 = x * x;
+ return h * (t1 - 2 * x * (2 * t1 + t2) + 3 * (t1 + t2) * x2) - 6 * (x - x2) * (y1 - y2);
+ }
+}
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/IMotionScheme.cs b/src/MaterialDesign3.Motion/IMotionScheme.cs
new file mode 100644
index 0000000000..f12645ff19
--- /dev/null
+++ b/src/MaterialDesign3.Motion/IMotionScheme.cs
@@ -0,0 +1,19 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Provides a set of spring-based motion specifications for Material components.
+///
+public interface IMotionScheme
+{
+ SpringMotionSpec DefaultSpatialSpec { get; }
+
+ SpringMotionSpec FastSpatialSpec { get; }
+
+ SpringMotionSpec SlowSpatialSpec { get; }
+
+ SpringMotionSpec DefaultEffectsSpec { get; }
+
+ SpringMotionSpec FastEffectsSpec { get; }
+
+ SpringMotionSpec SlowEffectsSpec { get; }
+}
diff --git a/src/MaterialDesign3.Motion/ITwoWayConverter.cs b/src/MaterialDesign3.Motion/ITwoWayConverter.cs
new file mode 100644
index 0000000000..f4dfd4fead
--- /dev/null
+++ b/src/MaterialDesign3.Motion/ITwoWayConverter.cs
@@ -0,0 +1,14 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// class contains the definition on how to convert from an arbitrary type T to a
+/// , and convert the back to the type T. This allows animations
+/// to run on any type of objects, e.g. position, rectangle, color, etc.
+///
+public interface ITwoWayConverter
+ where TAnimationVector : AnimationVector
+{
+ Func ConvertToVector { get; }
+
+ Func ConvertFromVector { get; }
+}
diff --git a/src/MaterialDesign3.Motion/MonoSpline.cs b/src/MaterialDesign3.Motion/MonoSpline.cs
new file mode 100644
index 0000000000..9b5ac85b65
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MonoSpline.cs
@@ -0,0 +1,259 @@
+namespace MaterialDesignThemes.Motion;
+
+internal sealed class MonoSpline
+{
+ private readonly float[] _timePoints;
+ private readonly float[][] _values;
+ private readonly float[][] _tangents;
+
+ public MonoSpline(float[] time, float[][] y, float periodicBias)
+ {
+ var n = time.Length;
+ var dim = y[0].Length;
+
+ var slope = MakeFloatArray(n - 1, dim);
+ var tangent = MakeFloatArray(n, dim);
+
+ for (var j = 0; j < dim; j++)
+ {
+ for (var i = 0; i < n - 1; i++)
+ {
+ var dt = time[i + 1] - time[i];
+ slope[i][j] = (y[i + 1][j] - y[i][j]) / dt;
+ tangent[i][j] = i == 0 ? slope[i][j] : 0.5f * (slope[i - 1][j] + slope[i][j]);
+ }
+
+ tangent[n - 1][j] = slope[n - 2][j];
+ }
+
+ if (!float.IsNaN(periodicBias))
+ {
+ for (var j = 0; j < dim; j++)
+ {
+ var adjustedSlope = (slope[n - 2][j] * (1 - periodicBias)) + (slope[0][j] * periodicBias);
+ slope[0][j] = adjustedSlope;
+ slope[n - 2][j] = adjustedSlope;
+ tangent[n - 1][j] = adjustedSlope;
+ tangent[0][j] = adjustedSlope;
+ }
+ }
+
+ for (var i = 0; i < n - 1; i++)
+ {
+ for (var j = 0; j < dim; j++)
+ {
+ if (slope[i][j] == 0.0f)
+ {
+ tangent[i][j] = 0.0f;
+ tangent[i + 1][j] = 0.0f;
+ }
+ else
+ {
+ var a = tangent[i][j] / slope[i][j];
+ var b = tangent[i + 1][j] / slope[i][j];
+ var h = (float)Math.Sqrt(a * a + b * b);
+ if (h > 9.0f)
+ {
+ var t = 3.0f / h;
+ tangent[i][j] = t * a * slope[i][j];
+ tangent[i + 1][j] = t * b * slope[i][j];
+ }
+ }
+ }
+ }
+
+ _timePoints = time;
+ _values = y;
+ _tangents = tangent;
+ }
+
+ public float GetPos(float time, int component)
+ {
+ var n = _timePoints.Length;
+ var index = time <= _timePoints[0]
+ ? 0
+ : (time >= _timePoints[n - 1] ? n - 1 : -1);
+
+ if (index != -1)
+ {
+ return _values[index][component] + (time - _timePoints[index]) * GetSlope(_timePoints[index], component);
+ }
+
+ for (var i = 0; i < n - 1; i++)
+ {
+ if (Math.Abs(time - _timePoints[i]) < float.Epsilon)
+ {
+ return _values[i][component];
+ }
+
+ if (time < _timePoints[i + 1])
+ {
+ var h = _timePoints[i + 1] - _timePoints[i];
+ var x = (time - _timePoints[i]) / h;
+ var y1 = _values[i][component];
+ var y2 = _values[i + 1][component];
+ var t1 = _tangents[i][component];
+ var t2 = _tangents[i + 1][component];
+ return Hermite.Interpolate(h, x, y1, y2, t1, t2);
+ }
+ }
+
+ return 0f;
+ }
+
+ public void GetPos(float time, AnimationVector vector, int index = 0)
+ {
+ var n = _timePoints.Length;
+ var dim = _values[0].Length;
+ if (vector.Size < dim)
+ {
+ return;
+ }
+
+ var t = Clamp(time, _timePoints[0], _timePoints[n - 1]);
+ for (var i = index; i < n - 1; i++)
+ {
+ if (t <= _timePoints[i + 1])
+ {
+ var h = _timePoints[i + 1] - _timePoints[i];
+ var x = (t - _timePoints[i]) / h;
+ for (var j = 0; j < dim; j++)
+ {
+ var y1 = _values[i][j];
+ var y2 = _values[i + 1][j];
+ var t1 = _tangents[i][j];
+ var t2 = _tangents[i + 1][j];
+ vector[j] = Hermite.Interpolate(h, x, y1, y2, t1, t2);
+ }
+
+ break;
+ }
+ }
+ }
+
+ public float GetSlope(float time, int component)
+ {
+ var n = _timePoints.Length;
+ var t = Clamp(time, _timePoints[0], _timePoints[n - 1]);
+ for (var i = 0; i < n - 1; i++)
+ {
+ if (t <= _timePoints[i + 1])
+ {
+ var y1 = _values[i][component];
+ var y2 = _values[i + 1][component];
+ var t1 = _tangents[i][component];
+ var t2 = _tangents[i + 1][component];
+ var h = _timePoints[i + 1] - _timePoints[i];
+ var x = (t - _timePoints[i]) / h;
+ return Hermite.Differential(h, x, y1, y2, t1, t2) / h;
+ }
+ }
+
+ return 0f;
+ }
+
+ public void GetSlope(float time, float[] slope)
+ {
+ var dim = _values[0].Length;
+ if (slope.Length < dim)
+ {
+ return;
+ }
+
+ var n = _timePoints.Length;
+ var t = Clamp(time, _timePoints[0], _timePoints[n - 1]);
+ for (var i = 0; i < n - 1; i++)
+ {
+ if (t <= _timePoints[i + 1])
+ {
+ var h = _timePoints[i + 1] - _timePoints[i];
+ var x = (t - _timePoints[i]) / h;
+ for (var j = 0; j < dim; j++)
+ {
+ var y1 = _values[i][j];
+ var y2 = _values[i + 1][j];
+ var t1 = _tangents[i][j];
+ var t2 = _tangents[i + 1][j];
+ slope[j] = Hermite.Differential(h, x, y1, y2, t1, t2) / h;
+ }
+
+ break;
+ }
+ }
+ }
+
+ public void GetSlope(float time, AnimationVector vector, int index = 0)
+ {
+ var n = _timePoints.Length;
+ var dim = _values[0].Length;
+ if (vector.Size < dim)
+ {
+ return;
+ }
+
+ var tangentIndex = time <= _timePoints[0]
+ ? 0
+ : (time >= _timePoints[n - 1] ? n - 1 : -1);
+
+ if (tangentIndex != -1)
+ {
+ var tangent = _tangents[tangentIndex];
+ if (tangent.Length < dim)
+ {
+ return;
+ }
+
+ for (var j = 0; j < dim; j++)
+ {
+ vector[j] = tangent[j];
+ }
+
+ return;
+ }
+
+ for (var i = index; i < n - 1; i++)
+ {
+ if (time <= _timePoints[i + 1])
+ {
+ var h = _timePoints[i + 1] - _timePoints[i];
+ var x = (time - _timePoints[i]) / h;
+ for (var j = 0; j < dim; j++)
+ {
+ var y1 = _values[i][j];
+ var y2 = _values[i + 1][j];
+ var t1 = _tangents[i][j];
+ var t2 = _tangents[i + 1][j];
+ vector[j] = Hermite.Differential(h, x, y1, y2, t1, t2) / h;
+ }
+
+ break;
+ }
+ }
+ }
+
+ private static float[][] MakeFloatArray(int count, int dimension)
+ {
+ var array = new float[count][];
+ for (var i = 0; i < count; i++)
+ {
+ array[i] = new float[dimension];
+ }
+
+ return array;
+ }
+
+ private static float Clamp(float value, float min, float max)
+ {
+ if (value < min)
+ {
+ return min;
+ }
+
+ if (value > max)
+ {
+ return max;
+ }
+
+ return value;
+ }
+}
diff --git a/src/MaterialDesign3.Motion/Motion.cs b/src/MaterialDesign3.Motion/Motion.cs
new file mode 100644
index 0000000000..d8de72d53b
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Motion.cs
@@ -0,0 +1,3 @@
+namespace MaterialDesignThemes.Motion;
+
+internal readonly record struct Motion(float Value, float Velocity);
\ No newline at end of file
diff --git a/src/MaterialDesign3.Motion/Motion.csproj b/src/MaterialDesign3.Motion/Motion.csproj
new file mode 100644
index 0000000000..a4e4b1ee68
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Motion.csproj
@@ -0,0 +1,42 @@
+
+
+ MaterialDesignThemes.Motion
+ MaterialDesignThemes.Motion
+ net462;net8.0-windows
+ true
+ Material motion specifications for Material Design in XAML Toolkit.
+ 13
+ enable
+ disable
+ false
+ true
+ 1.0.1
+ $([System.Text.RegularExpressions.Regex]::Replace("$(MDIXColorsVersion)", "-ci\d+$", ""))
+ $(MDIXColorsVersion)
+ $(MDIXColorsVersion)
+
+
+ MaterialDesignMotion
+ Material Design 3 Color Utilities
+ Utilities to create Material Design 3 Color Palettes
+ WPF XAML Material Design Animation UI UX
+ CS0618
+ enable
+
+
+
+
+ <_Parameter1>MDIXColorsVersion
+ <_Parameter2>$(MDIXColorsVersion)
+
+
+
+
+
+
+
+ all
+
+
+
+
diff --git a/src/MaterialDesign3.Motion/Motion.md b/src/MaterialDesign3.Motion/Motion.md
new file mode 100644
index 0000000000..cdc96d14c9
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Motion.md
@@ -0,0 +1,127 @@
+# Motion in Material Design 3
+
+Contains code ported from the following **AndroidX** libraries to support motion in Material Design 3:
+- **Compose Animation** library (`androidx.compose.animation.core`)
+- **Compose Material3** library (`androidx.compose.material3`)
+
+For example:
+* Animation APIs such as [animateDpAsState], [animateDecay], and [animateRect].
+* Infinite animation APIs such as [infiniteRepeatable], and [rememberInfiniteTransition].
+* Animation spec APIs such as [tween], [spring], [snap], and [keyframes].
+* Easing APIs such as [FastOutSlowInEasing], and [CubicBezierEasing].
+
+For more details, see the original source code at:
+- [androidx/compose/material3](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/)
+- [androidx/compose/animation/core](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/)
+
+## Using Material Motion Primitives in WPF
+
+The `MaterialDesignThemes.Motion` project does not ship a drop-in `AnimationTimeline`; instead, it exposes the pieces you need to build WPF-friendly motion:
+- `MotionSchemeContext` and `MotionSchemes` give you the Material 3 spring presets (`SpringMotionSpec`) for spatial and effects motion.
+- `MotionTokens` maps to the duration and easing tokens that you can feed into WPF `Storyboard`-based tweens.
+- `AnimationParameters`, `Repeatable`, and `Easing` capture how long to run, which easing curve to apply, and how to repeat an animation.
+- `SpringSimulation`, `SpringConstants`, and `SpringEstimation` let you drive a per-frame spring animation when WPF’s built-in easing functions are not sufficient.
+
+### Example: Spring-driven translation on `TranslateTransform.X`
+
+```csharp
+using System;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Media;
+using MaterialDesignThemes.Motion;
+
+public partial class SpringSampleControl : UserControl, IDisposable
+{
+ private readonly SpringAnimator _animator;
+
+ public SpringSampleControl()
+ {
+ InitializeComponent();
+ var spec = MotionSchemeContext.Current.RememberDefaultSpatialSpec();
+ _animator = new SpringAnimator(
+ apply: value => Translate.X = value,
+ springSpec: spec);
+ Loaded += (_, _) => _animator.Start(from: -120, to: 0);
+ Unloaded += (_, _) => Dispose();
+ }
+
+ public void Dispose() => _animator.Dispose();
+
+ public TranslateTransform Translate { get; } = new();
+
+ private sealed class SpringAnimator : IDisposable
+ {
+ private readonly SpringSimulation _simulation;
+ private readonly Action _apply;
+ private readonly Stopwatch _stopwatch = new();
+ private double _value;
+ private double _velocity;
+ private bool _isRunning;
+
+ public SpringAnimator(Action apply, SpringMotionSpec springSpec)
+ {
+ _apply = apply;
+ _simulation = new SpringSimulation(finalPosition: 0f)
+ {
+ Stiffness = (float)springSpec.Stiffness,
+ DampingRatio = (float)springSpec.DampingRatio,
+ };
+ }
+
+ public void Start(double from, double to)
+ {
+ _simulation.FinalPosition = (float)to;
+ _value = from;
+ _velocity = 0;
+ _stopwatch.Restart();
+ if (_isRunning)
+ {
+ return;
+ }
+
+ CompositionTarget.Rendering += OnRendering;
+ _isRunning = true;
+ }
+
+ private void OnRendering(object? sender, EventArgs e)
+ {
+ var elapsed = _stopwatch.Elapsed;
+ _stopwatch.Restart();
+ var next = _simulation.UpdateValues((float)_value, (float)_velocity, elapsed);
+ _value = next.Value;
+ _velocity = next.Velocity;
+ _apply(_value);
+
+ if (Math.Abs(_value - _simulation.FinalPosition) < 0.5 &&
+ Math.Abs(_velocity) < 0.5)
+ {
+ Stop();
+ }
+ }
+
+ private void Stop()
+ {
+ if (!_isRunning)
+ {
+ return;
+ }
+
+ CompositionTarget.Rendering -= OnRendering;
+ _isRunning = false;
+ }
+
+ public void Dispose() => Stop();
+ }
+}
+```
+
+Key takeaways for adapting the primitives to WPF:
+- Use `SpringMotionSpec` from a scheme to configure stiffness and damping ratios that align with the Material 3 design guidance.
+- `SpringSimulation.UpdateValues` returns both the new position and velocity, letting you decide when the animation has converged.
+- When you want more traditional tweens, reuse the durations (`MotionTokens.DurationMedium2`, etc.) and cubic bezier control points (`MotionTokens.EasingEmphasizedCubicBezier`) inside standard `DoubleAnimation` / `SplineDoubleKeyFrame` definitions.
+
+## See also
+
+- [Animation in WPF](https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/animation-overview)
+- [Android Animator](https://developer.android.com/reference/kotlin/android/animation/Animator)
diff --git a/src/MaterialDesign3.Motion/MotionSchemeContext.cs b/src/MaterialDesign3.Motion/MotionSchemeContext.cs
new file mode 100644
index 0000000000..6163ea0faf
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionSchemeContext.cs
@@ -0,0 +1,15 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Provides access to the current motion scheme and allows overriding it at runtime.
+///
+public static class MotionSchemeContext
+{
+ private static IMotionScheme _current = MotionSchemes.Standard();
+
+ public static IMotionScheme Current
+ {
+ get => _current;
+ set => _current = value ?? throw new ArgumentNullException(nameof(value));
+ }
+}
diff --git a/src/MaterialDesign3.Motion/MotionSchemeExtensions.cs b/src/MaterialDesign3.Motion/MotionSchemeExtensions.cs
new file mode 100644
index 0000000000..2dd1070631
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionSchemeExtensions.cs
@@ -0,0 +1,23 @@
+namespace MaterialDesignThemes.Motion;
+
+public static class MotionSchemeExtensions
+{
+ public static SpringMotionSpec RememberDefaultSpatialSpec(this IMotionScheme scheme) => scheme.DefaultSpatialSpec;
+ public static SpringMotionSpec RememberFastSpatialSpec(this IMotionScheme scheme) => scheme.FastSpatialSpec;
+ public static SpringMotionSpec RememberSlowSpatialSpec(this IMotionScheme scheme) => scheme.SlowSpatialSpec;
+ public static SpringMotionSpec RememberDefaultEffectsSpec(this IMotionScheme scheme) => scheme.DefaultEffectsSpec;
+ public static SpringMotionSpec RememberFastEffectsSpec(this IMotionScheme scheme) => scheme.FastEffectsSpec;
+ public static SpringMotionSpec RememberSlowEffectsSpec(this IMotionScheme scheme) => scheme.SlowEffectsSpec;
+
+ internal static SpringMotionSpec FromToken(this IMotionScheme scheme, MotionSchemeKeyTokens token) =>
+ token switch
+ {
+ MotionSchemeKeyTokens.DefaultSpatial => scheme.RememberDefaultSpatialSpec(),
+ MotionSchemeKeyTokens.FastSpatial => scheme.RememberFastSpatialSpec(),
+ MotionSchemeKeyTokens.SlowSpatial => scheme.RememberSlowSpatialSpec(),
+ MotionSchemeKeyTokens.DefaultEffects => scheme.RememberDefaultEffectsSpec(),
+ MotionSchemeKeyTokens.FastEffects => scheme.RememberFastEffectsSpec(),
+ MotionSchemeKeyTokens.SlowEffects => scheme.RememberSlowEffectsSpec(),
+ _ => throw new ArgumentOutOfRangeException(nameof(token), token, null),
+ };
+}
diff --git a/src/MaterialDesign3.Motion/MotionSchemeKeyTokenExtensions.cs b/src/MaterialDesign3.Motion/MotionSchemeKeyTokenExtensions.cs
new file mode 100644
index 0000000000..4539770e60
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionSchemeKeyTokenExtensions.cs
@@ -0,0 +1,12 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class MotionSchemeKeyTokenExtensions
+{
+ internal static SpringMotionSpec Value(this MotionSchemeKeyTokens token) =>
+ token.Value(MotionSchemeContext.Current);
+
+ internal static SpringMotionSpec Value(
+ this MotionSchemeKeyTokens token,
+ IMotionScheme scheme) =>
+ scheme.FromToken(token);
+}
diff --git a/src/MaterialDesign3.Motion/MotionSchemeKeyTokens.cs b/src/MaterialDesign3.Motion/MotionSchemeKeyTokens.cs
new file mode 100644
index 0000000000..43589065d8
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionSchemeKeyTokens.cs
@@ -0,0 +1,11 @@
+namespace MaterialDesignThemes.Motion;
+
+public enum MotionSchemeKeyTokens
+{
+ DefaultSpatial,
+ FastSpatial,
+ SlowSpatial,
+ DefaultEffects,
+ FastEffects,
+ SlowEffects,
+}
diff --git a/src/MaterialDesign3.Motion/MotionSchemes.cs b/src/MaterialDesign3.Motion/MotionSchemes.cs
new file mode 100644
index 0000000000..efd5ff4975
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionSchemes.cs
@@ -0,0 +1,63 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Factory helpers that expose the built-in Material motion schemes.
+///
+public static class MotionSchemes
+{
+ public static IMotionScheme Standard() => new DelegateMotionScheme(
+ defaultSpatial: new SpringMotionSpec(StandardSpatialDampingRatio, 700.0),
+ fastSpatial: new SpringMotionSpec(StandardSpatialDampingRatio, 1400.0),
+ slowSpatial: new SpringMotionSpec(StandardSpatialDampingRatio, 300.0),
+ defaultEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsDefaultStiffness),
+ fastEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsFastStiffness),
+ slowEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsSlowStiffness));
+
+ public static IMotionScheme Expressive() => new DelegateMotionScheme(
+ defaultSpatial: new SpringMotionSpec(0.8, 380.0),
+ fastSpatial: new SpringMotionSpec(0.6, 800.0),
+ slowSpatial: new SpringMotionSpec(0.8, 200.0),
+ defaultEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsDefaultStiffness),
+ fastEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsFastStiffness),
+ slowEffects: new SpringMotionSpec(EffectsDampingRatio, EffectsSlowStiffness));
+
+ private sealed class DelegateMotionScheme : IMotionScheme
+ {
+ public DelegateMotionScheme(
+ SpringMotionSpec defaultSpatial,
+ SpringMotionSpec fastSpatial,
+ SpringMotionSpec slowSpatial,
+ SpringMotionSpec defaultEffects,
+ SpringMotionSpec fastEffects,
+ SpringMotionSpec slowEffects)
+ {
+ DefaultSpatialSpec = defaultSpatial;
+ FastSpatialSpec = fastSpatial;
+ SlowSpatialSpec = slowSpatial;
+ DefaultEffectsSpec = defaultEffects;
+ FastEffectsSpec = fastEffects;
+ SlowEffectsSpec = slowEffects;
+ }
+
+ public SpringMotionSpec DefaultSpatialSpec { get; }
+
+ public SpringMotionSpec FastSpatialSpec { get; }
+
+ public SpringMotionSpec SlowSpatialSpec { get; }
+
+ public SpringMotionSpec DefaultEffectsSpec { get; }
+
+ public SpringMotionSpec FastEffectsSpec { get; }
+
+ public SpringMotionSpec SlowEffectsSpec { get; }
+ }
+
+ ///
+ /// Spring.DampingRatioNoBouncy
+ ///
+ private const double EffectsDampingRatio = 1.0;
+ private const double EffectsDefaultStiffness = 1600.0;
+ private const double EffectsFastStiffness = 3800.0;
+ private const double EffectsSlowStiffness = 800.0;
+ private const double StandardSpatialDampingRatio = 0.9;
+}
diff --git a/src/MaterialDesign3.Motion/MotionTokens.cs b/src/MaterialDesign3.Motion/MotionTokens.cs
new file mode 100644
index 0000000000..b5a71d662e
--- /dev/null
+++ b/src/MaterialDesign3.Motion/MotionTokens.cs
@@ -0,0 +1,32 @@
+namespace MaterialDesignThemes.Motion;
+
+public static class MotionTokens
+{
+ public static readonly TimeSpan DurationExtraLong1 = TimeSpan.FromMilliseconds(700.0);
+ public static readonly TimeSpan DurationExtraLong2 = TimeSpan.FromMilliseconds(800.0);
+ public static readonly TimeSpan DurationExtraLong3 = TimeSpan.FromMilliseconds(900.0);
+ public static readonly TimeSpan DurationExtraLong4 = TimeSpan.FromMilliseconds(1000.0);
+ public static readonly TimeSpan DurationLong1 = TimeSpan.FromMilliseconds(450.0);
+ public static readonly TimeSpan DurationLong2 = TimeSpan.FromMilliseconds(500.0);
+ public static readonly TimeSpan DurationLong3 = TimeSpan.FromMilliseconds(550.0);
+ public static readonly TimeSpan DurationLong4 = TimeSpan.FromMilliseconds(600.0);
+ public static readonly TimeSpan DurationMedium1 = TimeSpan.FromMilliseconds(250.0);
+ public static readonly TimeSpan DurationMedium2 = TimeSpan.FromMilliseconds(300.0);
+ public static readonly TimeSpan DurationMedium3 = TimeSpan.FromMilliseconds(350.0);
+ public static readonly TimeSpan DurationMedium4 = TimeSpan.FromMilliseconds(400.0);
+ public static readonly TimeSpan DurationShort1 = TimeSpan.FromMilliseconds(50.0);
+ public static readonly TimeSpan DurationShort2 = TimeSpan.FromMilliseconds(100.0);
+ public static readonly TimeSpan DurationShort3 = TimeSpan.FromMilliseconds(150.0);
+ public static readonly TimeSpan DurationShort4 = TimeSpan.FromMilliseconds(200.0);
+
+ public static readonly CubicBezierEasing EasingEmphasizedCubicBezier = new(0.2f, 0.0f, 0.0f, 1.0f);
+ public static readonly CubicBezierEasing EasingEmphasizedAccelerateCubicBezier = new(0.3f, 0.0f, 0.8f, 0.15f);
+ public static readonly CubicBezierEasing EasingEmphasizedDecelerateCubicBezier = new(0.05f, 0.7f, 0.1f, 1.0f);
+ public static readonly CubicBezierEasing EasingLegacyCubicBezier = new(0.4f, 0.0f, 0.2f, 1.0f);
+ public static readonly CubicBezierEasing EasingLegacyAccelerateCubicBezier = new(0.4f, 0.0f, 1.0f, 1.0f);
+ public static readonly CubicBezierEasing EasingLegacyDecelerateCubicBezier = new(0.0f, 0.0f, 0.2f, 1.0f);
+ public static readonly CubicBezierEasing EasingLinearCubicBezier = new(0.0f, 0.0f, 1.0f, 1.0f);
+ public static readonly CubicBezierEasing EasingStandardCubicBezier = new(0.2f, 0.0f, 0.0f, 1.0f);
+ public static readonly CubicBezierEasing EasingStandardAccelerateCubicBezier = new(0.3f, 0.0f, 1.0f, 1.0f);
+ public static readonly CubicBezierEasing EasingStandardDecelerateCubicBezier = new(0.0f, 0.0f, 0.0f, 1.0f);
+}
diff --git a/src/MaterialDesign3.Motion/Preconditions.cs b/src/MaterialDesign3.Motion/Preconditions.cs
new file mode 100644
index 0000000000..ab4c069027
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Preconditions.cs
@@ -0,0 +1,44 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class Preconditions
+{
+ internal static void ThrowIllegalArgumentException(string message) =>
+ throw new ArgumentException(message);
+
+ internal static void RequirePrecondition(bool value, Func lazyMessage)
+ {
+ if (value)
+ {
+ return;
+ }
+
+ ThrowIllegalArgumentException(lazyMessage());
+ }
+
+ internal static void ThrowIllegalStateException(string message) =>
+ throw new InvalidOperationException(message);
+
+ internal static void ThrowIllegalStateExceptionForNullCheck(string message) =>
+ throw new InvalidOperationException(message);
+
+ internal static T CheckPreconditionNotNull(T? value, Func lazyMessage)
+ where T : class
+ {
+ if (value is null)
+ {
+ ThrowIllegalStateExceptionForNullCheck(lazyMessage());
+ }
+
+ return value!;
+ }
+
+ internal static void CheckPrecondition(bool value, Func lazyMessage)
+ {
+ if (value)
+ {
+ return;
+ }
+
+ ThrowIllegalStateException(lazyMessage());
+ }
+}
diff --git a/src/MaterialDesign3.Motion/RepeatMode.cs b/src/MaterialDesign3.Motion/RepeatMode.cs
new file mode 100644
index 0000000000..8b9c480130
--- /dev/null
+++ b/src/MaterialDesign3.Motion/RepeatMode.cs
@@ -0,0 +1,22 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// The repeat mode to specify how animation will behave when repeated.
+///
+public enum RepeatMode
+{
+ ///
+ /// The unknown repeat mode.
+ ///
+ Unknown = 0,
+
+ ///
+ /// The repeat mode where animation restarts from the beginning when repeated.
+ ///
+ Restart = 1,
+
+ ///
+ /// The repeat mode where animation is played in reverse when repeated.
+ ///
+ Reverse = 2
+}
diff --git a/src/MaterialDesign3.Motion/Repeatable.cs b/src/MaterialDesign3.Motion/Repeatable.cs
new file mode 100644
index 0000000000..9ae4e88cb2
--- /dev/null
+++ b/src/MaterialDesign3.Motion/Repeatable.cs
@@ -0,0 +1,39 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// The repeatable mode to be used for specifying how many times animation will be repeated.
+///
+public sealed class Repeatable
+{
+ ///
+ /// The number specifying how many times animation will be repeated.
+ ///
+ ///
+ /// If not set, defaults to 0, i.e. repeat infinitely.
+ ///
+ public uint Iterations { get; set; } = 0;
+
+ ///
+ /// The repeat mode to specify how animation will behave when repeated.
+ ///
+ ///
+ /// If not set, defaults to restart.
+ ///
+ public RepeatMode RepeatMode { get; set; } = RepeatMode.Reverse;
+
+ ///
+ /// Optional custom parameters for the forward passes of animation.
+ ///
+ ///
+ /// If not set, use the main animation parameters set outside of Repeatable.
+ ///
+ public AnimationParameters? ForwardRepeatOverride { get; set; }
+
+ ///
+ /// Optional custom parameters for the reverse passes of animation.
+ ///
+ ///
+ /// If not set, use the main animation parameters set outside of Repeatable.
+ ///
+ public AnimationParameters? ReverseRepeatOverride { get; set; }
+}
diff --git a/src/MaterialDesign3.Motion/SpringConstants.cs b/src/MaterialDesign3.Motion/SpringConstants.cs
new file mode 100644
index 0000000000..047ff7c0e8
--- /dev/null
+++ b/src/MaterialDesign3.Motion/SpringConstants.cs
@@ -0,0 +1,57 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Physics class contains a number of recommended configurations for physics animations.
+///
+public static class SpringConstants
+{
+ ///
+ /// Stiffness constant for extremely stiff spring.
+ ///
+ public const float StiffnessHigh = 10_000f;
+
+ ///
+ /// Stiffness constant for medium stiff spring. This is the default stiffness for spring force.
+ ///
+ public const float StiffnessMedium = 1_500f;
+
+ ///
+ /// Stiffness constant for medium-low stiff spring. This is the default stiffness for springs used in enter/exit transitions.
+ ///
+ public const float StiffnessMediumLow = 400f;
+
+ ///
+ /// Stiffness constant for a spring with low stiffness.
+ ///
+ public const float StiffnessLow = 200f;
+
+ ///
+ /// Stiffness constant for a spring with very low stiffness.
+ ///
+ public const float StiffnessVeryLow = 50f;
+
+ ///
+ /// Damping ratio for a very bouncy spring.
+ ///
+ public const float DampingRatioHighBouncy = 0.2f;
+
+ ///
+ /// Damping ratio for a medium bouncy spring. This is also the default damping ratio for spring force.
+ ///
+ public const float DampingRatioMediumBouncy = 0.5f;
+
+ ///
+ /// Damping ratio for a spring with low bounciness.
+ ///
+ public const float DampingRatioLowBouncy = 0.75f;
+
+ ///
+ /// Friction ratio for a spring with gentle bounciness.
+ ///
+ public const float DampingRatioMediumBouncyFriction = 0.65f;
+
+ ///
+ /// Damping ratio for a spring that is critically damped (i.e. without oscillation).
+ ///
+ public const float DampingRatioNoBouncy = 1f;
+}
diff --git a/src/MaterialDesign3.Motion/SpringEstimation.cs b/src/MaterialDesign3.Motion/SpringEstimation.cs
new file mode 100644
index 0000000000..311492d064
--- /dev/null
+++ b/src/MaterialDesign3.Motion/SpringEstimation.cs
@@ -0,0 +1,241 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class SpringEstimation
+{
+ public static TimeSpan EstimateAnimationDuration(float stiffness, float dampingRatio, float initialVelocity, float initialDisplacement, float delta)
+ {
+ if (Math.Abs(dampingRatio) < float.Epsilon)
+ {
+ return TimeSpan.MaxValue;
+ }
+
+ return EstimateAnimationDuration(
+ stiffness: (double)stiffness,
+ dampingRatio: (double)dampingRatio,
+ initialVelocity: (double)initialVelocity,
+ initialDisplacement: (double)initialDisplacement,
+ delta: (double)delta);
+ }
+
+ public static TimeSpan EstimateAnimationDuration(double stiffness, double dampingRatio, double initialVelocity, double initialDisplacement, double delta)
+ {
+ var dampingCoefficient = 2.0 * dampingRatio * Math.Sqrt(stiffness);
+
+ var partialRoot = dampingCoefficient * dampingCoefficient - 4.0 * stiffness;
+ var partialRootReal = partialRoot < 0.0 ? 0.0 : Math.Sqrt(partialRoot);
+ var partialRootImaginary = partialRoot < 0.0 ? Math.Sqrt(Math.Abs(partialRoot)) : 0.0;
+
+ var firstRootReal = (-dampingCoefficient + partialRootReal) * 0.5;
+ var firstRootImaginary = partialRootImaginary * 0.5;
+ var secondRootReal = (-dampingCoefficient - partialRootReal) * 0.5;
+
+ return EstimateDurationInternal(
+ firstRootReal,
+ firstRootImaginary,
+ secondRootReal,
+ dampingRatio,
+ initialVelocity,
+ initialDisplacement,
+ delta);
+ }
+
+ public static TimeSpan EstimateAnimationDuration(double springConstant, double dampingCoefficient, double mass, double initialVelocity, double initialDisplacement, double delta)
+ {
+ var criticalDamping = 2.0 * Math.Sqrt(springConstant * mass);
+ var dampingRatio = dampingCoefficient / criticalDamping;
+
+ var partialRoot = dampingCoefficient * dampingCoefficient - 4.0 * mass * springConstant;
+ var divisor = 1.0 / (2.0 * mass);
+ var partialRootReal = partialRoot < 0.0 ? 0.0 : Math.Sqrt(partialRoot);
+ var partialRootImaginary = partialRoot < 0.0 ? Math.Sqrt(Math.Abs(partialRoot)) : 0.0;
+
+ var firstRootReal = (-dampingCoefficient + partialRootReal) * divisor;
+ var firstRootImaginary = partialRootImaginary * divisor;
+ var secondRootReal = (-dampingCoefficient - partialRootReal) * divisor;
+
+ return EstimateDurationInternal(
+ firstRootReal,
+ firstRootImaginary,
+ secondRootReal,
+ dampingRatio,
+ initialVelocity,
+ initialDisplacement,
+ delta);
+ }
+
+ private static double EstimateUnderDamped(double firstRootReal, double firstRootImaginary, double p0, double v0, double delta)
+ {
+ var r = firstRootReal;
+ var c1 = p0;
+ var c2 = (v0 - r * c1) / firstRootImaginary;
+ var c = Math.Sqrt(c1 * c1 + c2 * c2);
+
+ return Math.Log(delta / c) / r;
+ }
+
+ private static double EstimateCriticallyDamped(double firstRootReal, double p0, double v0, double delta)
+ {
+ var r = firstRootReal;
+ var c1 = p0;
+ var c2 = v0 - r * c1;
+
+ var t1 = Math.Log(Math.Abs(delta / c1)) / r;
+ double t2;
+ if (Math.Abs(c2) < double.Epsilon)
+ {
+ t2 = double.NaN;
+ }
+ else
+ {
+ var guess = Math.Log(Math.Abs(delta / c2));
+ var t = guess;
+ for (var i = 0; i <= 5; i++)
+ {
+ t = guess - Math.Log(Math.Abs(t / r));
+ }
+
+ t2 = t / r;
+ }
+
+ var tCurr = t1.IsNotFinite()
+ ? t2
+ : t2.IsNotFinite()
+ ? t1
+ : Math.Max(t1, t2);
+
+ var tInflection = -(r * c1 + c2) / (r * c2);
+ var xInflection = c1 * Math.Exp(r * tInflection) + c2 * tInflection * Math.Exp(r * tInflection);
+
+ double signedDelta;
+ if (double.IsNaN(tInflection) || tInflection <= 0.0)
+ {
+ signedDelta = -delta;
+ }
+ else if (tInflection > 0.0 && -xInflection < delta)
+ {
+ if (c2 < 0.0 && c1 > 0.0)
+ {
+ tCurr = 0.0;
+ }
+
+ signedDelta = -delta;
+ }
+ else
+ {
+ tCurr = -(2.0 / r) - (c1 / c2);
+ signedDelta = delta;
+ }
+
+ var tDelta = double.MaxValue;
+ var iterations = 0;
+ while (tDelta > 0.001 && iterations < 100)
+ {
+ iterations++;
+ var tLast = tCurr;
+ tCurr = IterateNewtonsMethod(
+ tCurr,
+ t => (c1 + c2 * t) * Math.Exp(r * t) + signedDelta,
+ t => (c2 * (r * t + 1) + c1 * r) * Math.Exp(r * t));
+ tDelta = Math.Abs(tLast - tCurr);
+ }
+
+ return tCurr;
+ }
+
+ private static double EstimateOverDamped(double firstRootReal, double secondRootReal, double p0, double v0, double delta)
+ {
+ var r1 = firstRootReal;
+ var r2 = secondRootReal;
+ var c2 = (r1 * p0 - v0) / (r1 - r2);
+ var c1 = p0 - c2;
+
+ var t1 = Math.Log(Math.Abs(delta / c1)) / r1;
+ var t2 = Math.Log(Math.Abs(delta / c2)) / r2;
+
+ var tCurr = t1.IsNotFinite()
+ ? t2
+ : t2.IsNotFinite()
+ ? t1
+ : Math.Max(t1, t2);
+
+ var tInflection = Math.Log((c1 * r1) / (-c2 * r2)) / (r2 - r1);
+ double signedDelta;
+ if (double.IsNaN(tInflection) || tInflection <= 0.0)
+ {
+ signedDelta = -delta;
+ }
+ else if (tInflection > 0.0 && -EvaluateOverDampedPosition(c1, c2, r1, r2, tInflection) < delta)
+ {
+ if (c2 > 0.0 && c1 < 0.0)
+ {
+ tCurr = 0.0;
+ }
+
+ signedDelta = -delta;
+ }
+ else
+ {
+ tCurr = Math.Log(-(c2 * r2 * r2) / (c1 * r1 * r1)) / (r1 - r2);
+ signedDelta = delta;
+ }
+
+ if (Math.Abs(c1 * r1 * Math.Exp(r1 * tCurr) + c2 * r2 * Math.Exp(r2 * tCurr)) < 0.0001)
+ {
+ return tCurr;
+ }
+
+ var tDelta = double.MaxValue;
+ var iterations = 0;
+ while (tDelta > 0.001 && iterations < 100)
+ {
+ iterations++;
+ var tLast = tCurr;
+ tCurr = IterateNewtonsMethod(
+ tCurr,
+ t => c1 * Math.Exp(r1 * t) + c2 * Math.Exp(r2 * t) + signedDelta,
+ t => c1 * r1 * Math.Exp(r1 * t) + c2 * r2 * Math.Exp(r2 * t));
+ tDelta = Math.Abs(tLast - tCurr);
+ }
+
+ return tCurr;
+ }
+
+ private static TimeSpan EstimateDurationInternal(double firstRootReal, double firstRootImaginary, double secondRootReal, double dampingRatio, double initialVelocity, double initialPosition, double delta)
+ {
+ if (Math.Abs(initialPosition) < double.Epsilon && Math.Abs(initialVelocity) < double.Epsilon)
+ {
+ return TimeSpan.Zero;
+ }
+
+ var v0 = initialPosition < 0 ? -initialVelocity : initialVelocity;
+ var p0 = Math.Abs(initialPosition);
+
+ double seconds = dampingRatio switch
+ {
+ > 1.0 => EstimateOverDamped(firstRootReal, secondRootReal, p0, v0, delta),
+ < 1.0 => EstimateUnderDamped(firstRootReal, firstRootImaginary, p0, v0, delta),
+ _ => EstimateCriticallyDamped(firstRootReal, p0, v0, delta),
+ };
+
+ if (!seconds.IsFinite() || seconds < 0.0)
+ {
+ return TimeSpan.MaxValue;
+ }
+
+ return TimeSpan.FromSeconds(seconds);
+ }
+
+ private static double IterateNewtonsMethod(double x, Func fn, Func fnPrime)
+ {
+ var fx = fn(x);
+ var derivative = fnPrime(x);
+ return x - fx / derivative;
+ }
+
+ private static bool IsNotFinite(this double value) => double.IsNaN(value) || double.IsInfinity(value);
+
+ private static bool IsFinite(this double value) => !double.IsNaN(value) && !double.IsInfinity(value);
+
+ private static double EvaluateOverDampedPosition(double c1, double c2, double r1, double r2, double time) =>
+ c1 * Math.Exp(r1 * time) + c2 * Math.Exp(r2 * time);
+}
diff --git a/src/MaterialDesign3.Motion/SpringMotionSpec.cs b/src/MaterialDesign3.Motion/SpringMotionSpec.cs
new file mode 100644
index 0000000000..990f930839
--- /dev/null
+++ b/src/MaterialDesign3.Motion/SpringMotionSpec.cs
@@ -0,0 +1,8 @@
+namespace MaterialDesignThemes.Motion;
+
+///
+/// Represents a simple spring-based motion specification matching the Material 3 motion tokens.
+///
+/// Gets the damping ratio to use for the spring animation.
+/// Gets the spring stiffness to use for the animation.
+public record class SpringMotionSpec(double DampingRatio, double Stiffness);
diff --git a/src/MaterialDesign3.Motion/SpringSimulation.cs b/src/MaterialDesign3.Motion/SpringSimulation.cs
new file mode 100644
index 0000000000..37b72cccf3
--- /dev/null
+++ b/src/MaterialDesign3.Motion/SpringSimulation.cs
@@ -0,0 +1,109 @@
+namespace MaterialDesignThemes.Motion;
+
+internal sealed class SpringSimulation
+{
+ private double _naturalFrequency = Math.Sqrt(SpringConstants.StiffnessVeryLow);
+ private float _finalPosition;
+ private float _dampingRatio = SpringConstants.DampingRatioNoBouncy;
+
+ public SpringSimulation(float finalPosition)
+ {
+ _finalPosition = finalPosition;
+ }
+
+ public float FinalPosition
+ {
+ get => _finalPosition;
+ set => _finalPosition = value;
+ }
+
+ public float Stiffness
+ {
+ get => (float)(_naturalFrequency * _naturalFrequency);
+ set
+ {
+ if (value <= 0f)
+ {
+ Preconditions.ThrowIllegalArgumentException("Spring stiffness constant must be positive.");
+ }
+
+ _naturalFrequency = Math.Sqrt(value);
+ }
+ }
+
+ public float DampingRatio
+ {
+ get => _dampingRatio;
+ set
+ {
+ if (value < 0f)
+ {
+ Preconditions.ThrowIllegalArgumentException("Damping ratio must be non-negative");
+ }
+
+ _dampingRatio = value;
+ }
+ }
+
+ public float GetAcceleration(float lastDisplacement, float lastVelocity)
+ {
+ var adjustedDisplacement = lastDisplacement - _finalPosition;
+
+ var stiffness = _naturalFrequency * _naturalFrequency;
+ var damping = 2.0 * _naturalFrequency * _dampingRatio;
+
+ return (float)(-stiffness * adjustedDisplacement - damping * lastVelocity);
+ }
+
+ internal Motion UpdateValues(float lastDisplacement, float lastVelocity, TimeSpan timeElapsed)
+ {
+ var adjustedDisplacement = lastDisplacement - _finalPosition;
+ var deltaT = timeElapsed.TotalSeconds;
+ var dampingRatioSquared = _dampingRatio * _dampingRatio;
+ var r = -_dampingRatio * _naturalFrequency;
+
+ double displacement;
+ double currentVelocity;
+
+ if (_dampingRatio > 1f)
+ {
+ var s = _naturalFrequency * Math.Sqrt(dampingRatioSquared - 1.0);
+ var gammaPlus = r + s;
+ var gammaMinus = r - s;
+
+ var coeffB = (gammaMinus * adjustedDisplacement - lastVelocity) / (gammaMinus - gammaPlus);
+ var coeffA = adjustedDisplacement - coeffB;
+ displacement = coeffA * Math.Exp(gammaMinus * deltaT) + coeffB * Math.Exp(gammaPlus * deltaT);
+ currentVelocity =
+ coeffA * gammaMinus * Math.Exp(gammaMinus * deltaT) +
+ coeffB * gammaPlus * Math.Exp(gammaPlus * deltaT);
+ }
+ else if (Math.Abs(_dampingRatio - 1f) < 1e-6f)
+ {
+ var coeffA = adjustedDisplacement;
+ var coeffB = lastVelocity + _naturalFrequency * adjustedDisplacement;
+ var nfdt = -_naturalFrequency * deltaT;
+ var exp = Math.Exp(nfdt);
+ displacement = (coeffA + coeffB * deltaT) * exp;
+ currentVelocity = ((coeffA + coeffB * deltaT) * exp * (-_naturalFrequency)) + coeffB * exp;
+ }
+ else
+ {
+ var dampedFrequency = _naturalFrequency * Math.Sqrt(1.0 - dampingRatioSquared);
+ var cosCoeff = adjustedDisplacement;
+ var sinCoeff = (1 / dampedFrequency) * (((-r * adjustedDisplacement) + lastVelocity));
+ var dfdT = dampedFrequency * deltaT;
+ var expTerm = Math.Exp(r * deltaT);
+ var cos = Math.Cos(dfdT);
+ var sin = Math.Sin(dfdT);
+ displacement = expTerm * (cosCoeff * cos + sinCoeff * sin);
+ currentVelocity =
+ displacement * r +
+ expTerm * ((-dampedFrequency * cosCoeff * sin) + (dampedFrequency * sinCoeff * cos));
+ }
+
+ var newValue = (float)(displacement + _finalPosition);
+ var newVelocity = (float)currentVelocity;
+ return new Motion(newValue, newVelocity);
+ }
+}
diff --git a/src/MaterialDesign3.Motion/TwoWayConverter.cs b/src/MaterialDesign3.Motion/TwoWayConverter.cs
new file mode 100644
index 0000000000..7445df92fe
--- /dev/null
+++ b/src/MaterialDesign3.Motion/TwoWayConverter.cs
@@ -0,0 +1,29 @@
+namespace MaterialDesignThemes.Motion;
+
+public static class TwoWayConverter
+{
+ public static ITwoWayConverter Create(Func convertToVector, Func convertFromVector)
+ where V : AnimationVector =>
+ new TwoWayConverterImpl(convertToVector ?? throw new ArgumentNullException(nameof(convertToVector)),
+ convertFromVector ?? throw new ArgumentNullException(nameof(convertFromVector)));
+
+ public static ITwoWayConverter Float { get; } =
+ Create(value => new AnimationVector1D(value), vector => vector.Value);
+
+ public static ITwoWayConverter Int { get; } =
+ Create(value => new AnimationVector1D(value), vector => (int)Math.Round(vector.Value));
+
+ private sealed class TwoWayConverterImpl : ITwoWayConverter
+ where V : AnimationVector
+ {
+ public TwoWayConverterImpl(Func convertToVector, Func convertFromVector)
+ {
+ ConvertToVector = convertToVector;
+ ConvertFromVector = convertFromVector;
+ }
+
+ public Func ConvertToVector { get; }
+
+ public Func ConvertFromVector { get; }
+ }
+}
diff --git a/src/MaterialDesign3.Motion/VectorConverters.cs b/src/MaterialDesign3.Motion/VectorConverters.cs
new file mode 100644
index 0000000000..7b5d75c73c
--- /dev/null
+++ b/src/MaterialDesign3.Motion/VectorConverters.cs
@@ -0,0 +1,7 @@
+namespace MaterialDesignThemes.Motion;
+
+internal static class VectorConverters
+{
+ public static float Lerp(float start, float stop, float fraction) =>
+ (start * (1f - fraction)) + (stop * fraction);
+}