From 55d469ede65a72bc251b8de7d68c466cf56544a7 Mon Sep 17 00:00:00 2001 From: Johann Dirry Date: Sat, 11 Oct 2025 19:47:44 +0200 Subject: [PATCH] starting to implement MD3 motion library --- Directory.packages.props | 1 + MaterialDesignToolkit.Full.sln | 21 +- .../AnimationEndReason.cs | 21 + .../AnimationParameters.cs | 33 ++ src/MaterialDesign3.Motion/AnimationSpec.cs | 20 + src/MaterialDesign3.Motion/AnimationVector.cs | 26 + .../AnimationVector1D.cs | 42 ++ .../AnimationVector2D.cs | 59 ++ .../AnimationVector3D.cs | 70 +++ .../AnimationVector4D.cs | 79 +++ .../AnimationVectorExtensions.cs | 52 ++ .../AnimationVectorFactory.cs | 12 + src/MaterialDesign3.Motion/ArcSpline.cs | 516 ++++++++++++++++++ .../CubicBezierEasing.cs | 27 + src/MaterialDesign3.Motion/Easing.cs | 11 + src/MaterialDesign3.Motion/Hermite.cs | 17 + src/MaterialDesign3.Motion/IMotionScheme.cs | 19 + .../ITwoWayConverter.cs | 14 + src/MaterialDesign3.Motion/MonoSpline.cs | 259 +++++++++ src/MaterialDesign3.Motion/Motion.cs | 3 + src/MaterialDesign3.Motion/Motion.csproj | 42 ++ src/MaterialDesign3.Motion/Motion.md | 127 +++++ .../MotionSchemeContext.cs | 15 + .../MotionSchemeExtensions.cs | 23 + .../MotionSchemeKeyTokenExtensions.cs | 12 + .../MotionSchemeKeyTokens.cs | 11 + src/MaterialDesign3.Motion/MotionSchemes.cs | 63 +++ src/MaterialDesign3.Motion/MotionTokens.cs | 32 ++ src/MaterialDesign3.Motion/Preconditions.cs | 44 ++ src/MaterialDesign3.Motion/RepeatMode.cs | 22 + src/MaterialDesign3.Motion/Repeatable.cs | 39 ++ src/MaterialDesign3.Motion/SpringConstants.cs | 57 ++ .../SpringEstimation.cs | 241 ++++++++ .../SpringMotionSpec.cs | 8 + .../SpringSimulation.cs | 109 ++++ src/MaterialDesign3.Motion/TwoWayConverter.cs | 29 + .../VectorConverters.cs | 7 + 37 files changed, 2182 insertions(+), 1 deletion(-) create mode 100644 src/MaterialDesign3.Motion/AnimationEndReason.cs create mode 100644 src/MaterialDesign3.Motion/AnimationParameters.cs create mode 100644 src/MaterialDesign3.Motion/AnimationSpec.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVector.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVector1D.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVector2D.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVector3D.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVector4D.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVectorExtensions.cs create mode 100644 src/MaterialDesign3.Motion/AnimationVectorFactory.cs create mode 100644 src/MaterialDesign3.Motion/ArcSpline.cs create mode 100644 src/MaterialDesign3.Motion/CubicBezierEasing.cs create mode 100644 src/MaterialDesign3.Motion/Easing.cs create mode 100644 src/MaterialDesign3.Motion/Hermite.cs create mode 100644 src/MaterialDesign3.Motion/IMotionScheme.cs create mode 100644 src/MaterialDesign3.Motion/ITwoWayConverter.cs create mode 100644 src/MaterialDesign3.Motion/MonoSpline.cs create mode 100644 src/MaterialDesign3.Motion/Motion.cs create mode 100644 src/MaterialDesign3.Motion/Motion.csproj create mode 100644 src/MaterialDesign3.Motion/Motion.md create mode 100644 src/MaterialDesign3.Motion/MotionSchemeContext.cs create mode 100644 src/MaterialDesign3.Motion/MotionSchemeExtensions.cs create mode 100644 src/MaterialDesign3.Motion/MotionSchemeKeyTokenExtensions.cs create mode 100644 src/MaterialDesign3.Motion/MotionSchemeKeyTokens.cs create mode 100644 src/MaterialDesign3.Motion/MotionSchemes.cs create mode 100644 src/MaterialDesign3.Motion/MotionTokens.cs create mode 100644 src/MaterialDesign3.Motion/Preconditions.cs create mode 100644 src/MaterialDesign3.Motion/RepeatMode.cs create mode 100644 src/MaterialDesign3.Motion/Repeatable.cs create mode 100644 src/MaterialDesign3.Motion/SpringConstants.cs create mode 100644 src/MaterialDesign3.Motion/SpringEstimation.cs create mode 100644 src/MaterialDesign3.Motion/SpringMotionSpec.cs create mode 100644 src/MaterialDesign3.Motion/SpringSimulation.cs create mode 100644 src/MaterialDesign3.Motion/TwoWayConverter.cs create mode 100644 src/MaterialDesign3.Motion/VectorConverters.cs 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); +}