diff --git a/Windows.Toolkit.Common.props b/Windows.Toolkit.Common.props index 20fe12883..83f9ae093 100644 --- a/Windows.Toolkit.Common.props +++ b/Windows.Toolkit.Common.props @@ -9,7 +9,7 @@ (c) .NET Foundation and Contributors. All rights reserved. https://github.com/CommunityToolkit/Labs-Windows https://github.com/CommunityToolkit/Labs-Windows/releases - Icon.png + https://raw.githubusercontent.com/CommunityToolkit/Labs-Windows/main/nuget.png $(NoWarn);NU1505;NU1504 diff --git a/components/TransitionHelper/OpenSolution.bat b/components/TransitionHelper/OpenSolution.bat new file mode 100644 index 000000000..814a56d4b --- /dev/null +++ b/components/TransitionHelper/OpenSolution.bat @@ -0,0 +1,3 @@ +@ECHO OFF + +powershell ..\..\tooling\ProjectHeads\GenerateSingleSampleHeads.ps1 -componentPath %CD% %* \ No newline at end of file diff --git a/components/TransitionHelper/samples/Assets/Owl2.jpg b/components/TransitionHelper/samples/Assets/Owl2.jpg new file mode 100644 index 000000000..1c8f39f70 Binary files /dev/null and b/components/TransitionHelper/samples/Assets/Owl2.jpg differ diff --git a/components/TransitionHelper/samples/CustomTextScalingCalculator.cs b/components/TransitionHelper/samples/CustomTextScalingCalculator.cs new file mode 100644 index 000000000..4be4d5bcd --- /dev/null +++ b/components/TransitionHelper/samples/CustomTextScalingCalculator.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using CommunityToolkit.Labs.WinUI; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI; +#else +using Microsoft.Toolkit.Uwp.UI; +#endif + +namespace TransitionHelperExperiment.Samples; + +public sealed class CustomTextScalingCalculator : IScalingCalculator +{ + /// + public Vector2 GetScaling(UIElement source, UIElement target) + { + var sourceTextElement = source?.FindDescendantOrSelf(); + var targetTextElement = target?.FindDescendantOrSelf(); + if (sourceTextElement is not null && targetTextElement is not null) + { + var scale = targetTextElement.FontSize / sourceTextElement.FontSize; + return new Vector2((float)scale); + } + + return new Vector2(1); + } +} diff --git a/components/TransitionHelper/samples/Dependencies.props b/components/TransitionHelper/samples/Dependencies.props new file mode 100644 index 000000000..0a825f228 --- /dev/null +++ b/components/TransitionHelper/samples/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TransitionHelper/samples/MultiTarget.props b/components/TransitionHelper/samples/MultiTarget.props new file mode 100644 index 000000000..da7a3d596 --- /dev/null +++ b/components/TransitionHelper/samples/MultiTarget.props @@ -0,0 +1,9 @@ + + + + uwp;wasdk + + diff --git a/components/TransitionHelper/samples/TransitionHelper.Samples.csproj b/components/TransitionHelper/samples/TransitionHelper.Samples.csproj new file mode 100644 index 000000000..8c74130f0 --- /dev/null +++ b/components/TransitionHelper/samples/TransitionHelper.Samples.csproj @@ -0,0 +1,14 @@ + + + TransitionHelper + + + + + PreserveNewest + + + + + + diff --git a/components/TransitionHelper/samples/TransitionHelper.md b/components/TransitionHelper/samples/TransitionHelper.md new file mode 100644 index 000000000..b16e9cd31 --- /dev/null +++ b/components/TransitionHelper/samples/TransitionHelper.md @@ -0,0 +1,21 @@ +--- +title: TransitionHelper +author: githubaccount +description: An animation helper that morphs between two controls. +keywords: TransitionHelper, Control, Layout +dev_langs: + - csharp +category: Controls +subcategory: Layout +discussion-id: 353 +issue-id: 0 +--- + +# TransitionHelper + +An animation helper that morphs between two controls. + +### Example + +> [!SAMPLE TransitionHelperFullExample] + diff --git a/components/TransitionHelper/samples/TransitionHelperFullExample.xaml b/components/TransitionHelper/samples/TransitionHelperFullExample.xaml new file mode 100644 index 000000000..32d914642 --- /dev/null +++ b/components/TransitionHelper/samples/TransitionHelperFullExample.xaml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Magic + Magic is a cute 😺. + + + + + + + + + + + + + + + Magic is my cat's name + + + Magic is a cute 😺, but sometimes very naughty. + + + + + + + + + + + + + + + + + diff --git a/components/TransitionHelper/samples/TransitionHelperFullExample.xaml.cs b/components/TransitionHelper/samples/TransitionHelperFullExample.xaml.cs new file mode 100644 index 000000000..7edacd892 --- /dev/null +++ b/components/TransitionHelper/samples/TransitionHelperFullExample.xaml.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TransitionHelperExperiment.Samples; + +/// +/// An empty page that can be used on its own or navigated to within a Frame. +/// +[ToolkitSample(id: nameof(TransitionHelperFullExample), "TransitionHelper Detailed Example", description: "A detailed example of how to use the TransitionHelper with multiple elements to transition between.")] +public sealed partial class TransitionHelperFullExample : Page +{ + public TransitionHelperFullExample() + { + this.InitializeComponent(); + } +} diff --git a/components/TransitionHelper/src/AdditionalAssemblyInfo.cs b/components/TransitionHelper/src/AdditionalAssemblyInfo.cs new file mode 100644 index 000000000..79bbf3e5d --- /dev/null +++ b/components/TransitionHelper/src/AdditionalAssemblyInfo.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +// These `InternalsVisibleTo` calls are intended to make it easier for +// for any internal code to be testable in all the different test projects +// used with the Labs infrastructure. +[assembly: InternalsVisibleTo("TransitionHelper.Tests.Uwp")] +[assembly: InternalsVisibleTo("TransitionHelper.Tests.WinAppSdk")] +[assembly: InternalsVisibleTo("CommunityToolkit.Labs.Tests.Uwp")] +[assembly: InternalsVisibleTo("CommunityToolkit.Labs.Tests.WinAppSdk")] diff --git a/components/TransitionHelper/src/Behaviors/ReverseTransitionAction.cs b/components/TransitionHelper/src/Behaviors/ReverseTransitionAction.cs new file mode 100644 index 000000000..2a42b6f79 --- /dev/null +++ b/components/TransitionHelper/src/Behaviors/ReverseTransitionAction.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Xaml.Interactivity; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI.Animations; +#else +using Microsoft.Toolkit.Uwp.UI.Animations; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// An implementation that can trigger a target instance. +/// +public sealed partial class ReverseTransitionAction : DependencyObject, IAction +{ + /// + /// Gets or sets the linked instance to reverse. + /// + public TransitionHelper Transition + { + get + { + return (TransitionHelper)this.GetValue(TransitionProperty); + } + + set + { + this.SetValue(TransitionProperty, value); + } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty TransitionProperty = DependencyProperty.Register( + nameof(Transition), + typeof(TransitionHelper), + typeof(StartTransitionAction), + new PropertyMetadata(null)); + + /// + public object Execute(object sender, object parameter) + { + if (this.Transition is null) + { + throw new ArgumentNullException(nameof(this.Transition)); + } + + _ = this.Transition.ReverseAsync(); + + return null!; + } +} diff --git a/components/TransitionHelper/src/Behaviors/StartTransitionAction.cs b/components/TransitionHelper/src/Behaviors/StartTransitionAction.cs new file mode 100644 index 000000000..fd4be2cea --- /dev/null +++ b/components/TransitionHelper/src/Behaviors/StartTransitionAction.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Xaml.Interactivity; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI.Animations; +#else +using Microsoft.Toolkit.Uwp.UI.Animations; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// An implementation that can trigger a target instance. +/// +public sealed partial class StartTransitionAction : DependencyObject, IAction +{ + /// + /// Gets or sets the linked instance to invoke. + /// + public TransitionHelper Transition + { + get + { + return (TransitionHelper)this.GetValue(TransitionProperty); + } + + set + { + this.SetValue(TransitionProperty, value); + } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty TransitionProperty = DependencyProperty.Register( + nameof(Transition), + typeof(TransitionHelper), + typeof(StartTransitionAction), + new PropertyMetadata(null)); + + /// + /// Gets or sets the source control of the . + /// + public FrameworkElement Source + { + get + { + return (FrameworkElement)this.GetValue(SourceProperty); + } + + set + { + this.SetValue(SourceProperty, value); + } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty SourceProperty = DependencyProperty.Register( + nameof(Source), + typeof(FrameworkElement), + typeof(StartTransitionAction), + new PropertyMetadata(null)); + + /// + /// Gets or sets the target control of the . + /// + public FrameworkElement Target + { + get + { + return (FrameworkElement)this.GetValue(TargetProperty); + } + + set + { + this.SetValue(TargetProperty, value); + } + } + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty TargetProperty = DependencyProperty.Register( + nameof(Target), + typeof(FrameworkElement), + typeof(StartTransitionAction), + new PropertyMetadata(null)); + + /// + public object Execute(object sender, object parameter) + { + if (this.Transition is null) + { + throw new ArgumentNullException(nameof(this.Transition)); + } + + if (this.Source is null) + { + throw new ArgumentNullException(nameof(this.Source)); + } + + if (this.Target is null) + { + throw new ArgumentNullException(nameof(this.Target)); + } + + this.Transition.Source = this.Source; + this.Transition.Target = this.Target; + _ = this.Transition.StartAsync(); + + return null!; + } +} diff --git a/components/TransitionHelper/src/CommunityToolkit.Labs.WinUI.TransitionHelper.csproj b/components/TransitionHelper/src/CommunityToolkit.Labs.WinUI.TransitionHelper.csproj new file mode 100644 index 000000000..21bdbf281 --- /dev/null +++ b/components/TransitionHelper/src/CommunityToolkit.Labs.WinUI.TransitionHelper.csproj @@ -0,0 +1,17 @@ + + + TransitionHelper + This package contains a TransitionHelper. + 0.0.1 + + + CommunityToolkit.Labs.WinUI.TransitionHelperRns + + + + $(NoWarn);CA1001 + + + + + diff --git a/components/TransitionHelper/src/Dependencies.props b/components/TransitionHelper/src/Dependencies.props new file mode 100644 index 000000000..9c7434348 --- /dev/null +++ b/components/TransitionHelper/src/Dependencies.props @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/TransitionHelper/src/Enums/ScaleMode.cs b/components/TransitionHelper/src/Enums/ScaleMode.cs new file mode 100644 index 000000000..b75729fa2 --- /dev/null +++ b/components/TransitionHelper/src/Enums/ScaleMode.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// Indicates the strategy when the scale property of a UI element is animated. +/// +public enum ScaleMode +{ + /// + /// Do not make any changes to the scale attribute of the UI element. + /// + None, + + /// + /// Apply the scaling changes to the horizontal and vertical directions of the UI element. + /// + Scale, + + /// + /// Apply the scaling changes to the horizontal and vertical directions of the UI element, + /// but the value is calculated based on the change in the horizontal direction. + /// + ScaleX, + + /// + /// Apply scaling changes to the horizontal and vertical directions of the UI element, + /// but the value is calculated based on the change in the vertical direction. + /// + ScaleY, + + /// + /// Apply the scaling changes calculated by using custom scaling calculator. + /// + Custom, +} diff --git a/components/TransitionHelper/src/Enums/VisualStateToggleMethod.cs b/components/TransitionHelper/src/Enums/VisualStateToggleMethod.cs new file mode 100644 index 000000000..ed37092c4 --- /dev/null +++ b/components/TransitionHelper/src/Enums/VisualStateToggleMethod.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// Indicates the method of changing the visibility of UI elements. +/// +public enum VisualStateToggleMethod +{ + /// + /// Change the visibility of UI elements by modifying the Visibility property. + /// + ByVisibility, + + /// + /// Change the visibility of UI elements by modifying the IsVisible property of it's Visual. + /// + ByIsVisible +} diff --git a/components/TransitionHelper/src/IScalingCalculator.cs b/components/TransitionHelper/src/IScalingCalculator.cs new file mode 100644 index 000000000..a11b2cd95 --- /dev/null +++ b/components/TransitionHelper/src/IScalingCalculator.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// Defines methods to support calculating scaling changes. +/// +public interface IScalingCalculator +{ + /// + /// Gets the scaling changes when the source element transitions to the target element. + /// + /// The source element. + /// The target element. + /// A whose X value represents the horizontal scaling change and whose Y represents the vertical scaling change. + Vector2 GetScaling(UIElement source, UIElement target); +} diff --git a/components/TransitionHelper/src/MultiTarget.props b/components/TransitionHelper/src/MultiTarget.props new file mode 100644 index 000000000..da7a3d596 --- /dev/null +++ b/components/TransitionHelper/src/MultiTarget.props @@ -0,0 +1,9 @@ + + + + uwp;wasdk + + diff --git a/components/TransitionHelper/src/Properties/IsExternalInit.cs b/components/TransitionHelper/src/Properties/IsExternalInit.cs new file mode 100644 index 000000000..0160c8ef0 --- /dev/null +++ b/components/TransitionHelper/src/Properties/IsExternalInit.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +/// +/// Reserved to be used by the compiler for tracking metadata. +/// This class should not be used by developers in source code. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit +{ +} diff --git a/components/TransitionHelper/src/Toolkit/AnimationExtensions.cs b/components/TransitionHelper/src/Toolkit/AnimationExtensions.cs new file mode 100644 index 000000000..5088d40b5 --- /dev/null +++ b/components/TransitionHelper/src/Toolkit/AnimationExtensions.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Xaml.Media.Animation; +#else +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Xaml.Media.Animation; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// Common properties related to extensions. (Copied from internal of Toolkit) +/// +internal static class AnimationExtensions +{ + /// + /// The reusable mapping of control points for easing curves for combinations of and values. + /// + internal static readonly Dictionary<(EasingType Type, EasingMode Mode), (Vector2 A, Vector2 B)> EasingMaps = new() + { + // The default/inout combination is missing, as in this case we just skip creating + // an easing function entirely, and rely on the composition APIs using the implicit + // easing automatically. This is a bit more efficient, and results in the same + // visual behavior anyway, as that's the standard combination for animations. + [(EasingType.Default, EasingMode.EaseOut)] = (new(0.1f, 0.9f), new(0.2f, 1.0f)), + [(EasingType.Default, EasingMode.EaseIn)] = (new(0.7f, 0.0f), new(1.0f, 0.5f)), + + [(EasingType.Cubic, EasingMode.EaseOut)] = (new(0.215f, 0.61f), new(0.355f, 1f)), + [(EasingType.Cubic, EasingMode.EaseIn)] = (new(0.55f, 0.055f), new(0.675f, 0.19f)), + [(EasingType.Cubic, EasingMode.EaseInOut)] = (new(0.645f, 0.045f), new(0.355f, 1f)), + + [(EasingType.Back, EasingMode.EaseOut)] = (new(0.175f, 0.885f), new(0.32f, 1.275f)), + [(EasingType.Back, EasingMode.EaseIn)] = (new(0.6f, -0.28f), new(0.735f, 0.045f)), + [(EasingType.Back, EasingMode.EaseInOut)] = (new(0.68f, -0.55f), new(0.265f, 1.55f)), + + [(EasingType.Bounce, EasingMode.EaseOut)] = (new(0.58f, 1.93f), new(.08f, .36f)), + [(EasingType.Bounce, EasingMode.EaseIn)] = (new(0.93f, 0.7f), new(0.4f, -0.93f)), + [(EasingType.Bounce, EasingMode.EaseInOut)] = (new(0.65f, -0.85f), new(0.35f, 1.85f)), + + [(EasingType.Elastic, EasingMode.EaseOut)] = (new(0.37f, 2.68f), new(0f, 0.22f)), + [(EasingType.Elastic, EasingMode.EaseIn)] = (new(1, .78f), new(.63f, -1.68f)), + [(EasingType.Elastic, EasingMode.EaseInOut)] = (new(0.9f, -1.2f), new(0.1f, 2.2f)), + + [(EasingType.Circle, EasingMode.EaseOut)] = (new(0.075f, 0.82f), new(0.165f, 1f)), + [(EasingType.Circle, EasingMode.EaseIn)] = (new(0.6f, 0.04f), new(0.98f, 0.335f)), + [(EasingType.Circle, EasingMode.EaseInOut)] = (new(0.785f, 0.135f), new(0.15f, 0.86f)), + + [(EasingType.Quadratic, EasingMode.EaseOut)] = (new(0.25f, 0.46f), new(0.45f, 0.94f)), + [(EasingType.Quadratic, EasingMode.EaseIn)] = (new(0.55f, 0.085f), new(0.68f, 0.53f)), + [(EasingType.Quadratic, EasingMode.EaseInOut)] = (new(0.445f, 0.03f), new(0.515f, 0.955f)), + + [(EasingType.Quartic, EasingMode.EaseOut)] = (new(0.165f, 0.84f), new(0.44f, 1f)), + [(EasingType.Quartic, EasingMode.EaseIn)] = (new(0.895f, 0.03f), new(0.685f, 0.22f)), + [(EasingType.Quartic, EasingMode.EaseInOut)] = (new(0.77f, 0.0f), new(0.175f, 1.0f)), + + [(EasingType.Quintic, EasingMode.EaseOut)] = (new(0.23f, 1f), new(0.32f, 1f)), + [(EasingType.Quintic, EasingMode.EaseIn)] = (new(0.755f, 0.05f), new(0.855f, 0.06f)), + [(EasingType.Quintic, EasingMode.EaseInOut)] = (new(0.86f, 0.0f), new(0.07f, 1.0f)), + + [(EasingType.Sine, EasingMode.EaseOut)] = (new(0.39f, 0.575f), new(0.565f, 1f)), + [(EasingType.Sine, EasingMode.EaseIn)] = (new(0.47f, 0.0f), new(0.745f, 0.715f)), + [(EasingType.Sine, EasingMode.EaseInOut)] = (new(0.445f, 0.05f), new(0.55f, 0.95f)) + }; +} diff --git a/components/TransitionHelper/src/TransitionConfig.cs b/components/TransitionHelper/src/TransitionConfig.cs new file mode 100644 index 000000000..4b2166841 --- /dev/null +++ b/components/TransitionHelper/src/TransitionConfig.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Xaml.Media.Animation; +#else +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Xaml.Media.Animation; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// Configuration used for the transition between UI elements. +/// +public class TransitionConfig +{ + /// + /// Gets or sets an id to indicate the target UI elements. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the scale strategy of the transition. + /// The default value is . + /// + public ScaleMode ScaleMode { get; set; } = ScaleMode.None; + + /// + /// Gets or sets the custom scale calculator. + /// Only works when is . + /// If this value is not set, the scale strategy will fall back to . + /// + public IScalingCalculator? CustomScalingCalculator { get; set; } = null; + + /// + /// Gets or sets a value indicating whether clip animations are enabled for the target UI elements. + /// + public bool EnableClipAnimation { get; set; } + + /// + /// Gets or sets the center point used to calculate the element's translation or scale when animating. + /// Value is normalized with respect to the size of the animated element. + /// For example, a value of (0.0, 0.5) means that this point is at the leftmost point of the element horizontally and the center of the element vertically. + /// The default value is (0, 0). + /// + public Point NormalizedCenterPoint { get; set; } = default; + + /// + /// Gets or sets the easing function type for the transition. + /// If this value is not set, it will fall back to the value in . + /// + public EasingType? EasingType { get; set; } = null; + + /// + /// Gets or sets the easing function mode for the transition. + /// If this value is not set, it will fall back to the value in . + /// + public EasingMode? EasingMode { get; set; } = null; + + /// + /// Gets or sets the key point of opacity transition. + /// The time the keyframe of opacity from 0 to 1 or from 1 to 0 should occur at, expressed as a percentage of the animation duration. The allowed values are from (0, 0) to (1, 1). + /// .X will be used in the animation of the normal direction. + /// .Y will be used in the animation of the reverse direction. + /// If this value is not set, it will fall back to the value in . + /// + public Point? OpacityTransitionProgressKey { get; set; } = null; +} diff --git a/components/TransitionHelper/src/TransitionHelper.Animation.cs b/components/TransitionHelper/src/TransitionHelper.Animation.cs new file mode 100644 index 000000000..56fcd27b2 --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.Animation.cs @@ -0,0 +1,446 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.Contracts; +using System.Numerics; +using System.Runtime.CompilerServices; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI; +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media.Animation; +using AnimationDirection = Microsoft.UI.Composition.AnimationDirection; +#else +using Microsoft.Toolkit.Uwp.UI; +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Composition; +using Windows.UI.Xaml.Hosting; +using Windows.UI.Xaml.Media.Animation; +using AnimationDirection = Windows.UI.Composition.AnimationDirection; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +public sealed partial class TransitionHelper +{ + private const string TranslationPropertyName = "Translation"; + private const string TranslationXYPropertyName = "Translation.XY"; + private const string ScaleXYPropertyName = "Scale.XY"; + + private interface IEasingFunctionFactory + { + CompositionEasingFunction? GetEasingFunction(Compositor compositor, bool inverse); + } + + private interface IKeyFrameCompositionAnimationFactory + { + KeyFrameAnimation GetAnimation(CompositionObject targetHint, bool reversed, bool useReversedKeyframes, bool inverseEasingFunction, out CompositionObject? target); + } + + private interface IKeyFrameAnimationGroupController + { + float? LastStopProgress { get; } + + AnimationDirection? CurrentDirection { get; } + + Task StartAsync(CancellationToken token, TimeSpan? duration); + + Task ReverseAsync(CancellationToken token, bool inverseEasingFunction, TimeSpan? duration); + + void AddAnimationFor(UIElement target, IKeyFrameCompositionAnimationFactory? factory); + + void AddAnimationGroupFor(UIElement target, IKeyFrameCompositionAnimationFactory?[] factories); + } + + private sealed record EasingFunctionFactory( + EasingType Type = EasingType.Default, + EasingMode Mode = EasingMode.EaseInOut, + bool Inverse = false) + : IEasingFunctionFactory + { + public CompositionEasingFunction? GetEasingFunction(Compositor compositor, bool inverse) + { + if (Type == EasingType.Linear) + { + return compositor.CreateLinearEasingFunction(); + } + + var inversed = Inverse ^ inverse; + if (Type == EasingType.Default && Mode == EasingMode.EaseInOut) + { + return inversed ? compositor.CreateCubicBezierEasingFunction(new(1f, 0.06f), new(0.59f, 0.48f)) : null; + } + + var (a, b) = AnimationExtensions.EasingMaps[(Type, Mode)]; + return inversed ? compositor.CreateCubicBezierEasingFunction(new(1 - b.X, 1 - b.Y), new(1 - a.X, 1 - a.Y)) : compositor.CreateCubicBezierEasingFunction(a, b); + } + } + + private sealed record KeyFrameAnimationFactory( + string Property, + T To, + T? From, + TimeSpan? Delay, + TimeSpan? Duration, + IEasingFunctionFactory? EasingFunctionFactory, + Dictionary? NormalizedKeyFrames, + Dictionary? ReversedNormalizedKeyFrames) + : IKeyFrameCompositionAnimationFactory + where T : unmanaged + { + public KeyFrameAnimation GetAnimation(CompositionObject targetHint, bool reversed, bool useReversedKeyframes, bool inverseEasingFunction, out CompositionObject? target) + { + target = null; + + var direction = reversed ? AnimationDirection.Reverse : AnimationDirection.Normal; + var keyFrames = (useReversedKeyframes && ReversedNormalizedKeyFrames is not null) ? ReversedNormalizedKeyFrames : NormalizedKeyFrames; + + if (typeof(T) == typeof(float)) + { + var scalarAnimation = targetHint.Compositor.CreateScalarKeyFrameAnimation( + Property, + CastTo(To), + CastToNullable(From), + Delay, + Duration, + EasingFunctionFactory?.GetEasingFunction(targetHint.Compositor, inverseEasingFunction), + direction: direction); + if (keyFrames?.Count > 0) + { + foreach (var item in keyFrames) + { + var (value, easingFunctionFactory) = item.Value; + scalarAnimation.InsertKeyFrame(item.Key, CastTo(value), easingFunctionFactory?.GetEasingFunction(targetHint.Compositor, inverseEasingFunction)); + } + } + + return scalarAnimation; + } + + if (typeof(T) == typeof(Vector2)) + { + var vector2Animation = targetHint.Compositor.CreateVector2KeyFrameAnimation( + Property, + CastTo(To), + CastToNullable(From), + Delay, + Duration, + EasingFunctionFactory?.GetEasingFunction(targetHint.Compositor, inverseEasingFunction), + direction: direction); + if (keyFrames?.Count > 0) + { + foreach (var item in keyFrames) + { + var (value, easingFunctionFactory) = item.Value; + vector2Animation.InsertKeyFrame(item.Key, CastTo(value), easingFunctionFactory?.GetEasingFunction(targetHint.Compositor, inverseEasingFunction)); + } + } + + return vector2Animation; + } + + throw new InvalidOperationException("Invalid animation type"); + } + + [Pure] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TValue CastTo(T value) + where TValue : unmanaged + { + return (TValue)(object)value; + } + + [Pure] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TValue? CastToNullable(T? value) + where TValue : unmanaged + { + if (value is null) + { + return null; + } + + T validValue = value.GetValueOrDefault(); + + return (TValue)(object)validValue; + } + } + + private sealed record ClipScalarAnimationFactory( + string Property, + float To, + float? From, + TimeSpan? Delay, + TimeSpan? Duration, + IEasingFunctionFactory? EasingFunctionFactory) + : IKeyFrameCompositionAnimationFactory + { + public KeyFrameAnimation GetAnimation(CompositionObject targetHint, bool reversed, bool useReversedKeyframes, bool inverseEasingFunction, out CompositionObject target) + { + var direction = reversed ? AnimationDirection.Reverse : AnimationDirection.Normal; + var visual = (Visual)targetHint; + var clip = visual.Clip as InsetClip ?? (InsetClip)(visual.Clip = visual.Compositor.CreateInsetClip()); + var easingFunction = EasingFunctionFactory?.GetEasingFunction(clip.Compositor, inverseEasingFunction); + var animation = clip.Compositor.CreateScalarKeyFrameAnimation( + Property, + To, + From, + Delay, + Duration, + easingFunction, + direction: direction); + + target = clip; + return animation; + } + } + + private IKeyFrameCompositionAnimationFactory[] Clip( + Thickness to, + IEasingFunctionFactory? easingFunctionFactory, + Thickness? from = null, + TimeSpan? delay = null, + TimeSpan? duration = null) + { + return new[] + { + new ClipScalarAnimationFactory( + nameof(InsetClip.LeftInset), + (float)to.Left, + (float?)from?.Left, + delay, + duration, + easingFunctionFactory), + new ClipScalarAnimationFactory( + nameof(InsetClip.TopInset), + (float)to.Top, + (float?)from?.Top, + delay, + duration, + easingFunctionFactory), + new ClipScalarAnimationFactory( + nameof(InsetClip.RightInset), + (float)to.Right, + (float?)from?.Right, + delay, + duration, + easingFunctionFactory), + new ClipScalarAnimationFactory( + nameof(InsetClip.BottomInset), + (float)to.Bottom, + (float?)from?.Bottom, + delay, + duration, + easingFunctionFactory) + }; + } + + private IKeyFrameCompositionAnimationFactory Translation( + Vector2 to, + IEasingFunctionFactory? easingFunctionFactory, + Vector2? from = null, + TimeSpan? delay = null, + TimeSpan? duration = null, + Dictionary? normalizedKeyFrames = null, + Dictionary? reversedNormalizedKeyFrames = null) + { + return new KeyFrameAnimationFactory(TranslationXYPropertyName, to, from, delay, duration, easingFunctionFactory, normalizedKeyFrames, reversedNormalizedKeyFrames); + } + + private IKeyFrameCompositionAnimationFactory Opacity( + double to, + IEasingFunctionFactory? easingFunctionFactory, + double? from = null, + TimeSpan? delay = null, + TimeSpan? duration = null, + Dictionary? normalizedKeyFrames = null, + Dictionary? reversedNormalizedKeyFrames = null) + { + return new KeyFrameAnimationFactory(nameof(Visual.Opacity), (float)to, (float?)from, delay, duration, easingFunctionFactory, normalizedKeyFrames, reversedNormalizedKeyFrames); + } + + private IKeyFrameCompositionAnimationFactory Scale( + Vector2 to, + IEasingFunctionFactory? easingFunctionFactory, + Vector2? from = null, + TimeSpan? delay = null, + TimeSpan? duration = null, + Dictionary? normalizedKeyFrames = null, + Dictionary? reversedNormalizedKeyFrames = null) + { + return new KeyFrameAnimationFactory(ScaleXYPropertyName, to, from, delay, duration, easingFunctionFactory, normalizedKeyFrames, reversedNormalizedKeyFrames); + } + + private sealed class KeyFrameAnimationGroupController : IKeyFrameAnimationGroupController + { + private readonly Dictionary> animationFactories = new(); + + public float? LastStopProgress { get; private set; } = null; + + public AnimationDirection? CurrentDirection { get; private set; } = null; + + private bool _lastInverseEasingFunction = false; + + private bool _lastStartInNormalDirection = true; + + public void AddAnimationFor(UIElement target, IKeyFrameCompositionAnimationFactory? factory) + { + if (factory is null) + { + return; + } + + if (animationFactories.ContainsKey(target)) + { + animationFactories[target].Add(factory); + } + else + { + animationFactories.Add(target, new List() { factory }); + } + } + + public void AddAnimationGroupFor(UIElement target, IKeyFrameCompositionAnimationFactory?[] factories) + { + var validFactories = factories.Where(factory => factory is not null); + if (validFactories.Any() is false) + { + return; + } + + if (animationFactories.ContainsKey(target)) + { + animationFactories[target].AddRange(validFactories!); + } + else + { + animationFactories.Add(target, new List(validFactories!)); + } + } + + public Task StartAsync(CancellationToken token, TimeSpan? duration) + { + var start = this.LastStopProgress; + var isInterruptedAnimation = start.HasValue; + if (isInterruptedAnimation is false) + { + this._lastStartInNormalDirection = true; + } + + var inverseEasing = isInterruptedAnimation && this._lastInverseEasingFunction; + var useReversedKeyframes = isInterruptedAnimation && !this._lastStartInNormalDirection; + return AnimateAsync(false, useReversedKeyframes, token, inverseEasing, duration, start); + } + + public Task ReverseAsync(CancellationToken token, bool inverseEasingFunction, TimeSpan? duration) + { + float? start = null; + if (this.LastStopProgress.HasValue) + { + start = 1 - this.LastStopProgress.Value; + } + + var isInterruptedAnimation = start.HasValue; + if (isInterruptedAnimation is false) + { + this._lastStartInNormalDirection = false; + } + + var inverseEasing = (isInterruptedAnimation && this._lastInverseEasingFunction) || (!isInterruptedAnimation && inverseEasingFunction); + var useReversedKeyframes = !isInterruptedAnimation || !this._lastStartInNormalDirection; + return AnimateAsync(true, useReversedKeyframes, token, inverseEasing, duration, start); + } + + private Task AnimateAsync(bool reversed, bool useReversedKeyframes, CancellationToken token, bool inverseEasingFunction, TimeSpan? duration, float? startProgress) + { + List? tasks = null; + List<(CompositionObject Target, string Path)>? compositionAnimations = null; + DateTime? animationStartTime = null; + this.LastStopProgress = null; + this.CurrentDirection = reversed ? AnimationDirection.Reverse : AnimationDirection.Normal; + this._lastInverseEasingFunction = inverseEasingFunction; + if (this.animationFactories.Count > 0) + { + if (duration.HasValue) + { + var elapsedDuration = duration.Value * (startProgress ?? 0d); + animationStartTime = DateTime.Now - elapsedDuration; + } + + tasks = new List(this.animationFactories.Count); + compositionAnimations = new List<(CompositionObject Target, string Path)>(); + foreach (var item in this.animationFactories) + { + tasks.Add(StartForAsync(item.Key, reversed, useReversedKeyframes, inverseEasingFunction, duration, startProgress, compositionAnimations)); + } + } + + static void Stop(object state) + { + var (controller, reversed, duration, animationStartTime, animations) = ((KeyFrameAnimationGroupController, bool, TimeSpan?, DateTime?, List<(CompositionObject Target, string Path)>))state; + foreach (var (target, path) in animations) + { + target.StopAnimation(path); + } + + if (duration.HasValue is false || animationStartTime.HasValue is false) + { + return; + } + + var stopProgress = Math.Max(0, Math.Min((DateTime.Now - animationStartTime.Value) / duration.Value, 1)); + controller.LastStopProgress = (float)(reversed ? 1 - stopProgress : stopProgress); + } + + if (compositionAnimations is not null) + { + token.Register(static obj => Stop(obj!), (this, reversed, duration, animationStartTime, compositionAnimations)); + } + + return tasks is null ? Task.CompletedTask : Task.WhenAll(tasks); + } + + private Task StartForAsync(UIElement element, bool reversed, bool useReversedKeyframes, bool inverseEasingFunction, TimeSpan? duration, float? startProgress, List<(CompositionObject Target, string Path)> animations) + { + if (!this.animationFactories.TryGetValue(element, out var factories) || factories.Count > 0 is false) + { + return Task.CompletedTask; + } + + ElementCompositionPreview.SetIsTranslationEnabled(element, true); + var visual = element.GetVisual(); + var batch = visual.Compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + var taskCompletionSource = new TaskCompletionSource(); + batch.Completed += (_, _) => taskCompletionSource.SetResult(null); + foreach (var factory in factories) + { + var animation = factory.GetAnimation(visual, reversed, useReversedKeyframes, inverseEasingFunction, out var target); + if (duration.HasValue) + { + animation.Duration = duration.Value; + } + + (target ?? visual).StartAnimation(animation.Target, animation); + if (startProgress.HasValue) + { + var controller = (target ?? visual).TryGetAnimationController(animation.Target); + if (controller is not null) + { + controller.Progress = startProgress.Value; + } + } + + animations.Add((target ?? visual, animation.Target)); + } + + batch.End(); + return taskCompletionSource.Task; + } + } +} diff --git a/components/TransitionHelper/src/TransitionHelper.AttachedProperty.cs b/components/TransitionHelper/src/TransitionHelper.AttachedProperty.cs new file mode 100644 index 000000000..374aadc18 --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.AttachedProperty.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +public sealed partial class TransitionHelper +{ + /// + /// Get the animation id of the UI element. + /// + /// The animation id of the UI element + public static string GetId(DependencyObject obj) + { + return (string)obj.GetValue(IdProperty); + } + + /// + /// Set the animation id of the UI element. + /// + public static void SetId(DependencyObject obj, string value) + { + obj.SetValue(IdProperty, value); + } + + /// + /// Id is used to mark the animation id of UI elements. + /// Two elements of the same id on different controls will be connected by animation. + /// + public static readonly DependencyProperty IdProperty = + DependencyProperty.RegisterAttached("Id", typeof(string), typeof(TransitionHelper), null); + + /// + /// Get the value indicating whether the UI element is animated independently. + /// + /// A bool value indicating whether the UI element needs to be connected by animation. + public static bool GetIsIndependent(DependencyObject obj) + { + return (bool)obj.GetValue(IsIndependentProperty); + } + + /// + /// Set the value indicating whether the UI element is animated independently. + /// + public static void SetIsIndependent(DependencyObject obj, bool value) + { + obj.SetValue(IsIndependentProperty, value); + } + + /// + /// IsIndependent is used to mark controls that do not need to be connected by animation, it will disappear/show independently. + /// + public static readonly DependencyProperty IsIndependentProperty = + DependencyProperty.RegisterAttached("IsIndependent", typeof(bool), typeof(TransitionHelper), new PropertyMetadata(false)); + + /// + /// Get the translation used by the show or hide animation for independent or unpaired UI elements. + /// + /// A bool value indicating whether the UI element needs to be connected by animation. + public static Point? GetIndependentTranslation(DependencyObject obj) + { + return (Point?)obj.GetValue(IndependentTranslationProperty); + } + + /// + /// Set the translation used by the show or hide animation for independent or unpaired UI elements. + /// + public static void SetIndependentTranslation(DependencyObject obj, Point? value) + { + obj.SetValue(IndependentTranslationProperty, value); + } + + /// + /// IsIndependent is used by the show or hide animation for independent or unpaired UI elements. + /// + public static readonly DependencyProperty IndependentTranslationProperty = + DependencyProperty.RegisterAttached("IndependentTranslation", typeof(Point?), typeof(TransitionHelper), null); + + /// + /// Get the target animation id for coordinated animation of the UI element. + /// + /// The target animation id for coordinated animation of the UI element. + public static string GetCoordinatedTarget(DependencyObject obj) + { + return (string)obj.GetValue(CoordinatedTargetProperty); + } + + /// + /// Set the target animation id for coordinated animation of the UI element. + /// + public static void SetCoordinatedTarget(DependencyObject obj, string value) + { + obj.SetValue(CoordinatedTargetProperty, value); + } + + /// + /// CoordinatedTarget is used to mark the target animation id of coordinated animation. + /// These elements that use coordinated animation will travel alongside the target UI element. + /// + public static readonly DependencyProperty CoordinatedTargetProperty = + DependencyProperty.RegisterAttached("CoordinatedTarget", typeof(string), typeof(TransitionHelper), null); +} diff --git a/components/TransitionHelper/src/TransitionHelper.Helpers.cs b/components/TransitionHelper/src/TransitionHelper.Helpers.cs new file mode 100644 index 000000000..cc79fc2f0 --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.Helpers.cs @@ -0,0 +1,276 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI; +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media.Animation; +#else +using Microsoft.Toolkit.Uwp.UI; +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Composition; +using Windows.UI.Xaml.Hosting; +using Windows.UI.Xaml.Media.Animation; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +public sealed partial class TransitionHelper +{ + private sealed class AnimatedElementComparer : IEqualityComparer + { + public bool Equals(DependencyObject? x, DependencyObject? y) + { + if (x == null || y == null) + { + return false; + } + + if (GetIsIndependent(x) || GetIsIndependent(y)) + { + return false; + } + + return GetId(x) is { } xId && GetId(y) is { } yId && xId.Equals(yId); + } + + public int GetHashCode(DependencyObject obj) + { + return 0; + } + } + + private static AnimatedElements GetAnimatedElements(DependencyObject? parent) + { + var animatedElements = new AnimatedElements( + new Dictionary(), + new Dictionary>(), + new List()); + if (parent is null) + { + return animatedElements; + } + + var allAnimatedElements = FindDescendantsWithBFSAndPruneAndPredicate(parent, IsNotVisible, IsAnimatedElement) + .Distinct(new AnimatedElementComparer()) + .OfType(); + foreach (var item in allAnimatedElements) + { + if (GetId(item) is { } id) + { + animatedElements.ConnectedElements[id] = item; + } + else if (GetCoordinatedTarget(item) is { } targetId) + { + if (animatedElements.CoordinatedElements.ContainsKey(targetId) is false) + { + animatedElements.CoordinatedElements[targetId] = new List { item }; + } + else + { + animatedElements.CoordinatedElements[targetId].Add(item); + } + } + else + { + animatedElements.IndependentElements.Add(item); + } + } + + return animatedElements; + } + + private static bool IsNotVisible(DependencyObject element) + { + if (element is not UIElement target + || target.Visibility == Visibility.Collapsed + || target.Opacity < AlmostZero) + { + return true; + } + + return false; + } + + private static bool IsAnimatedElement(DependencyObject element) + { + return GetId(element) is not null || GetCoordinatedTarget(element) is not null || GetIsIndependent(element); + } + + private static IEnumerable FindDescendantsWithBFSAndPruneAndPredicate(DependencyObject element, Func prune, Func predicate) + { + if (predicate(element)) + { + yield return element; + yield break; + } + + var searchQueue = new Queue(); + var childrenCount = VisualTreeHelper.GetChildrenCount(element); + for (var i = 0; i < childrenCount; i++) + { + var child = VisualTreeHelper.GetChild(element, i); + if (predicate(child)) + { + yield return child; + } + else if (prune(child) is false) + { + searchQueue.Enqueue(child); + } + } + + while (searchQueue.Count > 0) + { + var parent = searchQueue.Dequeue(); + childrenCount = VisualTreeHelper.GetChildrenCount(parent); + for (var j = 0; j < childrenCount; j++) + { + var child = VisualTreeHelper.GetChild(parent, j); + if (predicate(child)) + { + yield return child; + } + else if (prune(child) is false) + { + searchQueue.Enqueue(child); + } + } + } + } + + private static void ToggleVisualState(UIElement target, VisualStateToggleMethod method, bool isVisible) + { + if (target is null) + { + return; + } + + switch (method) + { + case VisualStateToggleMethod.ByVisibility: + target.Visibility = isVisible ? Visibility.Visible : Visibility.Collapsed; + break; + case VisualStateToggleMethod.ByIsVisible: + target.GetVisual().IsVisible = isVisible; + break; + } + + target.IsHitTestVisible = isVisible; + } + + private static void RestoreElements(IEnumerable animatedElements) + { + foreach (var animatedElement in animatedElements) + { + RestoreElement(animatedElement); + } + } + + private static void RestoreElement(UIElement animatedElement) + { + ElementCompositionPreview.SetIsTranslationEnabled(animatedElement, true); + var visual = animatedElement.GetVisual(); + visual.StopAnimation(nameof(Visual.Opacity)); + visual.StopAnimation(TranslationXYPropertyName); + visual.StopAnimation(ScaleXYPropertyName); + if (visual.Clip is InsetClip clip) + { + clip.StopAnimation(nameof(InsetClip.LeftInset)); + clip.StopAnimation(nameof(InsetClip.TopInset)); + clip.StopAnimation(nameof(InsetClip.RightInset)); + clip.StopAnimation(nameof(InsetClip.BottomInset)); + } + + visual.Opacity = 1; + visual.Scale = Vector3.One; + visual.Clip = null; + visual.Properties.InsertVector3(TranslationPropertyName, Vector3.Zero); + } + + private static void IsNotNullAndIsInVisualTree(FrameworkElement? target, string name) + { + if (target is null) + { + throw new ArgumentNullException(name); + } + + if (VisualTreeHelper.GetParent(target) is null) + { + throw new ArgumentException($"The {name} element is not in the visual tree.", name); + } + } + + private static Task UpdateControlLayout(FrameworkElement target) + { + var updateTargetLayoutTaskSource = new TaskCompletionSource(); + void OnTargetLayoutUpdated(object? sender, object e) + { + target.LayoutUpdated -= OnTargetLayoutUpdated; + _ = updateTargetLayoutTaskSource.TrySetResult(null); + } + + target.LayoutUpdated += OnTargetLayoutUpdated; + target.UpdateLayout(); + return updateTargetLayoutTaskSource.Task; + } + + private static Vector2 GetInverseScale(Vector2 scale) => new(1 / scale.X, 1 / scale.Y); + + private static Thickness GetFixedThickness(double left, double top, double right, double bottom, double defaultValue = 0) + { + var fixedLeft = left < AlmostZero ? defaultValue : left; + var fixedTop = top < AlmostZero ? defaultValue : top; + var fixedRight = right < AlmostZero ? defaultValue : right; + var fixedBottom = bottom < AlmostZero ? defaultValue : bottom; + return new Thickness(fixedLeft, fixedTop, fixedRight, fixedBottom); + } + + private static Rect GetTransformedBounds(Vector2 initialLocation, Vector2 initialSize, Vector2 centerPoint, Vector2 targetScale) + { + var targetMatrix3x2 = Matrix3x2.CreateScale(targetScale, centerPoint); + return new Rect((initialLocation + Vector2.Transform(default, targetMatrix3x2)).ToPoint(), (initialSize * targetScale).ToSize()); + } + + private static Thickness? GetTargetClip(Vector2 initialLocation, Vector2 initialSize, Vector2 centerPoint, Vector2 targetScale, Vector2 translation, Rect targetParentBounds) + { + var transformedBounds = GetTransformedBounds(initialLocation + translation, initialSize, centerPoint, targetScale); + var inverseScale = GetInverseScale(targetScale); + if (targetParentBounds.Contains(new Point(transformedBounds.Left, transformedBounds.Top)) && targetParentBounds.Contains(new Point(transformedBounds.Right, transformedBounds.Bottom))) + { + return null; + } + + return GetFixedThickness( + (targetParentBounds.X - transformedBounds.X) * inverseScale.X, + (targetParentBounds.Y - transformedBounds.Y) * inverseScale.Y, + (transformedBounds.Right - targetParentBounds.Right) * inverseScale.X, + (transformedBounds.Bottom - targetParentBounds.Bottom) * inverseScale.X); + } + + private static readonly Dictionary<(EasingType, EasingMode, bool), IEasingFunctionFactory> EasingFunctionFactoryCache = new(); + + private static IEasingFunctionFactory GetEasingFunctionFactory(EasingType type = EasingType.Default, EasingMode mode = EasingMode.EaseInOut, bool inverse = false) + { + if (EasingFunctionFactoryCache.TryGetValue((type, mode, inverse), out var easingFunctionFactory)) + { + return easingFunctionFactory; + } + + var factory = new EasingFunctionFactory(type, mode, inverse); + EasingFunctionFactoryCache[(type, mode, inverse)] = factory; + return factory; + } + + private static float GetOpacityTransitionStartKey(float normalizedKey, float halfTransitionNormalizedDuration = 0.1f) => Math.Clamp(normalizedKey - halfTransitionNormalizedDuration, 0, 1); + + private static float GetOpacityTransitionEndKey(float normalizedKey, float halfTransitionNormalizedDuration = 0.1f) => Math.Clamp(normalizedKey + halfTransitionNormalizedDuration, 0, 1); +} diff --git a/components/TransitionHelper/src/TransitionHelper.Logic.cs b/components/TransitionHelper/src/TransitionHelper.Logic.cs new file mode 100644 index 000000000..c2b87d728 --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.Logic.cs @@ -0,0 +1,574 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI; +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Xaml.Media.Animation; +using AnimationDirection = Microsoft.UI.Composition.AnimationDirection; +#else +using Microsoft.Toolkit.Uwp.UI; +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Xaml.Media.Animation; +using AnimationDirection = Windows.UI.Composition.AnimationDirection; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +public sealed partial class TransitionHelper +{ + private void RestoreState(bool isTargetState) + { + this.IsTargetState = isTargetState; + if (this.Source is not null) + { + Canvas.SetZIndex(this.Source, _sourceZIndex); + ToggleVisualState(this.Source, this.SourceToggleMethod, !isTargetState); + RestoreElements(this.SourceAnimatedElements.All()); + } + + if (this.Target is not null) + { + Canvas.SetZIndex(this.Target, _targetZIndex); + ToggleVisualState(this.Target, this.TargetToggleMethod, isTargetState); + RestoreElements(this.TargetAnimatedElements.All()); + } + } + + private async Task InitControlsStateAsync(bool forceUpdateAnimatedElements = false) + { + var maxZIndex = Math.Max(_sourceZIndex, _targetZIndex) + 1; + Canvas.SetZIndex(this.IsTargetState ? this.Source : this.Target, maxZIndex); + + await Task.WhenAll( + this.InitControlStateAsync(this.Source), + this.InitControlStateAsync(this.Target)); + + if (forceUpdateAnimatedElements) + { + _sourceAnimatedElements = null; + _targetAnimatedElements = null; + } + } + + private async Task InitControlStateAsync(FrameworkElement? target) + { + if (target is null) + { + return; + } + + target.IsHitTestVisible = IsHitTestVisibleWhenAnimating; + if (target.Visibility == Visibility.Collapsed) + { + target.Visibility = Visibility.Visible; + await UpdateControlLayout(target); + } + else if (target.Opacity < AlmostZero) + { + target.Opacity = 1; + } + else if (target.GetVisual() is { IsVisible: false } visual) + { + visual.IsVisible = true; + } + } + + private async Task AnimateControlsAsync(bool reversed, CancellationToken token, bool forceUpdateAnimatedElements) + { + IsNotNullAndIsInVisualTree(this.Source, nameof(this.Source)); + IsNotNullAndIsInVisualTree(this.Target, nameof(this.Target)); + if (IsAnimating) + { + if ((_currentAnimationGroupController?.CurrentDirection is AnimationDirection.Reverse) == reversed) + { + return; + } + else + { + this.Stop(); + } + } + else if (this.IsTargetState == !reversed) + { + return; + } + else + { + this._currentAnimationGroupController = null; + await this.InitControlsStateAsync(forceUpdateAnimatedElements); + } + + this._currentAnimationCancellationTokenSource = new CancellationTokenSource(); + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token, this._currentAnimationCancellationTokenSource.Token); + await this.AnimateControlsImpAsync(reversed ? this.ReverseDuration : this.Duration, reversed, linkedTokenSource.Token); + if (linkedTokenSource.Token.IsCancellationRequested) + { + return; + } + + this._currentAnimationGroupController = null; + this.RestoreState(!reversed); + } + + private Task AnimateControlsImpAsync(TimeSpan duration, bool reversed, CancellationToken token) + { + var sourceUnpairedElements = this.SourceAnimatedElements.ConnectedElements + .Where(item => !this.TargetAnimatedElements.ConnectedElements.ContainsKey(item.Key)) + .SelectMany(item => + { + var result = new[] { item.Value }; + if (this.SourceAnimatedElements.CoordinatedElements.TryGetValue(item.Key, out var coordinatedElements)) + { + return result.Concat(coordinatedElements); + } + + return result; + }); + var targetUnpairedElements = this.TargetAnimatedElements.ConnectedElements + .Where(item => !this.SourceAnimatedElements.ConnectedElements.ContainsKey(item.Key)) + .SelectMany(item => + { + var result = new[] { item.Value }; + if (this.TargetAnimatedElements.CoordinatedElements.TryGetValue(item.Key, out var coordinatedElements)) + { + return result.Concat(coordinatedElements); + } + + return result; + }); + var pairedElementKeys = this.SourceAnimatedElements.ConnectedElements + .Where(item => this.TargetAnimatedElements.ConnectedElements.ContainsKey(item.Key)) + .Select(item => item.Key); + if (_currentAnimationGroupController is null) + { + _currentAnimationGroupController = new KeyFrameAnimationGroupController(); + foreach (var key in pairedElementKeys) + { + var source = this.SourceAnimatedElements.ConnectedElements[key]; + var target = this.TargetAnimatedElements.ConnectedElements[key]; + var animationConfig = this.Configs.FirstOrDefault(config => config.Id == key) ?? + this.DefaultConfig; + this.SourceAnimatedElements.CoordinatedElements.TryGetValue(key, out var sourceAttachedElements); + this.TargetAnimatedElements.CoordinatedElements.TryGetValue(key, out var targetAttachedElements); + this.BuildConnectedAnimationController( + _currentAnimationGroupController, + source, + target, + sourceAttachedElements, + targetAttachedElements, + duration, + animationConfig); + } + } + + TimeSpan? startTime = null; + if (_currentAnimationGroupController.LastStopProgress.HasValue) + { + var startProgress = reversed ? (1 - _currentAnimationGroupController.LastStopProgress.Value) : _currentAnimationGroupController.LastStopProgress.Value; + startTime = startProgress * duration; + } + + var animationTasks = new[] + { + reversed + ? _currentAnimationGroupController.ReverseAsync(token, this.InverseEasingFunctionWhenReversing, duration) + : _currentAnimationGroupController.StartAsync(token, duration), + + this.AnimateIndependentElements( + this.SourceAnimatedElements.IndependentElements.Concat(sourceUnpairedElements), + reversed, + token, + startTime, + IndependentElementEasingType, + IndependentElementEasingMode), + this.AnimateIndependentElements( + this.TargetAnimatedElements.IndependentElements.Concat(targetUnpairedElements), + !reversed, + token, + startTime, + IndependentElementEasingType, + IndependentElementEasingMode) + }; + + return Task.WhenAll(animationTasks); + } + + private void BuildConnectedAnimationController( + IKeyFrameAnimationGroupController controller, + UIElement source, + UIElement target, + IList? sourceAttachedElements, + IList? targetAttachedElements, + TimeSpan duration, + TransitionConfig config) + { + var sourceActualSize = source is FrameworkElement sourceElement ? new Vector2((float)sourceElement.ActualWidth, (float)sourceElement.ActualHeight) : source.ActualSize; + var targetActualSize = target is FrameworkElement targetElement ? new Vector2((float)targetElement.ActualWidth, (float)targetElement.ActualHeight) : target.ActualSize; + var sourceCenterPoint = sourceActualSize * config.NormalizedCenterPoint.ToVector2(); + var targetCenterPoint = targetActualSize * config.NormalizedCenterPoint.ToVector2(); + + source.GetVisual().CenterPoint = new Vector3(sourceCenterPoint, 0); + target.GetVisual().CenterPoint = new Vector3(targetCenterPoint, 0); + var easingType = config.EasingType ?? this.DefaultEasingType; + var easingMode = config.EasingMode ?? this.DefaultEasingMode; + var (sourceTranslationAnimation, targetTranslationAnimation, sourceTargetTranslation) = this.AnimateTranslation( + source, + target, + sourceCenterPoint, + targetCenterPoint, + duration, + easingType, + easingMode); + var (sourceOpacityAnimation, targetOpacityAnimation) = this.AnimateOpacity(duration, config.OpacityTransitionProgressKey ?? this.DefaultOpacityTransitionProgressKey); + var (sourceScaleAnimation, targetScaleAnimation, sourceTargetScale) = config.ScaleMode switch + { + ScaleMode.None => (null, null, Vector2.One), + ScaleMode.Scale => this.AnimateScale( + sourceActualSize, + targetActualSize, + duration, + easingType, + easingMode), + ScaleMode.ScaleX => this.AnimateScaleX( + sourceActualSize, + targetActualSize, + duration, + easingType, + easingMode), + ScaleMode.ScaleY => this.AnimateScaleY( + sourceActualSize, + targetActualSize, + duration, + easingType, + easingMode), + ScaleMode.Custom => this.AnimateScaleWithScaleCalculator( + source, + target, + config.CustomScalingCalculator, + duration, + easingType, + easingMode), + _ => (null, null, Vector2.One), + }; + + controller.AddAnimationGroupFor( + source, + new[] + { + sourceTranslationAnimation, + sourceOpacityAnimation, + sourceScaleAnimation + }); + controller.AddAnimationGroupFor( + target, + new[] + { + targetTranslationAnimation, + targetOpacityAnimation, + targetScaleAnimation + }); + if (sourceAttachedElements?.Count > 0 && this.Target != null) + { + var targetControlBounds = new Rect(0, 0, this.Target.ActualWidth, this.Target.ActualHeight); + var targetTransformedBounds = this.Target.TransformToVisual(this.Source).TransformBounds(targetControlBounds); + var targetScale = sourceTargetScale; + foreach (var coordinatedElement in sourceAttachedElements) + { + var coordinatedElementActualSize = coordinatedElement is FrameworkElement coordinatedFrameworkElement ? new Vector2((float)coordinatedFrameworkElement.ActualWidth, (float)coordinatedFrameworkElement.ActualHeight) : coordinatedElement.ActualSize; + var coordinatedElementCenterPoint = coordinatedElementActualSize * config.NormalizedCenterPoint.ToVector2(); + coordinatedElement.GetVisual().CenterPoint = new Vector3(coordinatedElementCenterPoint, 0); + controller.AddAnimationGroupFor( + coordinatedElement, + new[] + { + sourceTranslationAnimation, + sourceOpacityAnimation, + sourceScaleAnimation + }); + var initialLocation = coordinatedElement.TransformToVisual(this.Source).TransformPoint(default).ToVector2(); + var targetClip = GetTargetClip(initialLocation, coordinatedElementActualSize, coordinatedElementCenterPoint, targetScale, sourceTargetTranslation, targetTransformedBounds); + if (targetClip.HasValue) + { + controller.AddAnimationGroupFor( + coordinatedElement, + this.Clip( + targetClip.Value, + GetEasingFunctionFactory(easingType, easingMode), + duration: duration)); + } + } + } + + if (targetAttachedElements?.Count > 0 && this.Source != null) + { + var sourceControlBounds = new Rect(0, 0, this.Source.ActualWidth, this.Source.ActualHeight); + var sourceTransformedBounds = this.Source.TransformToVisual(this.Target).TransformBounds(sourceControlBounds); + var targetScale = GetInverseScale(sourceTargetScale); + foreach (var coordinatedElement in targetAttachedElements) + { + var coordinatedElementActualSize = coordinatedElement is FrameworkElement coordinatedFrameworkElement ? new Vector2((float)coordinatedFrameworkElement.ActualWidth, (float)coordinatedFrameworkElement.ActualHeight) : coordinatedElement.ActualSize; + var coordinatedElementCenterPoint = coordinatedElementActualSize * config.NormalizedCenterPoint.ToVector2(); + coordinatedElement.GetVisual().CenterPoint = new Vector3(coordinatedElementCenterPoint, 0); + controller.AddAnimationGroupFor( + coordinatedElement, + new[] + { + targetTranslationAnimation, + targetOpacityAnimation, + targetScaleAnimation + }); + var initialLocation = coordinatedElement.TransformToVisual(this.Target).TransformPoint(default).ToVector2(); + var targetClip = GetTargetClip(initialLocation, coordinatedElementActualSize, coordinatedElementCenterPoint, targetScale, -sourceTargetTranslation, sourceTransformedBounds); + if (targetClip.HasValue) + { + controller.AddAnimationGroupFor( + coordinatedElement, + this.Clip( + default, + GetEasingFunctionFactory(easingType, easingMode), + from: targetClip.Value, + duration: duration)); + } + } + } + + if (config.EnableClipAnimation) + { + var (sourceClipAnimationGroup, targetClipAnimationGroup) = this.AnimateClip( + sourceActualSize, + targetActualSize, + sourceCenterPoint, + targetCenterPoint, + sourceTargetScale, + duration, + easingType, + easingMode); + if (sourceClipAnimationGroup is not null) + { + controller.AddAnimationGroupFor(source, sourceClipAnimationGroup); + } + + if (targetClipAnimationGroup is not null) + { + controller.AddAnimationGroupFor(target, targetClipAnimationGroup); + } + } + } + + private Task AnimateIndependentElements( + IEnumerable independentElements, + bool isShow, + CancellationToken token, + TimeSpan? startTime, + EasingType easingType, + EasingMode easingMode) + { + if (independentElements?.ToArray() is not { Length: > 0 } elements) + { + return Task.CompletedTask; + } + + var controller = new KeyFrameAnimationGroupController(); + var duration = isShow ? this.IndependentElementShowDuration : this.IndependentElementHideDuration; + var delay = isShow ? this.IndependentElementShowDelay : TimeSpan.Zero; + var opacityFrom = isShow ? 0 : 1; + var opacityTo = isShow ? 1 : 0; + foreach (var item in elements) + { + if (startTime.HasValue && delay < startTime) + { + if (isShow) + { + RestoreElement(item); + } + else + { + item.GetVisual().Opacity = 0; + } + } + else + { + var independentTranslation = GetIndependentTranslation(item) ?? this.DefaultIndependentTranslation; + var translationFrom = isShow ? independentTranslation.ToVector2() : Vector2.Zero; + var translationTo = isShow ? Vector2.Zero : independentTranslation.ToVector2(); + var useDelay = delay - (startTime ?? TimeSpan.Zero); + if (Math.Abs(independentTranslation.X) > AlmostZero || + Math.Abs(independentTranslation.Y) > AlmostZero) + { + controller.AddAnimationFor(item, this.Translation( + translationTo, + GetEasingFunctionFactory(easingType, easingMode), + startTime.HasValue ? null : translationFrom, + useDelay, + duration: duration)); + } + + controller.AddAnimationFor(item, this.Opacity( + opacityTo, + GetEasingFunctionFactory(easingType, easingMode), + startTime.HasValue ? null : opacityFrom, + useDelay, + duration)); + } + + if (isShow) + { + delay += this.IndependentElementShowInterval; + } + } + + return controller.StartAsync(token, null); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory, Vector2) AnimateTranslation( + UIElement source, + UIElement target, + Vector2 sourceCenterPoint, + Vector2 targetCenterPoint, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + var translation = target.TransformToVisual(source).TransformPoint(default).ToVector2() - sourceCenterPoint + targetCenterPoint; + return (this.Translation(translation, GetEasingFunctionFactory(easingType, easingMode), Vector2.Zero, duration: duration), + this.Translation(Vector2.Zero, GetEasingFunctionFactory(easingType, easingMode), -translation, duration: duration), + translation); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory, Vector2) AnimateScale( + Vector2 sourceActualSize, + Vector2 targetActualSize, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + var scaleX = targetActualSize.X / sourceActualSize.X; + var scaleY = targetActualSize.Y / sourceActualSize.Y; + var scale = new Vector2(scaleX, scaleY); + var (sourceFactory, targetFactory) = this.AnimateScaleImp(scale, duration, easingType, easingMode); + return (sourceFactory, targetFactory, scale); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory, Vector2) AnimateScaleX( + Vector2 sourceActualSize, + Vector2 targetActualSize, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + var scaleX = targetActualSize.X / sourceActualSize.X; + var scale = new Vector2(scaleX, scaleX); + var (sourceFactory, targetFactory) = this.AnimateScaleImp(scale, duration, easingType, easingMode); + return (sourceFactory, targetFactory, scale); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory, Vector2) AnimateScaleY( + Vector2 sourceActualSize, + Vector2 targetActualSize, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + var scaleY = targetActualSize.Y / sourceActualSize.Y; + var scale = new Vector2(scaleY, scaleY); + var (sourceFactory, targetFactory) = this.AnimateScaleImp(scale, duration, easingType, easingMode); + return (sourceFactory, targetFactory, scale); + } + + private (IKeyFrameCompositionAnimationFactory?, IKeyFrameCompositionAnimationFactory?, Vector2) AnimateScaleWithScaleCalculator( + UIElement source, + UIElement target, + IScalingCalculator? scalingCalculator, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + if (scalingCalculator is null) + { + return (null, null, Vector2.One); + } + + var scale = scalingCalculator.GetScaling(source, target); + var (sourceFactory, targetFactory) = this.AnimateScaleImp(scale, duration, easingType, easingMode); + return (sourceFactory, targetFactory, scale); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory) AnimateScaleImp( + Vector2 targetScale, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + return (this.Scale(targetScale, GetEasingFunctionFactory(easingType, easingMode), Vector2.One, duration: duration), + this.Scale(Vector2.One, GetEasingFunctionFactory(easingType, easingMode), GetInverseScale(targetScale), duration: duration)); + } + + private (IKeyFrameCompositionAnimationFactory, IKeyFrameCompositionAnimationFactory) AnimateOpacity(TimeSpan duration, Point opacityTransitionProgressKey) + { + var normalKey = (float)opacityTransitionProgressKey.X; + var normalKeyForTarget = Math.Clamp(normalKey - 0.1f, 0, 1); + var sourceNormalizedKeyFrames = new Dictionary + { + [GetOpacityTransitionStartKey(normalKey)] = (1, null), + [GetOpacityTransitionEndKey(normalKey)] = (0, GetEasingFunctionFactory(EasingType.Cubic, EasingMode.EaseIn)) + }; + var targetNormalizedKeyFrames = new Dictionary + { + [GetOpacityTransitionStartKey(normalKeyForTarget)] = (0, null), + [GetOpacityTransitionEndKey(normalKeyForTarget)] = (1, GetEasingFunctionFactory(EasingType.Cubic, EasingMode.EaseOut)) + }; + + var reversedKey = (float)(1 - opacityTransitionProgressKey.Y); + var reversedKeyForSource = Math.Clamp(reversedKey + 0.1f, 0, 1); + var reversedSourceNormalizedKeyFrames = new Dictionary + { + [GetOpacityTransitionStartKey(reversedKeyForSource)] = (1, null), + [GetOpacityTransitionEndKey(reversedKeyForSource)] = (0, GetEasingFunctionFactory(EasingType.Cubic, EasingMode.EaseIn, true)) + }; + var reversedTargetNormalizedKeyFrames = new Dictionary + { + [GetOpacityTransitionStartKey(reversedKey)] = (0, null), + [GetOpacityTransitionEndKey(reversedKey)] = (1, GetEasingFunctionFactory(EasingType.Cubic, EasingMode.EaseOut, true)), + }; + + return (this.Opacity(0, null, 1, duration: duration, normalizedKeyFrames: sourceNormalizedKeyFrames, reversedNormalizedKeyFrames: reversedSourceNormalizedKeyFrames), + this.Opacity(1, null, 0, duration: duration, normalizedKeyFrames: targetNormalizedKeyFrames, reversedNormalizedKeyFrames: reversedTargetNormalizedKeyFrames)); + } + + private (IKeyFrameCompositionAnimationFactory[]?, IKeyFrameCompositionAnimationFactory[]?) AnimateClip( + Vector2 sourceActualSize, + Vector2 targetActualSize, + Vector2 sourceCenterPoint, + Vector2 targetCenterPoint, + Vector2 sourceTargetScale, + TimeSpan duration, + EasingType easingType, + EasingMode easingMode) + { + var sourceToClip = GetTargetClip(-sourceCenterPoint, sourceActualSize, sourceCenterPoint, sourceTargetScale, default, new Rect((-targetCenterPoint).ToPoint(), targetActualSize.ToSize())); + var targetFromClip = GetTargetClip(-targetCenterPoint, targetActualSize, targetCenterPoint, GetInverseScale(sourceTargetScale), default, new Rect((-sourceCenterPoint).ToPoint(), sourceActualSize.ToSize())); + return ( + sourceToClip.HasValue + ? this.Clip( + sourceToClip.Value, + GetEasingFunctionFactory(easingType, easingMode), + default, + duration: duration) + : null, + targetFromClip.HasValue + ? this.Clip( + default, + GetEasingFunctionFactory(easingType, easingMode), + targetFromClip.Value, + duration: duration) + : null + ); + } +} diff --git a/components/TransitionHelper/src/TransitionHelper.Properties.cs b/components/TransitionHelper/src/TransitionHelper.Properties.cs new file mode 100644 index 000000000..e12a5ecdb --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.Properties.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if WINAPPSDK +using CommunityToolkit.WinUI.UI.Animations; +using Microsoft.UI.Xaml.Media.Animation; +#else +using Microsoft.Toolkit.Uwp.UI.Animations; +using Windows.UI.Xaml.Media.Animation; +#endif + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +public sealed partial class TransitionHelper +{ + private FrameworkElement? _source; + private int _sourceZIndex = -1; + private FrameworkElement? _target; + private int _targetZIndex = -1; + + /// + /// Gets or sets the source control. + /// + public FrameworkElement? Source + { + get + { + return this._source; + } + + set + { + if (this._source == value) + { + return; + } + + var needReset = IsAnimating || IsTargetState; + if (IsAnimating && this._source is not null) + { + this.Stop(); + RestoreElements(this.SourceAnimatedElements.All()); + } + + this._currentAnimationGroupController = null; + this._source = value; + this._sourceZIndex = value is null ? -1 : Canvas.GetZIndex(value); + this._sourceAnimatedElements = null; + if (needReset) + { + this.Reset(true); + } + } + } + + /// + /// Gets or sets the target control. + /// + public FrameworkElement? Target + { + get + { + return this._target; + } + + set + { + if (this._target == value) + { + return; + } + + var needReset = IsAnimating || IsTargetState; + if (IsAnimating && this._target is not null) + { + this.Stop(); + RestoreElements(this.TargetAnimatedElements.All()); + } + + this._currentAnimationGroupController = null; + this._target = value; + this._targetZIndex = value is null ? -1 : Canvas.GetZIndex(value); + this._targetAnimatedElements = null; + if (needReset) + { + this.Reset(true); + } + } + } + + /// + /// Gets or sets transition configurations of UI elements that need to be connected by animation. + /// + public List Configs { get; set; } = new(); + + /// + /// Gets a value indicating whether the source control has been morphed to the target control. + /// The default value is false. + /// + public bool IsTargetState { get; private set; } = false; + + /// + /// Gets or sets a value indicating whether the contained area of the source or target control can return true values for hit testing when animating. + /// The default value is false. + /// + public bool IsHitTestVisibleWhenAnimating { get; set; } = false; + + /// + /// Gets or sets the method of changing the visibility of the source control. + /// The default value is . + /// + public VisualStateToggleMethod SourceToggleMethod { get; set; } = VisualStateToggleMethod.ByVisibility; + + /// + /// Gets or sets the method of changing the visibility of the target control. + /// The default value is . + /// + public VisualStateToggleMethod TargetToggleMethod { get; set; } = VisualStateToggleMethod.ByVisibility; + + /// + /// Gets or sets the duration of the connected animation between two UI elements. + /// The default value is 600ms. + /// + public TimeSpan Duration { get; set; } = TimeSpan.FromMilliseconds(600); + + /// + /// Gets or sets the reverse duration of the connected animation between two UI elements. + /// The default value is 600ms. + /// + public TimeSpan ReverseDuration { get; set; } = TimeSpan.FromMilliseconds(600); + + /// + /// Gets or sets a value indicating whether to use the inverse easing function when animating in reverse direction. + /// The default value is true. + /// + public bool InverseEasingFunctionWhenReversing { get; set; } = true; + + /// + /// Gets or sets the duration of the show animation for independent or unpaired UI elements. + /// The default value is 200ms. + /// + public TimeSpan IndependentElementShowDuration { get; set; } = TimeSpan.FromMilliseconds(200); + + /// + /// Gets or sets the delay of the show animation for independent or unpaired UI elements. + /// The default value is 300ms. + /// + public TimeSpan IndependentElementShowDelay { get; set; } = TimeSpan.FromMilliseconds(300); + + /// + /// Gets or sets the interval between the show animations for independent or unpaired UI elements. + /// The default value is 50ms. + /// + public TimeSpan IndependentElementShowInterval { get; set; } = TimeSpan.FromMilliseconds(50); + + /// + /// Gets or sets the duration of the hide animation for independent or unpaired UI elements. + /// The default value is 100ms. + /// + public TimeSpan IndependentElementHideDuration { get; set; } = TimeSpan.FromMilliseconds(100); + + /// + /// Gets or sets the default easing function type for the transition. + /// The default value is . + /// + public EasingType DefaultEasingType { get; set; } = EasingType.Default; + + /// + /// Gets or sets the default easing function mode for the transition. + /// The default value is . + /// + public EasingMode DefaultEasingMode { get; set; } = EasingMode.EaseInOut; + + /// + /// Gets or sets the default translation used by the show or hide animation for independent or unpaired UI elements. + /// The default value is (0, 20). + /// + public Point DefaultIndependentTranslation { get; set; } = new(0, 20); + + /// + /// Gets or sets the default key point of opacity transition. + /// The time the keyframe of opacity from 0 to 1 or from 1 to 0 should occur at, expressed as a percentage of the animation duration. The allowed values are from (0, 0) to (1, 1). + /// .X will be used in the animation of the normal direction. + /// .Y will be used in the animation of the reverse direction. + /// The default value is (0.3, 0.3). + /// + public Point DefaultOpacityTransitionProgressKey { get; set; } = new(0.3, 0.3); + + /// + /// Gets or sets the easing function type for animation of independent or unpaired UI elements. + /// The default value is . + /// + public EasingType IndependentElementEasingType { get; set; } = EasingType.Default; + + /// + /// Gets or sets the easing function mode for animation of independent or unpaired UI elements. + /// The default value is . + /// + public EasingMode IndependentElementEasingMode { get; set; } = EasingMode.EaseInOut; +} diff --git a/components/TransitionHelper/src/TransitionHelper.cs b/components/TransitionHelper/src/TransitionHelper.cs new file mode 100644 index 000000000..e4f704ab7 --- /dev/null +++ b/components/TransitionHelper/src/TransitionHelper.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +namespace CommunityToolkit.Labs.WinUI; + +/// +/// A animation helper that morphs between two controls. +/// +[ContentProperty(Name = nameof(Configs))] +public sealed partial class TransitionHelper // TODO: Implement IDisposible? or resolve CA1001 another way? +{ + private sealed record AnimatedElements( + IDictionary ConnectedElements, + IDictionary> CoordinatedElements, + IList IndependentElements) + { + public IEnumerable All() + { + return this.ConnectedElements.Values.Concat(this.IndependentElements).Concat(this.CoordinatedElements.SelectMany(item => item.Value)); + } + } + + private const double AlmostZero = 0.01; + private AnimatedElements? _sourceAnimatedElements; + private AnimatedElements? _targetAnimatedElements; + private CancellationTokenSource? _currentAnimationCancellationTokenSource; + private IKeyFrameAnimationGroupController? _currentAnimationGroupController; + + private AnimatedElements SourceAnimatedElements => _sourceAnimatedElements ??= GetAnimatedElements(this.Source); + + private AnimatedElements TargetAnimatedElements => _targetAnimatedElements ??= GetAnimatedElements(this.Target); + + private TransitionConfig DefaultConfig => new() + { + EasingMode = DefaultEasingMode, + EasingType = DefaultEasingType, + OpacityTransitionProgressKey = DefaultIndependentTranslation + }; + + /// + /// Gets a value indicating whether the source and target controls are animating. + /// + public bool IsAnimating => _currentAnimationCancellationTokenSource is not null && this._currentAnimationGroupController is not null; + + /// + /// Morphs from source control to target control. + /// + /// A that completes when all animations have completed. + public Task StartAsync() + { + return StartAsync(CancellationToken.None, false); + } + + /// + /// Morphs from source control to target control. + /// + /// Indicates whether to force the update of the child element list before the animation starts. + /// A that completes when all animations have completed. + public Task StartAsync(bool forceUpdateAnimatedElements) + { + return StartAsync(CancellationToken.None, forceUpdateAnimatedElements); + } + + /// + /// Morphs from source control to target control. + /// + /// The cancellation token to stop animations while they're running. + /// Indicates whether to force the update of the child element list before the animation starts. + /// A that completes when all animations have completed. + public Task StartAsync(CancellationToken token, bool forceUpdateAnimatedElements) + { + return this.AnimateControlsAsync(false, token, forceUpdateAnimatedElements); + } + + /// + /// Reverse animation, morphs from target control to source control. + /// + /// A that completes when all animations have completed. + public Task ReverseAsync() + { + return ReverseAsync(CancellationToken.None, false); + } + + /// + /// Reverse animation, morphs from target control to source control. + /// + /// Indicates whether to force the update of child elements before the animation starts. + /// A that completes when all animations have completed. + public Task ReverseAsync(bool forceUpdateAnimatedElements) + { + return ReverseAsync(CancellationToken.None, forceUpdateAnimatedElements); + } + + /// + /// Reverse animation, morphs from target control to source control. + /// + /// The cancellation token to stop animations while they're running. + /// Indicates whether to force the update of child elements before the animation starts. + /// A that completes when all animations have completed. + public Task ReverseAsync(CancellationToken token, bool forceUpdateAnimatedElements) + { + return this.AnimateControlsAsync(true, token, forceUpdateAnimatedElements); + } + + /// + /// Stop all animations. + /// + public void Stop() + { + if (IsAnimating is false) + { + return; + } + + this._currentAnimationCancellationTokenSource?.Cancel(); + this._currentAnimationCancellationTokenSource = null; + } + + /// + /// Reset to initial or target state. + /// + /// Indicates whether to reset to initial state. default value is True, if it is False, it will be reset to target state. + public void Reset(bool toInitialState = true) + { + this.Stop(); + this._currentAnimationGroupController = null; + this.RestoreState(!toInitialState); + } +} diff --git a/components/TransitionHelper/tests/ExampleTransitionHelperTestClass.cs b/components/TransitionHelper/tests/ExampleTransitionHelperTestClass.cs new file mode 100644 index 000000000..4262f56e9 --- /dev/null +++ b/components/TransitionHelper/tests/ExampleTransitionHelperTestClass.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Labs.WinUI; +using CommunityToolkit.Tests; +using CommunityToolkit.Tooling.TestGen; + +namespace TransitionHelperExperiment.Tests; + +[TestClass] +public partial class ExampleTransitionHelperTestClass : VisualUITestBase +{ + // If you don't need access to UI objects directly or async code, use this pattern. + [TestMethod] + public void SimpleSynchronousExampleTest() + { + var assembly = typeof(TransitionHelper).Assembly; + var type = assembly.GetType(typeof(TransitionHelper).FullName ?? string.Empty); + + Assert.IsNotNull(type, "Could not find TransitionHelper type."); + Assert.AreEqual(typeof(TransitionHelper), type, "Type of TransitionHelper does not match expected type."); + } + + // The UIThreadTestMethod automatically dispatches to the UI for us to work with UI objects. + [UIThreadTestMethod] + public void SimpleUIAttributeExampleTest() + { + var component = new TransitionHelper(); + Assert.IsNotNull(component); + } +} diff --git a/components/TransitionHelper/tests/TransitionHelper.Tests.projitems b/components/TransitionHelper/tests/TransitionHelper.Tests.projitems new file mode 100644 index 000000000..827f0f041 --- /dev/null +++ b/components/TransitionHelper/tests/TransitionHelper.Tests.projitems @@ -0,0 +1,14 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + F2D39FE2-BDFC-4E8C-9948-272F5272F936 + + + TransitionHelperExperiment.Tests + + + + + \ No newline at end of file diff --git a/components/TransitionHelper/tests/TransitionHelper.Tests.shproj b/components/TransitionHelper/tests/TransitionHelper.Tests.shproj new file mode 100644 index 000000000..e375cccbc --- /dev/null +++ b/components/TransitionHelper/tests/TransitionHelper.Tests.shproj @@ -0,0 +1,13 @@ + + + + F2D39FE2-BDFC-4E8C-9948-272F5272F936 + 14.0 + + + + + + + + diff --git a/tooling b/tooling index d34f72568..66ab9faeb 160000 --- a/tooling +++ b/tooling @@ -1 +1 @@ -Subproject commit d34f72568e654a4a65d8804d8f145ac76032100c +Subproject commit 66ab9faebbcf0118e66008bfa194c1fa2b9b06f0