diff --git a/AvaloniaBehaviors.slnx b/AvaloniaBehaviors.slnx index b4f6045f9..3e27fc78a 100644 --- a/AvaloniaBehaviors.slnx +++ b/AvaloniaBehaviors.slnx @@ -28,10 +28,12 @@ + + @@ -47,6 +49,7 @@ + diff --git a/README.md b/README.md index f53664112..4b8abd3fe 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ In addition to the classic reflection-based behaviors from WPF/UWP, this port ad See the [AOT-friendly behaviors docs](docfx/articles/source-generators/index.md) for examples and guidance. +Animation definitions and composition helpers are also available independently through `Xaml.Behaviors.Animations`. This package depends on Avalonia only; behavior, action, and trigger adapters in `Xaml.Behaviors.Interactions.Custom` reuse the same implementation. See the [standalone animations guide](docfx/articles/animations/index.md). + +The [standalone animations sample](samples/AnimationsTestApplication/README.md) demonstrates every feature family in a dedicated tab without referencing the behaviors framework. + ## Building XAML Behaviors Avalonia First, clone the repository or download the latest zip. @@ -75,6 +79,7 @@ and install the package like this: | Package | NuGet | Description | |---------|-------|-------------| | [Xaml.Behaviors](https://www.nuget.org/packages/Xaml.Behaviors) | [![NuGet](https://img.shields.io/nuget/v/Xaml.Behaviors.svg)](https://www.nuget.org/packages/Xaml.Behaviors) | Complete library of behaviors, actions and triggers for Avalonia applications. | +| [Xaml.Behaviors.Animations](https://www.nuget.org/packages/Xaml.Behaviors.Animations) | [![NuGet](https://img.shields.io/nuget/v/Xaml.Behaviors.Animations.svg)](https://www.nuget.org/packages/Xaml.Behaviors.Animations) | Reusable Avalonia animations, composition effects, and transition helpers with no behaviors dependency. | | [Xaml.Behaviors.Avalonia](https://www.nuget.org/packages/Xaml.Behaviors.Avalonia) | [![NuGet](https://img.shields.io/nuget/v/Xaml.Behaviors.Avalonia.svg)](https://www.nuget.org/packages/Xaml.Behaviors.Avalonia) | Meta package that bundles all Avalonia XAML Behaviors for easy installation. | | [Xaml.Behaviors.Interactivity](https://www.nuget.org/packages/Xaml.Behaviors.Interactivity) | [![NuGet](https://img.shields.io/nuget/v/Xaml.Behaviors.Interactivity.svg)](https://www.nuget.org/packages/Xaml.Behaviors.Interactivity) | Foundation library providing base classes for actions, triggers and behaviors. | | [Xaml.Behaviors.Interactions](https://www.nuget.org/packages/Xaml.Behaviors.Interactions) | [![NuGet](https://img.shields.io/nuget/v/Xaml.Behaviors.Interactions.svg)](https://www.nuget.org/packages/Xaml.Behaviors.Interactions) | Core collection of common triggers and actions for Avalonia. | diff --git a/docfx/articles/animations/index.md b/docfx/articles/animations/index.md new file mode 100644 index 000000000..818485352 --- /dev/null +++ b/docfx/articles/animations/index.md @@ -0,0 +1,91 @@ +# Standalone animations + +`Xaml.Behaviors.Animations` is the reusable animation layer for this repository. It targets `net8.0` and `net10.0`, depends only on Avalonia, and does not reference `Xaml.Behaviors.Interactivity` or any interactions package. + +Install it when an application or library needs the animation implementations without behavior, action, or trigger types: + +```xml + +``` + +The repository includes an [animations-only sample application](https://github.com/wieslawsoltes/Xaml.Behaviors/tree/master/samples/AnimationsTestApplication) with a dedicated tab for every feature family. Its project references only Avalonia and `Xaml.Behaviors.Animations`. + +## API groups + +| Group | APIs | +| --- | --- | +| Avalonia key-frame animation | `AnimationFactory`, `AnimationRunner`, `FluidMoveAnimation`, `IAnimationBuilder` | +| Composition catalogs | `AttentionAnimations`, `EntranceAnimations`, `ExitAnimations`, `FramerMotionAnimations`, `SpecialAnimations` | +| Composition primitives | `FadeAnimation`, `RotateAnimation`, `ScaleAnimation`, `SlidingAnimation` | +| Reusable effects | `OrbitAnimation`, `ParallaxAnimation`, `TiltAnimation` | +| Selection animation | `SelectionIndicatorAnimation`, `SelectingItemsControlBehavior` attached property | +| Transitions | `TransitionOperations` | + +The CLR namespace remains `Avalonia.Xaml.Interactions.Custom` for source and XAML compatibility with earlier releases. The types now live in the `Xaml.Behaviors.Animations` assembly, while `Xaml.Behaviors.Interactions.Custom` ships type forwarders for legacy assembly-qualified references. + +## XAML composition animations + +The composition catalogs are attached properties, so they can be used with no behavior collection: + +```xml + + + +``` + +The attached values are durations in milliseconds. The animation begins when the element receives a composition visual. A zero duration applies the final key-frame state immediately; a positive duration runs through the compositor. + +## Direct use from code + +Create and run a normal Avalonia animation. Async variants return the completion task when callers need to coordinate follow-up work: + +```csharp +using Avalonia.Controls; +using Avalonia.Xaml.Interactions.Custom; + +public static class WelcomeAnimation +{ + public static void Start(Control target) + { + var animation = AnimationFactory.CreateFadeIn( + TimeSpan.FromMilliseconds(150), + TimeSpan.FromMilliseconds(350)); + + AnimationRunner.TryRun(animation, target); + } +} +``` + +`AnimationRunner.TryBuildAndRunAsync` applies the same explicit-animation-first selection used by the behavior adapters and returns `null` when neither an animation nor a builder result is available. `FluidMoveAnimation.TryRun` prepares a control's translation transform before starting its movement animation. + +Composition effects expose calculation and application separately where useful. For example, a scroll observer can retain a session from `ParallaxAnimation.TryCreate(target)` and call its `Apply(offset, ratio)` method without attaching `ParallaxBehavior`. The session preserves the target composition visual across scroll updates and adds each parallax delta to the target's layout offset. `OrbitAnimation` maintains its orientation state for callers, while `TiltAnimation` calculates an orientation directly from the target size and pointer position. + +`SelectionIndicatorAnimation.TryStart` can animate explicitly supplied indicator/container visuals or resolve the conventional `PART_SelectedPipe` from two templated item containers. `SelectingItemsControlBehavior.EnableSelectionAnimation` is the convenient attached-property adapter over the same primitive. + +Transition collection operations are likewise independent: + +```csharp +var transition = new DoubleTransition +{ + Property = Visual.OpacityProperty, + Duration = TimeSpan.FromMilliseconds(200) +}; + +TransitionOperations.Add(target, transition); + +using IDisposable subscription = TransitionOperations.Observe( + target, + transitions => OnTransitionsReplaced(transitions)); +``` + +`Observe` reports the current collection immediately and subsequent replacements until the returned subscription is disposed. + +## Behavior adapters + +Install `Xaml.Behaviors.Interactions.Custom` when XAML attachment lifecycle, triggers, or actions are desired. Its animation and transition adapters reference `Xaml.Behaviors.Animations` and use the same shared implementation; existing behavior XAML remains unchanged. diff --git a/docfx/articles/animations/toc.yml b/docfx/articles/animations/toc.yml new file mode 100644 index 000000000..8258ac29b --- /dev/null +++ b/docfx/articles/animations/toc.yml @@ -0,0 +1,2 @@ +- name: Standalone Animations + href: index.md diff --git a/docfx/articles/architecture.md b/docfx/articles/architecture.md index fdc2207b0..0693f7329 100644 --- a/docfx/articles/architecture.md +++ b/docfx/articles/architecture.md @@ -2,6 +2,12 @@ Understanding the internal architecture of `Xaml.Behaviors` helps in writing custom behaviors and debugging complex interactions. The library is built upon the concept of **Attached Properties** and a specific lifecycle management system. +## Package boundaries + +`Xaml.Behaviors.Animations` contains reusable Avalonia key-frame animations, composition animation catalogs and effects, animation runners/builders, and transition operations. It references Avalonia only and can be installed without the Interactivity or Interactions packages. + +`Xaml.Behaviors.Interactions.Custom` references that package and adds lifecycle adapters such as `FadeInBehavior`, `OrbitEffectBehavior`, actions, and triggers. This keeps animation mechanics independent from behavior attachment and action execution while preserving the existing public XAML APIs. + ## Core Components ### 1. The `Interaction` Class diff --git a/docfx/articles/interactions-custom/animations/i-animation-builder.md b/docfx/articles/interactions-custom/animations/i-animation-builder.md index c944e59ac..6445a9881 100644 --- a/docfx/articles/interactions-custom/animations/i-animation-builder.md +++ b/docfx/articles/interactions-custom/animations/i-animation-builder.md @@ -1,6 +1,6 @@ # IAnimationBuilder -The `IAnimationBuilder` interface allows for dynamic animation creation. +The `IAnimationBuilder` interface allows for dynamic animation creation. It is shipped by `Xaml.Behaviors.Animations` and has no behaviors dependency; the animation behaviors and actions consume the same interface. ```csharp public interface IAnimationBuilder diff --git a/docfx/articles/interactions-custom/animations/overview.md b/docfx/articles/interactions-custom/animations/overview.md index 1c74bb6a5..993fa3da0 100644 --- a/docfx/articles/interactions-custom/animations/overview.md +++ b/docfx/articles/interactions-custom/animations/overview.md @@ -1,6 +1,10 @@ # Animations -The `Animations` category contains the following interactions: +The `Animations` category contains behavior, action, and trigger adapters from `Xaml.Behaviors.Interactions.Custom`. They delegate animation creation and execution to the standalone `Xaml.Behaviors.Animations` package. + +Applications that do not need behavior lifecycle adapters can use `AnimationFactory`, `AnimationRunner`, and `IAnimationBuilder` directly by referencing only `Xaml.Behaviors.Animations`. See the [standalone animations guide](../../animations/index.md). + +The category contains the following interactions: * [AnimateOnAttachedBehavior](animate-on-attached-behavior.md) * [AnimationCompletedTrigger](animation-completed-trigger.md) diff --git a/docfx/articles/interactions-custom/composition/overview.md b/docfx/articles/interactions-custom/composition/overview.md index 4df0a3725..3a685c1ad 100644 --- a/docfx/articles/interactions-custom/composition/overview.md +++ b/docfx/articles/interactions-custom/composition/overview.md @@ -1,3 +1,5 @@ # Composition -The `Composition` category contains the following interactions: +Composition animation catalogs and reusable effects are shipped by `Xaml.Behaviors.Animations`. The package can be used directly without Interactivity. `OrbitEffectBehavior`, `ParallaxBehavior`, and `TiltEffectBehavior` remain in `Xaml.Behaviors.Interactions.Custom` as pointer and lifecycle adapters over the reusable animation APIs. + +See the [standalone animations guide](../../animations/index.md) for installation, the API inventory, and direct-use examples. diff --git a/docfx/articles/interactions-custom/transitions/overview.md b/docfx/articles/interactions-custom/transitions/overview.md index 2ecf7f3be..dd90e01b2 100644 --- a/docfx/articles/interactions-custom/transitions/overview.md +++ b/docfx/articles/interactions-custom/transitions/overview.md @@ -1,6 +1,6 @@ # Transitions -The `Avalonia.Xaml.Interactions.Custom` package provides behaviors, actions and triggers for working with `Transitions` in Avalonia. +The `Avalonia.Xaml.Interactions.Custom` package provides behaviors, actions and triggers for working with `Transitions` in Avalonia. These adapters delegate collection changes to `TransitionOperations` from the standalone `Xaml.Behaviors.Animations` package. ## Actions diff --git a/docfx/articles/toc.yml b/docfx/articles/toc.yml index 7e09fba6c..5d77bbd83 100644 --- a/docfx/articles/toc.yml +++ b/docfx/articles/toc.yml @@ -4,6 +4,8 @@ href: architecture.md - name: MVVM and Behaviors href: mvvm-and-behaviors.md +- name: Animations + href: animations/toc.yml - name: Interactivity href: interactivity/toc.yml - name: Interactions diff --git a/docfx/docfx.json b/docfx/docfx.json index 51bb4b52b..01b23856d 100644 --- a/docfx/docfx.json +++ b/docfx/docfx.json @@ -4,6 +4,7 @@ "src": [ { "files": [ + "src/Xaml.Behaviors.Animations/Xaml.Behaviors.Animations.csproj", "src/Xaml.Behaviors.Interactions/Xaml.Behaviors.Interactions.csproj", "src/Xaml.Behaviors.Interactions.Custom/Xaml.Behaviors.Interactions.Custom.csproj", "src/Xaml.Behaviors.Interactions.DragAndDrop/Xaml.Behaviors.Interactions.DragAndDrop.csproj", diff --git a/samples/AnimationsTestApplication/AnimationsTestApplication.csproj b/samples/AnimationsTestApplication/AnimationsTestApplication.csproj new file mode 100644 index 000000000..bd8d7c752 --- /dev/null +++ b/samples/AnimationsTestApplication/AnimationsTestApplication.csproj @@ -0,0 +1,22 @@ + + + + WinExe + net10.0 + False + enable + true + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/App.axaml b/samples/AnimationsTestApplication/App.axaml new file mode 100644 index 000000000..92b4f9b03 --- /dev/null +++ b/samples/AnimationsTestApplication/App.axaml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/samples/AnimationsTestApplication/App.axaml.cs b/samples/AnimationsTestApplication/App.axaml.cs new file mode 100644 index 000000000..ca12aced0 --- /dev/null +++ b/samples/AnimationsTestApplication/App.axaml.cs @@ -0,0 +1,31 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using AnimationsTestApplication.Views; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AnimationsTestApplication; + +public class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + desktopLifetime.MainWindow = new MainWindow(); + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewLifetime) + { + singleViewLifetime.MainView = new MainView(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/samples/AnimationsTestApplication/Controls/FluidMoveAnimationDemoControl.cs b/samples/AnimationsTestApplication/Controls/FluidMoveAnimationDemoControl.cs new file mode 100644 index 000000000..fe281c424 --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/FluidMoveAnimationDemoControl.cs @@ -0,0 +1,48 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Demonstrates direct fluid translation animations. +/// +public class FluidMoveAnimationDemoControl : ContentControl +{ + private bool _reverse; + + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register( + nameof(Duration), + TimeSpan.FromMilliseconds(450)); + + public static readonly StyledProperty DistanceProperty = + AvaloniaProperty.Register(nameof(Distance), 180d); + + public TimeSpan Duration + { + get => GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + public double Distance + { + get => GetValue(DistanceProperty); + set => SetValue(DistanceProperty, value); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + double offset = _reverse ? -Distance : Distance; + _reverse = !_reverse; + FluidMoveAnimation.TryRun(this, offset, 0d, Duration); + e.Handled = true; + } +} diff --git a/samples/AnimationsTestApplication/Controls/KeyFrameAnimationDemoControl.cs b/samples/AnimationsTestApplication/Controls/KeyFrameAnimationDemoControl.cs new file mode 100644 index 000000000..ed41ddae2 --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/KeyFrameAnimationDemoControl.cs @@ -0,0 +1,83 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Demonstrates direct use of animation factories, builders, and runners. +/// +public class KeyFrameAnimationDemoControl : ContentControl +{ + private sealed class FadeAnimationBuilder(TimeSpan initialDelay, TimeSpan duration) : IAnimationBuilder + { + public Animation? Build(Control control) + { + return AnimationFactory.CreateFadeIn(initialDelay, duration); + } + } + + public static readonly StyledProperty InitialDelayProperty = + AvaloniaProperty.Register( + nameof(InitialDelay), + TimeSpan.FromMilliseconds(150)); + + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register( + nameof(Duration), + TimeSpan.FromMilliseconds(500)); + + public static readonly StyledProperty UseBuilderProperty = + AvaloniaProperty.Register(nameof(UseBuilder)); + + public TimeSpan InitialDelay + { + get => GetValue(InitialDelayProperty); + set => SetValue(InitialDelayProperty, value); + } + + public TimeSpan Duration + { + get => GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + public bool UseBuilder + { + get => GetValue(UseBuilderProperty); + set => SetValue(UseBuilderProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Play(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + Play(); + e.Handled = true; + } + + private void Play() + { + if (UseBuilder) + { + var builder = new FadeAnimationBuilder(InitialDelay, Duration); + AnimationRunner.TryBuildAndRun(this, animation: null, builder); + return; + } + + Animation animation = AnimationFactory.CreateFadeIn(InitialDelay, Duration); + AnimationRunner.TryRun(animation, this); + } +} diff --git a/samples/AnimationsTestApplication/Controls/OrbitAnimationDemoControl.cs b/samples/AnimationsTestApplication/Controls/OrbitAnimationDemoControl.cs new file mode 100644 index 000000000..1869872ff --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/OrbitAnimationDemoControl.cs @@ -0,0 +1,80 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Adapts pointer input to the standalone orbit animation API. +/// +public class OrbitAnimationDemoControl : ContentControl +{ + private readonly OrbitAnimation _animation = new(); + private bool _isPressed; + private Point _lastPosition; + + public static readonly StyledProperty SensitivityProperty = + AvaloniaProperty.Register(nameof(Sensitivity), 0.5d); + + public double Sensitivity + { + get => GetValue(SensitivityProperty); + set => SetValue(SensitivityProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + SizeChanged += OnSizeChanged; + OrbitAnimation.UpdateCenterPoint(this); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + SizeChanged -= OnSizeChanged; + _isPressed = false; + _animation.Reset(); + base.OnDetachedFromVisualTree(e); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + _isPressed = true; + _lastPosition = e.GetPosition(this); + e.Pointer.Capture(this); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!_isPressed) + { + return; + } + + Point position = e.GetPosition(this); + Vector delta = position - _lastPosition; + _lastPosition = position; + _animation.Rotate(this, delta, Sensitivity); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _isPressed = false; + e.Pointer.Capture(null); + e.Handled = true; + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + OrbitAnimation.UpdateCenterPoint(this); + } +} diff --git a/samples/AnimationsTestApplication/Controls/ParallaxAnimationDemoControl.cs b/samples/AnimationsTestApplication/Controls/ParallaxAnimationDemoControl.cs new file mode 100644 index 000000000..2c3871d8d --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/ParallaxAnimationDemoControl.cs @@ -0,0 +1,86 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Connects a scroll offset to the standalone parallax animation API. +/// +public class ParallaxAnimationDemoControl : ContentControl, IObserver +{ + private IDisposable? _subscription; + private ParallaxAnimation? _animation; + private bool _isAttached; + + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + public static readonly StyledProperty RatioProperty = + AvaloniaProperty.Register(nameof(Ratio), 0.25d); + + [ResolveByName] + public ScrollViewer? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public double Ratio + { + get => GetValue(RatioProperty); + set => SetValue(RatioProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _isAttached = true; + _animation = ParallaxAnimation.TryCreate(this); + Subscribe(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _subscription?.Dispose(); + _subscription = null; + _animation = null; + base.OnDetachedFromVisualTree(e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty && _isAttached) + { + Subscribe(); + } + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(Vector value) + { + _animation ??= ParallaxAnimation.TryCreate(this); + _animation?.Apply(value, Ratio); + } + + private void Subscribe() + { + _subscription?.Dispose(); + _subscription = Source?.GetObservable(ScrollViewer.OffsetProperty).Subscribe(this); + } +} diff --git a/samples/AnimationsTestApplication/Controls/SingleSelectionTabControl.cs b/samples/AnimationsTestApplication/Controls/SingleSelectionTabControl.cs new file mode 100644 index 000000000..d78caf4f6 --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/SingleSelectionTabControl.cs @@ -0,0 +1,17 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Controls; + +namespace AnimationsTestApplication.Controls; + +public class SingleSelectionTabControl : TabControl +{ + protected override Type StyleKeyOverride => typeof(TabControl); + + static SingleSelectionTabControl() + { + SelectionModeProperty.OverrideDefaultValue(SelectionMode.Single); + } +} diff --git a/samples/AnimationsTestApplication/Controls/TiltAnimationDemoControl.cs b/samples/AnimationsTestApplication/Controls/TiltAnimationDemoControl.cs new file mode 100644 index 000000000..fceb553e3 --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/TiltAnimationDemoControl.cs @@ -0,0 +1,55 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Adapts pointer input to the standalone tilt animation API. +/// +public class TiltAnimationDemoControl : ContentControl +{ + public static readonly StyledProperty TiltStrengthProperty = + AvaloniaProperty.Register(nameof(TiltStrength), 12d); + + public double TiltStrength + { + get => GetValue(TiltStrengthProperty); + set => SetValue(TiltStrengthProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + SizeChanged += OnSizeChanged; + TiltAnimation.UpdateCenterPoint(this); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + SizeChanged -= OnSizeChanged; + base.OnDetachedFromVisualTree(e); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + TiltAnimation.Apply(this, e.GetPosition(this), TiltStrength); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + TiltAnimation.Reset(this); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + TiltAnimation.UpdateCenterPoint(this); + } +} diff --git a/samples/AnimationsTestApplication/Controls/TransitionOperationsDemoControl.cs b/samples/AnimationsTestApplication/Controls/TransitionOperationsDemoControl.cs new file mode 100644 index 000000000..0844ea94f --- /dev/null +++ b/samples/AnimationsTestApplication/Controls/TransitionOperationsDemoControl.cs @@ -0,0 +1,83 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; +using Avalonia.Xaml.Interactions.Custom; + +namespace AnimationsTestApplication.Controls; + +/// +/// Demonstrates direct transition collection operations. +/// +public class TransitionOperationsDemoControl : ContentControl +{ + private IDisposable? _transitionSubscription; + private DoubleTransition? _transition; + private int _transitionChangeCount; + private bool _dimmed; + + /// + /// Identifies the direct property. + /// + public static readonly DirectProperty TransitionChangeCountProperty = + AvaloniaProperty.RegisterDirect( + nameof(TransitionChangeCount), + control => control.TransitionChangeCount); + + /// + /// Gets the number of transition collection values reported by the active observation. + /// + public int TransitionChangeCount + { + get => _transitionChangeCount; + private set => SetAndRaise(TransitionChangeCountProperty, ref _transitionChangeCount, value); + } + + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register( + nameof(Duration), + TimeSpan.FromMilliseconds(300)); + + public TimeSpan Duration + { + get => GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + TransitionChangeCount = 0; + _transitionSubscription = TransitionOperations.Observe( + this, + _ => TransitionChangeCount++); + _transition = new DoubleTransition + { + Property = OpacityProperty, + Duration = Duration + }; + TransitionOperations.Add(this, _transition); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + TransitionOperations.Remove(this, _transition); + _transitionSubscription?.Dispose(); + _transitionSubscription = null; + _transition = null; + base.OnDetachedFromVisualTree(e); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + _dimmed = !_dimmed; + Opacity = _dimmed ? 0.25d : 1d; + e.Handled = true; + } +} diff --git a/samples/AnimationsTestApplication/Program.cs b/samples/AnimationsTestApplication/Program.cs new file mode 100644 index 000000000..e8eec2cc6 --- /dev/null +++ b/samples/AnimationsTestApplication/Program.cs @@ -0,0 +1,24 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia; + +namespace AnimationsTestApplication; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .WithInterFont() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/samples/AnimationsTestApplication/README.md b/samples/AnimationsTestApplication/README.md new file mode 100644 index 000000000..675de49b5 --- /dev/null +++ b/samples/AnimationsTestApplication/README.md @@ -0,0 +1,19 @@ +# Animations sample application + +This application demonstrates `Xaml.Behaviors.Animations` without referencing `Xaml.Behaviors.Interactivity` or any interactions package. + +The left-hand `SingleSelectionTabControl` and sidebar theme mirror the main behaviors sample. Each page focuses on one standalone feature family: + +- key-frame factory, builder, and synchronous/asynchronous runners; +- fluid movement with automatic transform preparation; +- fade, sliding, scale, and rotation primitives; +- attention, entrance, exit, Framer Motion, and special catalogs; +- selection indicator animation; +- orbit, tilt, and parallax composition effects; +- transition collection mutation and observation operations. + +Run it from the repository root: + +```bash +dotnet run --project samples/AnimationsTestApplication/AnimationsTestApplication.csproj +``` diff --git a/samples/AnimationsTestApplication/SideBar.axaml b/samples/AnimationsTestApplication/SideBar.axaml new file mode 100644 index 000000000..6312ecf79 --- /dev/null +++ b/samples/AnimationsTestApplication/SideBar.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/MainView.axaml b/samples/AnimationsTestApplication/Views/MainView.axaml new file mode 100644 index 000000000..c948f8ecc --- /dev/null +++ b/samples/AnimationsTestApplication/Views/MainView.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/MainView.axaml.cs b/samples/AnimationsTestApplication/Views/MainView.axaml.cs new file mode 100644 index 000000000..fe660e4a8 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/MainView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views; + +public partial class MainView : UserControl +{ + public MainView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/MainWindow.axaml b/samples/AnimationsTestApplication/Views/MainWindow.axaml new file mode 100644 index 000000000..ad5e94842 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/MainWindow.axaml @@ -0,0 +1,12 @@ + + + diff --git a/samples/AnimationsTestApplication/Views/MainWindow.axaml.cs b/samples/AnimationsTestApplication/Views/MainWindow.axaml.cs new file mode 100644 index 000000000..f1f21db51 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/MainWindow.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml new file mode 100644 index 000000000..673a72164 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml.cs new file mode 100644 index 000000000..1e71dd81a --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/AttentionAnimationsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class AttentionAnimationsView : UserControl +{ + public AttentionAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml new file mode 100644 index 000000000..41283081e --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml.cs new file mode 100644 index 000000000..f44d749ce --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/EntranceAnimationsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class EntranceAnimationsView : UserControl +{ + public EntranceAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml new file mode 100644 index 000000000..13f1d1676 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml.cs new file mode 100644 index 000000000..108e0a9fe --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ExitAnimationsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class ExitAnimationsView : UserControl +{ + public ExitAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml new file mode 100644 index 000000000..c8a00fab9 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml.cs new file mode 100644 index 000000000..cd650814c --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FadeAnimationView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class FadeAnimationView : UserControl +{ + public FadeAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml new file mode 100644 index 000000000..1719dccdf --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml.cs new file mode 100644 index 000000000..15487fe55 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FluidMoveAnimationView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class FluidMoveAnimationView : UserControl +{ + public FluidMoveAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml new file mode 100644 index 000000000..92674b120 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml.cs new file mode 100644 index 000000000..7001d018b --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/FramerMotionAnimationsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class FramerMotionAnimationsView : UserControl +{ + public FramerMotionAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml new file mode 100644 index 000000000..2adf389cd --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml.cs new file mode 100644 index 000000000..2e0a7b48b --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/KeyFrameAnimationsView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class KeyFrameAnimationsView : UserControl +{ + public KeyFrameAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml new file mode 100644 index 000000000..f23108d02 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml.cs new file mode 100644 index 000000000..e0a1f74d9 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/OrbitAnimationView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class OrbitAnimationView : UserControl +{ + public OrbitAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml new file mode 100644 index 000000000..9503f9d0b --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml.cs new file mode 100644 index 000000000..6a64b9738 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ParallaxAnimationView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class ParallaxAnimationView : UserControl +{ + public ParallaxAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml new file mode 100644 index 000000000..25472b09c --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml.cs new file mode 100644 index 000000000..907467c93 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/RotateAnimationView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class RotateAnimationView : UserControl +{ + public RotateAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml new file mode 100644 index 000000000..5c0a1a8b0 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml.cs new file mode 100644 index 000000000..5a88f705c --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/ScaleAnimationView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class ScaleAnimationView : UserControl +{ + public ScaleAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml new file mode 100644 index 000000000..8d6af3f4e --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml.cs new file mode 100644 index 000000000..ace16e281 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SelectionAnimationView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class SelectionAnimationView : UserControl +{ + public SelectionAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml new file mode 100644 index 000000000..0c0a84c6e --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml.cs new file mode 100644 index 000000000..feb6294cf --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SlidingAnimationView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class SlidingAnimationView : UserControl +{ + public SlidingAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml new file mode 100644 index 000000000..a197b4a76 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml.cs new file mode 100644 index 000000000..b6bf4ac34 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/SpecialAnimationsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class SpecialAnimationsView : UserControl +{ + public SpecialAnimationsView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml b/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml new file mode 100644 index 000000000..2d93d117a --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml.cs new file mode 100644 index 000000000..f0b38aaae --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/TiltAnimationView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class TiltAnimationView : UserControl +{ + public TiltAnimationView() + { + InitializeComponent(); + } +} diff --git a/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml b/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml new file mode 100644 index 000000000..567043673 --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + diff --git a/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml.cs b/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml.cs new file mode 100644 index 000000000..bfca449ab --- /dev/null +++ b/samples/AnimationsTestApplication/Views/Pages/TransitionOperationsView.axaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia.Controls; + +namespace AnimationsTestApplication.Views.Pages; + +public partial class TransitionOperationsView : UserControl +{ + public TransitionOperationsView() + { + InitializeComponent(); + } +} diff --git a/samples/BehaviorsTestApplication/BehaviorsTestApplication.csproj b/samples/BehaviorsTestApplication/BehaviorsTestApplication.csproj index d3ab06353..6f5300fb8 100644 --- a/samples/BehaviorsTestApplication/BehaviorsTestApplication.csproj +++ b/samples/BehaviorsTestApplication/BehaviorsTestApplication.csproj @@ -50,6 +50,7 @@ + diff --git a/src/Xaml.Behaviors.Animations/Animations/AnimationFactory.cs b/src/Xaml.Behaviors.Animations/Animations/AnimationFactory.cs new file mode 100644 index 000000000..40d0c79ce --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Animations/AnimationFactory.cs @@ -0,0 +1,77 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Animation; +using Avalonia.Styling; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Creates commonly used Avalonia key-frame animations. +/// +public static class AnimationFactory +{ + /// + /// Creates an opacity animation that holds at zero during the initial delay and then fades to one. + /// + /// The delay before the fade begins. + /// The duration of the fade. + /// The configured fade-in animation. + /// + /// Thrown when or is negative. + /// + public static Animation.Animation CreateFadeIn(TimeSpan initialDelay, TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(initialDelay, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + + TimeSpan totalDuration = initialDelay + duration; + return CreateFadeInTimeline(initialDelay, totalDuration, totalDuration); + } + + /// + /// Creates an opacity animation with explicit hold, completion, and total times. + /// + /// The key time through which opacity remains zero. + /// The key time at which opacity becomes one. + /// The total animation duration. + /// The configured fade-in animation. + /// + /// Thrown when any supplied time is negative or the key times are not chronological. + /// + public static Animation.Animation CreateFadeInTimeline( + TimeSpan initialDelay, + TimeSpan fadeCompletionTime, + TimeSpan totalDuration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(initialDelay, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(fadeCompletionTime, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(totalDuration, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfGreaterThan(initialDelay, fadeCompletionTime); + ArgumentOutOfRangeException.ThrowIfGreaterThan(fadeCompletionTime, totalDuration); + + return new Animation.Animation + { + Duration = totalDuration, + Children = + { + new KeyFrame + { + KeyTime = TimeSpan.Zero, + Setters = { new Setter(Visual.OpacityProperty, 0d) } + }, + new KeyFrame + { + KeyTime = initialDelay, + Setters = { new Setter(Visual.OpacityProperty, 0d) } + }, + new KeyFrame + { + KeyTime = fadeCompletionTime, + Setters = { new Setter(Visual.OpacityProperty, 1d) } + } + } + }; + } +} diff --git a/src/Xaml.Behaviors.Animations/Animations/AnimationRunner.cs b/src/Xaml.Behaviors.Animations/Animations/AnimationRunner.cs new file mode 100644 index 000000000..82e81d89a --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Animations/AnimationRunner.cs @@ -0,0 +1,85 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System.Threading.Tasks; +using Avalonia.Animation; +using Avalonia.Controls; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Builds and runs Avalonia animations independently of behaviors, actions, and triggers. +/// +public static class AnimationRunner +{ + /// + /// Runs an animation on an animatable target. + /// + /// The animation to run. + /// The target of the animation. + /// A task that completes when the animation finishes. + public static Task RunAsync(Animation.Animation animation, Animatable target) + { + return animation.RunAsync(target); + } + + /// + /// Starts an animation when both the animation and target are available. + /// + /// The animation to run. + /// The target of the animation. + /// true when the animation was started; otherwise, false. + public static bool TryRun(Animation.Animation? animation, Animatable? target) + { + return TryRunAsync(animation, target) is not null; + } + + /// + /// Starts an animation when both the animation and target are available and returns its completion task. + /// + /// The animation to run. + /// The target of the animation. + /// The animation completion task, or null when the animation could not be started. + public static Task? TryRunAsync(Animation.Animation? animation, Animatable? target) + { + return animation is not null && target is not null + ? RunAsync(animation, target) + : null; + } + + /// + /// Selects an explicit animation or builds one and starts it on a control. + /// + /// The target control. + /// The preferred animation. + /// The fallback animation builder. + /// true when an animation was started; otherwise, false. + public static bool TryBuildAndRun( + Control? control, + Animation.Animation? animation, + IAnimationBuilder? animationBuilder) + { + return TryBuildAndRunAsync(control, animation, animationBuilder) is not null; + } + + /// + /// Selects an explicit animation or builds one, starts it on a control, and returns its completion task. + /// + /// The target control. + /// The preferred animation. + /// The fallback animation builder. + /// The animation completion task, or null when no animation could be started. + public static Task? TryBuildAndRunAsync( + Control? control, + Animation.Animation? animation, + IAnimationBuilder? animationBuilder) + { + if (control is null) + { + return null; + } + + Animation.Animation? selectedAnimation = animation ?? animationBuilder?.Build(control); + return TryRunAsync(selectedAnimation, control); + } +} diff --git a/src/Xaml.Behaviors.Animations/Animations/FluidMoveAnimation.cs b/src/Xaml.Behaviors.Animations/Animations/FluidMoveAnimation.cs new file mode 100644 index 000000000..bec879d3a --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Animations/FluidMoveAnimation.cs @@ -0,0 +1,102 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Creates and runs the translation animation used for fluid layout movement. +/// +public static class FluidMoveAnimation +{ + /// + /// Creates an animation from a previous layout offset back to the current position. + /// + /// The previous horizontal offset. + /// The previous vertical offset. + /// The animation duration. + /// The configured translation animation. + /// Thrown when is negative. + public static Animation.Animation Create(double offsetX, double offsetY, TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + + return new Animation.Animation + { + Duration = duration, + FillMode = FillMode.Forward, + Children = + { + new KeyFrame + { + Cue = new Cue(0d), + Setters = + { + new Setter(TranslateTransform.XProperty, offsetX), + new Setter(TranslateTransform.YProperty, offsetY) + } + }, + new KeyFrame + { + Cue = new Cue(1d), + Setters = + { + new Setter(TranslateTransform.XProperty, 0d), + new Setter(TranslateTransform.YProperty, 0d) + } + } + } + }; + } + + /// + /// Creates and starts a fluid movement animation on a control. + /// + /// The target control. + /// The previous horizontal offset. + /// The previous vertical offset. + /// The animation duration. + public static void Run(Control target, double offsetX, double offsetY, TimeSpan duration) + { + ArgumentNullException.ThrowIfNull(target); + PrepareTransform(target, offsetX, offsetY); + Animation.Animation animation = Create(offsetX, offsetY, duration); + AnimationRunner.TryRun(animation, target); + } + + /// + /// Prepares a control's translation transform and starts a fluid movement animation. + /// + /// The target control. + /// The previous horizontal offset. + /// The previous vertical offset. + /// The animation duration. + /// true when the target was prepared and the animation was started; otherwise, false. + public static bool TryRun(Control? target, double offsetX, double offsetY, TimeSpan duration) + { + if (target is null) + { + return false; + } + + Run(target, offsetX, offsetY, duration); + return true; + } + + private static void PrepareTransform(Control target, double offsetX, double offsetY) + { + if (target.RenderTransform is not TranslateTransform transform) + { + transform = new TranslateTransform(); + target.RenderTransform = transform; + } + + transform.X = offsetX; + transform.Y = offsetY; + } +} diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/Contract/IAnimationBuilder.cs b/src/Xaml.Behaviors.Animations/Animations/IAnimationBuilder.cs similarity index 100% rename from src/Xaml.Behaviors.Interactions.Custom/Animations/Contract/IAnimationBuilder.cs rename to src/Xaml.Behaviors.Animations/Animations/IAnimationBuilder.cs diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/AttentionAnimations.cs b/src/Xaml.Behaviors.Animations/Composition/AttentionAnimations.cs similarity index 97% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/AttentionAnimations.cs rename to src/Xaml.Behaviors.Animations/Composition/AttentionAnimations.cs index 1fa8425f9..04b1dd9df 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/AttentionAnimations.cs +++ b/src/Xaml.Behaviors.Animations/Composition/AttentionAnimations.cs @@ -109,9 +109,9 @@ private static void Run(Control element, double milliseconds, Action keyFrames) + { + if (keyFrames.Count == 0) + { + return; + } + + Vector3 layoutOffset = GetLayoutOffset(element); + ValidateDuration(duration); + if (duration == TimeSpan.Zero) + { + visual.Offset = layoutOffset + keyFrames[keyFrames.Count - 1].Value; + return; + } + + Vector3KeyFrameAnimation animation = visual.Compositor.CreateVector3KeyFrameAnimation(); + foreach (Vector3KeyFrame keyFrame in keyFrames) + { + animation.InsertKeyFrame(keyFrame.Progress, layoutOffset + keyFrame.Value); + } + + ConfigureAndStartAnimation(visual, "Offset", duration, animation); + } + + public static Vector3 GetLayoutOffset(Control element) + { + Rect bounds = element.Bounds; + return new Vector3((float)bounds.X, (float)bounds.Y, 0f); + } + + public static Vector3 GetLayoutOffset(Control element, Vector3 relativeOffset) + { + return GetLayoutOffset(element) + relativeOffset; + } + public static void StartScalarAnimation(CompositionVisual visual, string propertyName, TimeSpan duration, IReadOnlyList keyFrames) { if (keyFrames.Count == 0) @@ -98,6 +140,13 @@ public static void StartScalarAnimation(CompositionVisual visual, string propert return; } + ValidateDuration(duration); + if (duration == TimeSpan.Zero) + { + SetScalarValue(visual, propertyName, keyFrames[keyFrames.Count - 1].Value); + return; + } + var animation = visual.Compositor.CreateScalarKeyFrameAnimation(); foreach (var keyFrame in keyFrames) { @@ -107,6 +156,41 @@ public static void StartScalarAnimation(CompositionVisual visual, string propert ConfigureAndStartAnimation(visual, propertyName, duration, animation); } + private static void SetVector3Value(CompositionVisual visual, string propertyName, Vector3 value) + { + switch (propertyName) + { + case "Offset": + visual.Offset = value; + break; + case "Scale": + visual.Scale = value; + break; + default: + throw new ArgumentException($"Unsupported Vector3 composition property '{propertyName}'.", nameof(propertyName)); + } + } + + private static void SetScalarValue(CompositionVisual visual, string propertyName, float value) + { + switch (propertyName) + { + case "Opacity": + visual.Opacity = value; + break; + case "RotationAngle": + visual.RotationAngle = value; + break; + default: + throw new ArgumentException($"Unsupported scalar composition property '{propertyName}'.", nameof(propertyName)); + } + } + + private static void ValidateDuration(TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + } + private static void ConfigureAndStartAnimation(CompositionVisual visual, string propertyName, TimeSpan duration, CompositionAnimation animation) { if (animation is KeyFrameAnimation keyFrameAnimation) diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/EntranceAnimations.cs b/src/Xaml.Behaviors.Animations/Composition/EntranceAnimations.cs similarity index 98% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/EntranceAnimations.cs rename to src/Xaml.Behaviors.Animations/Composition/EntranceAnimations.cs index 2881d24f5..4add1b44d 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/EntranceAnimations.cs +++ b/src/Xaml.Behaviors.Animations/Composition/EntranceAnimations.cs @@ -178,7 +178,7 @@ public static void SetLightSpeedInRight(Control element, double milliseconds) => /// Applies the animate.css rotateIn animation. /// public static void SetRotateIn(Control element, double milliseconds) => - Run(element, milliseconds, () => CreateRotateIn( -90f, new Vector2(0.5f, 0.5f)), ensureCenterPoint: true); + Run(element, milliseconds, () => CreateRotateIn(-90f, new Vector2(0.5f, 0.5f)), ensureCenterPoint: true); /// /// Applies the animate.css rotateInDownLeft animation. @@ -284,7 +284,7 @@ private static void Run(Control element, double milliseconds, Func 0 }) { - CompositionAnimationHelpers.StartVector3Animation(visual, "Offset", duration, definition.OffsetFrames); + CompositionAnimationHelpers.StartOffsetAnimation(element, visual, duration, definition.OffsetFrames); } if (definition.ScaleFrames is { Length: > 0 }) diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/ExitAnimations.cs b/src/Xaml.Behaviors.Animations/Composition/ExitAnimations.cs similarity index 99% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/ExitAnimations.cs rename to src/Xaml.Behaviors.Animations/Composition/ExitAnimations.cs index a587877c7..f617b3cb4 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/ExitAnimations.cs +++ b/src/Xaml.Behaviors.Animations/Composition/ExitAnimations.cs @@ -362,7 +362,7 @@ private static void Run(Control element, double milliseconds, Func 0 }) { - CompositionAnimationHelpers.StartVector3Animation(visual, "Offset", duration, definition.OffsetFrames); + CompositionAnimationHelpers.StartOffsetAnimation(element, visual, duration, definition.OffsetFrames); } if (definition.ScaleFrames is { Length: > 0 }) diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/FadeAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/FadeAnimation.cs similarity index 80% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/FadeAnimation.cs rename to src/Xaml.Behaviors.Animations/Composition/FadeAnimation.cs index 85e8854bc..c2ee7337c 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/FadeAnimation.cs +++ b/src/Xaml.Behaviors.Animations/Composition/FadeAnimation.cs @@ -1,10 +1,8 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; -using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; namespace Avalonia.Xaml.Interactions.Custom; @@ -69,16 +67,14 @@ private static void Apply(Visual visual, float fromOpacity, float toOpacity, Tim return; } - var compositor = compositionVisual.Compositor; - - var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); - opacityAnimation.InsertKeyFrame(0.0f, fromOpacity); - opacityAnimation.InsertKeyFrame(1.0f, toOpacity); - opacityAnimation.Direction = PlaybackDirection.Normal; - opacityAnimation.Duration = duration; - opacityAnimation.IterationBehavior = AnimationIterationBehavior.Count; - opacityAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("Opacity", opacityAnimation); + CompositionAnimationHelpers.StartScalarAnimation( + compositionVisual, + "Opacity", + duration, + new CompositionAnimationHelpers.ScalarKeyFrame[] + { + new(0f, fromOpacity), + new(1f, toOpacity) + }); } -} \ No newline at end of file +} diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/FramerMotionAnimations.cs b/src/Xaml.Behaviors.Animations/Composition/FramerMotionAnimations.cs similarity index 98% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/FramerMotionAnimations.cs rename to src/Xaml.Behaviors.Animations/Composition/FramerMotionAnimations.cs index 449ea7e29..830f0d080 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/FramerMotionAnimations.cs +++ b/src/Xaml.Behaviors.Animations/Composition/FramerMotionAnimations.cs @@ -162,7 +162,7 @@ private static void Run(Control element, double milliseconds, Func 0 }) { - CompositionAnimationHelpers.StartVector3Animation(visual, "Offset", duration, definition.OffsetFrames); + CompositionAnimationHelpers.StartOffsetAnimation(element, visual, duration, definition.OffsetFrames); } if (definition.ScaleFrames is { Length: > 0 }) diff --git a/src/Xaml.Behaviors.Animations/Composition/OrbitAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/OrbitAnimation.cs new file mode 100644 index 000000000..f51e37575 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Composition/OrbitAnimation.cs @@ -0,0 +1,90 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Numerics; +using Avalonia.Controls; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Maintains and applies a composition orientation for an orbit-style pointer interaction. +/// +public sealed class OrbitAnimation +{ + /// + /// Gets the current orientation. + /// + public Quaternion Orientation { get; private set; } = Quaternion.Identity; + + /// + /// Calculates the next normalized orientation from a pointer delta. + /// + /// The current orientation. + /// The pointer movement since the previous update. + /// The rotation sensitivity. + /// The next normalized orientation. + public static Quaternion CalculateOrientation( + Quaternion orientation, + Vector pointerDelta, + double sensitivity) + { + float rotationAroundX = (float)(pointerDelta.Y * sensitivity * 0.01); + float rotationAroundY = (float)(pointerDelta.X * sensitivity * 0.01); + Quaternion rotationX = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -rotationAroundX); + Quaternion rotationY = Quaternion.CreateFromAxisAngle(Vector3.UnitY, rotationAroundY); + return Quaternion.Normalize(orientation * rotationX * rotationY); + } + + /// + /// Updates the orientation and applies it to a control's composition visual. + /// + /// The target control. + /// The pointer movement since the previous update. + /// The rotation sensitivity. + /// true when a composition visual was animated; otherwise, false. + public bool Rotate(Control? target, Vector pointerDelta, double sensitivity) + { + CompositionVisual? visual = target is null ? null : ElementComposition.GetElementVisual(target); + if (visual is null) + { + return false; + } + + Orientation = CalculateOrientation(Orientation, pointerDelta, sensitivity); + var animation = visual.Compositor.CreateQuaternionKeyFrameAnimation(); + animation.InsertKeyFrame(1f, Orientation); + animation.Duration = TimeSpan.FromMilliseconds(1); + visual.StartAnimation("Orientation", animation); + return true; + } + + /// + /// Updates a control's composition center point from its current bounds. + /// + /// The target control. + /// true when a composition visual was updated; otherwise, false. + public static bool UpdateCenterPoint(Control? target) + { + CompositionVisual? visual = target is null ? null : ElementComposition.GetElementVisual(target); + if (visual is null || target is null) + { + return false; + } + + visual.CenterPoint = new Vector3( + (float)target.Bounds.Width / 2f, + (float)target.Bounds.Height / 2f, + 0f); + return true; + } + + /// + /// Resets the maintained orientation to the identity quaternion. + /// + public void Reset() + { + Orientation = Quaternion.Identity; + } +} diff --git a/src/Xaml.Behaviors.Animations/Composition/ParallaxAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/ParallaxAnimation.cs new file mode 100644 index 000000000..c92c042f2 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Composition/ParallaxAnimation.cs @@ -0,0 +1,87 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System.Numerics; +using Avalonia.Controls; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Applies composition-based parallax offsets without requiring a behavior. +/// +public sealed class ParallaxAnimation +{ + private readonly Control _target; + private readonly CompositionVisual _visual; + + private ParallaxAnimation(Control target, CompositionVisual visual) + { + _target = target; + _visual = visual; + } + + /// + /// Calculates the composition offset for a scroll offset and parallax ratio. + /// + /// The current scroll offset. + /// The proportion of the scroll offset to apply. + /// The parallax delta to add to the target's layout offset. + public static Vector3 CalculateOffset(Vector scrollOffset, double parallaxRatio) + { + return new Vector3( + (float)(scrollOffset.X * parallaxRatio), + (float)(scrollOffset.Y * parallaxRatio), + 0f); + } + + /// + /// Creates a parallax animation session that retains the target's composition visual. + /// + /// The target control. + /// A parallax animation session, or null when the target visual is unavailable. + public static ParallaxAnimation? TryCreate(Control? target) + { + if (target is null) + { + return null; + } + + CompositionVisual? visual = ElementComposition.GetElementVisual(target); + return visual is null ? null : new ParallaxAnimation(target, visual); + } + + /// + /// Applies a calculated parallax offset to the retained composition visual. + /// + /// The current scroll offset. + /// The proportion of the scroll offset to apply. + public void Apply(Vector scrollOffset, double parallaxRatio) + { + Vector3 delta = CalculateOffset(scrollOffset, parallaxRatio); + Rect bounds = _target.Bounds; + _visual.Offset = new Vector3( + (float)bounds.Left + delta.X, + (float)bounds.Top + delta.Y, + delta.Z); + } + + /// + /// Applies a calculated parallax offset to a control's composition visual. + /// + /// The target control. + /// The current scroll offset. + /// The proportion of the scroll offset to apply. + /// true when a composition visual was updated; otherwise, false. + public static bool Apply(Control? target, Vector scrollOffset, double parallaxRatio) + { + ParallaxAnimation? animation = TryCreate(target); + if (animation is null) + { + return false; + } + + animation.Apply(scrollOffset, parallaxRatio); + return true; + } +} diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/RotateAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/RotateAnimation.cs similarity index 78% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/RotateAnimation.cs rename to src/Xaml.Behaviors.Animations/Composition/RotateAnimation.cs index caa4e4d0b..4fdcbab03 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/RotateAnimation.cs +++ b/src/Xaml.Behaviors.Animations/Composition/RotateAnimation.cs @@ -1,10 +1,8 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; -using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; namespace Avalonia.Xaml.Interactions.Custom; @@ -121,19 +119,17 @@ private static void Apply(Visual visual, float fromAngle, float toAngle, TimeSpa return; } - var compositor = compositionVisual.Compositor; - - var rotationAnimation = compositor.CreateScalarKeyFrameAnimation(); var fromRadians = CompositionAnimationHelpers.DegreesToRadians(fromAngle); var toRadians = CompositionAnimationHelpers.DegreesToRadians(toAngle); - rotationAnimation.InsertKeyFrame(0.0f, fromRadians); - rotationAnimation.InsertKeyFrame(1.0f, toRadians); - rotationAnimation.Direction = PlaybackDirection.Normal; - rotationAnimation.Duration = duration; - rotationAnimation.IterationBehavior = AnimationIterationBehavior.Count; - rotationAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("RotationAngle", rotationAnimation); + CompositionAnimationHelpers.StartScalarAnimation( + compositionVisual, + "RotationAngle", + duration, + new CompositionAnimationHelpers.ScalarKeyFrame[] + { + new(0f, fromRadians), + new(1f, toRadians) + }); } /// @@ -149,19 +145,17 @@ private static void ApplySwing(Visual visual, TimeSpan duration) return; } - var compositor = compositionVisual.Compositor; - - var rotationAnimation = compositor.CreateScalarKeyFrameAnimation(); - rotationAnimation.InsertKeyFrame(0.0f, 0f); - rotationAnimation.InsertKeyFrame(0.25f, CompositionAnimationHelpers.DegreesToRadians(15f)); - rotationAnimation.InsertKeyFrame(0.5f, 0f); - rotationAnimation.InsertKeyFrame(0.75f, CompositionAnimationHelpers.DegreesToRadians(-15f)); - rotationAnimation.InsertKeyFrame(1.0f, 0f); - rotationAnimation.Direction = PlaybackDirection.Normal; - rotationAnimation.Duration = duration; - rotationAnimation.IterationBehavior = AnimationIterationBehavior.Count; - rotationAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("RotationAngle", rotationAnimation); + CompositionAnimationHelpers.StartScalarAnimation( + compositionVisual, + "RotationAngle", + duration, + new CompositionAnimationHelpers.ScalarKeyFrame[] + { + new(0f, 0f), + new(0.25f, CompositionAnimationHelpers.DegreesToRadians(15f)), + new(0.5f, 0f), + new(0.75f, CompositionAnimationHelpers.DegreesToRadians(-15f)), + new(1f, 0f) + }); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/ScaleAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/ScaleAnimation.cs similarity index 79% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/ScaleAnimation.cs rename to src/Xaml.Behaviors.Animations/Composition/ScaleAnimation.cs index 1405dcfed..b685d996f 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/ScaleAnimation.cs +++ b/src/Xaml.Behaviors.Animations/Composition/ScaleAnimation.cs @@ -2,10 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; using System.Numerics; -using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; namespace Avalonia.Xaml.Interactions.Custom; @@ -111,17 +109,15 @@ private static void Apply(Visual visual, Vector3 fromScale, Vector3 toScale, Tim return; } - var compositor = compositionVisual.Compositor; - - var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); - scaleAnimation.InsertKeyFrame(0.0f, fromScale); - scaleAnimation.InsertKeyFrame(1.0f, toScale); - scaleAnimation.Direction = PlaybackDirection.Normal; - scaleAnimation.Duration = duration; - scaleAnimation.IterationBehavior = AnimationIterationBehavior.Count; - scaleAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("Scale", scaleAnimation); + CompositionAnimationHelpers.StartVector3Animation( + compositionVisual, + "Scale", + duration, + new CompositionAnimationHelpers.Vector3KeyFrame[] + { + new(0f, fromScale), + new(1f, toScale) + }); } /// @@ -137,18 +133,16 @@ private static void ApplyBounce(Visual visual, TimeSpan duration) return; } - var compositor = compositionVisual.Compositor; - - var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); - scaleAnimation.InsertKeyFrame(0.0f, new Vector3(0, 0, 0)); - scaleAnimation.InsertKeyFrame(0.5f, new Vector3(1.1f, 1.1f, 1)); - scaleAnimation.InsertKeyFrame(0.75f, new Vector3(0.9f, 0.9f, 1)); - scaleAnimation.InsertKeyFrame(1.0f, Vector3.One); - scaleAnimation.Direction = PlaybackDirection.Normal; - scaleAnimation.Duration = duration; - scaleAnimation.IterationBehavior = AnimationIterationBehavior.Count; - scaleAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("Scale", scaleAnimation); + CompositionAnimationHelpers.StartVector3Animation( + compositionVisual, + "Scale", + duration, + new CompositionAnimationHelpers.Vector3KeyFrame[] + { + new(0f, Vector3.Zero), + new(0.5f, new Vector3(1.1f, 1.1f, 1f)), + new(0.75f, new Vector3(0.9f, 0.9f, 1f)), + new(1f, Vector3.One) + }); } -} \ No newline at end of file +} diff --git a/src/Xaml.Behaviors.Animations/Composition/SelectingItemsControlBehavior.cs b/src/Xaml.Behaviors.Animations/Composition/SelectingItemsControlBehavior.cs new file mode 100644 index 000000000..db3ec9289 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Composition/SelectingItemsControlBehavior.cs @@ -0,0 +1,86 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +// Based on code: https://github.com/adirh3/Avalonia.ListBoxAnimation.Samples + +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Enables the standard selection indicator animation on selecting items controls. +/// +public class SelectingItemsControlBehavior +{ + /// + /// Identifies the attached property. + /// + public static readonly AttachedProperty EnableSelectionAnimationProperty = + AvaloniaProperty.RegisterAttached("EnableSelectionAnimation", + typeof(SelectingItemsControlBehavior)); + + /// + /// Initializes the attached-property change handler. + /// + static SelectingItemsControlBehavior() + { + EnableSelectionAnimationProperty.Changed.AddClassHandler(OnEnableSelectionAnimation); + } + + private static void OnEnableSelectionAnimation(Control control, AvaloniaPropertyChangedEventArgs args) + { + if (control is SelectingItemsControl selectingItemsControl) + { + if (args.NewValue is true) + { + selectingItemsControl.PropertyChanged += SelectingItemsControlPropertyChanged; + } + else + { + selectingItemsControl.PropertyChanged -= SelectingItemsControlPropertyChanged; + } + } + } + + private static void SelectingItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs args) + { + if (sender is not SelectingItemsControl selectingItemsControl || + args.Property != SelectingItemsControl.SelectedIndexProperty || + args.OldValue is not int oldIndex || args.NewValue is not int newIndex) + { + return; + } + + if (selectingItemsControl.ContainerFromIndex(newIndex) is not TemplatedControl newSelection + || selectingItemsControl.ContainerFromIndex(oldIndex) is not TemplatedControl oldSelection) + { + return; + } + + SelectionIndicatorAnimation.TryStart( + newSelection, + oldSelection, + SelectionIndicatorAnimation.DefaultDuration); + } + + /// + /// Gets whether selection indicator animation is enabled for an element. + /// + /// The selecting items control. + /// true when selection animation is enabled; otherwise, false. + public static bool GetEnableSelectionAnimation(SelectingItemsControl element) + { + return element.GetValue(EnableSelectionAnimationProperty); + } + + /// + /// Sets whether selection indicator animation is enabled for an element. + /// + /// The selecting items control. + /// The value to set. + public static void SetEnableSelectionAnimation(SelectingItemsControl element, bool value) + { + element.SetValue(EnableSelectionAnimationProperty, value); + } +} diff --git a/src/Xaml.Behaviors.Animations/Composition/SelectionIndicatorAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/SelectionIndicatorAnimation.cs new file mode 100644 index 000000000..81769d6c4 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Composition/SelectionIndicatorAnimation.cs @@ -0,0 +1,135 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Linq; +using System.Numerics; +using Avalonia.Animation.Easings; +using Avalonia.Controls.Primitives; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Animations; +using Avalonia.VisualTree; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Creates and applies the composition animation used to move a selection indicator between item containers. +/// +public static class SelectionIndicatorAnimation +{ + private const string IndicatorPartName = "PART_SelectedPipe"; + + /// + /// Gets the default selection indicator animation duration. + /// + public static TimeSpan DefaultDuration { get; } = TimeSpan.FromMilliseconds(250); + + /// + /// Finds the standard selection indicator parts and starts their movement animation. + /// + /// The newly selected item container. + /// The previously selected item container. + /// The animation duration. + /// true when composition visuals were available and the animation was installed; otherwise, false. + /// Thrown when is negative. + public static bool TryStart( + TemplatedControl? newSelection, + TemplatedControl? oldSelection, + TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + + if (newSelection is null || oldSelection is null) + { + return false; + } + + Visual? newIndicator = newSelection.GetVisualDescendants() + .FirstOrDefault(visual => visual.Name == IndicatorPartName); + Visual? oldIndicator = oldSelection.GetVisualDescendants() + .FirstOrDefault(visual => visual.Name == IndicatorPartName); + + return TryStart( + newIndicator, + oldIndicator, + newSelection, + oldSelection, + duration); + } + + /// + /// Starts a selection indicator movement animation using explicitly supplied visuals. + /// + /// The indicator visual in the newly selected container. + /// The indicator visual in the previously selected container. + /// The newly selected container visual. + /// The previously selected container visual. + /// The animation duration. + /// true when composition visuals were available and the animation was installed; otherwise, false. + /// Thrown when is negative. + public static bool TryStart( + Visual? newIndicator, + Visual? oldIndicator, + Visual? newSelection, + Visual? oldSelection, + TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + + if (duration == TimeSpan.Zero || + newIndicator is null || oldIndicator is null || newSelection is null || oldSelection is null) + { + return false; + } + + ElementComposition.GetElementVisual(oldIndicator)?.ImplicitAnimations?.Clear(); + + CompositionVisual? indicatorVisual = ElementComposition.GetElementVisual(newIndicator); + CompositionVisual? newSelectionVisual = ElementComposition.GetElementVisual(newSelection); + CompositionVisual? oldSelectionVisual = ElementComposition.GetElementVisual(oldSelection); + if (indicatorVisual is null || newSelectionVisual is null || oldSelectionVisual is null) + { + return false; + } + + Vector3D selectionOffset = oldSelectionVisual.Offset - newSelectionVisual.Offset; + bool isVerticalOffset = selectionOffset.Y != 0f; + double offset = isVerticalOffset ? selectionOffset.Y : selectionOffset.X; + Compositor compositor = indicatorVisual.Compositor; + var springEasing = new SpringEasing(); + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + string expression = (offset > 0d ? "+" : "-") + Math.Abs(offset); + offsetAnimation.InsertExpressionKeyFrame( + 0f, + isVerticalOffset + ? $"Vector3(this.FinalValue.X, this.FinalValue.Y{expression}, 0)" + : $"Vector3(this.FinalValue.X{expression}, this.FinalValue.Y, 0)"); + offsetAnimation.InsertExpressionKeyFrame(1f, "this.FinalValue"); + offsetAnimation.Duration = duration; + + var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.Target = "Scale"; + scaleAnimation.InsertKeyFrame(0f, Vector3.One, springEasing); + scaleAnimation.InsertKeyFrame( + 0.5f, + new Vector3( + 1f + (!isVerticalOffset ? 0.75f : 0f), + 1f + (isVerticalOffset ? 0.75f : 0f), + 1f), + springEasing); + scaleAnimation.InsertKeyFrame(1f, Vector3.One, springEasing); + scaleAnimation.Duration = duration; + + CompositionAnimationGroup animationGroup = compositor.CreateAnimationGroup(); + animationGroup.Add(offsetAnimation); + animationGroup.Add(scaleAnimation); + + ImplicitAnimationCollection implicitAnimations = compositor.CreateImplicitAnimationCollection(); + double currentOffset = isVerticalOffset ? indicatorVisual.Offset.Y : indicatorVisual.Offset.X; + implicitAnimations[currentOffset == 0d ? "Offset" : "Visible"] = animationGroup; + indicatorVisual.ImplicitAnimations = implicitAnimations; + return true; + } +} diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/SlidingAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/SlidingAnimation.cs similarity index 74% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/SlidingAnimation.cs rename to src/Xaml.Behaviors.Animations/Composition/SlidingAnimation.cs index a18761340..af646c1b3 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/SlidingAnimation.cs +++ b/src/Xaml.Behaviors.Animations/Composition/SlidingAnimation.cs @@ -2,10 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for details. using System; using System.Numerics; -using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; namespace Avalonia.Xaml.Interactions.Custom; @@ -73,7 +71,7 @@ public static void SetBottom(Control element, double milliseconds) /// /// /// - private static void Apply(Visual visual, double offsetX, double offsetY, TimeSpan duration) + private static void Apply(Control visual, double offsetX, double offsetY, TimeSpan duration) { var compositionVisual = ElementComposition.GetElementVisual(visual); if (compositionVisual is null) @@ -81,16 +79,14 @@ private static void Apply(Visual visual, double offsetX, double offsetY, TimeSpa return; } - var compositor = compositionVisual.Compositor; - - var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); - offsetAnimation.InsertKeyFrame(0.0f, new Vector3((float)offsetX, (float)offsetY, 0)); - offsetAnimation.InsertKeyFrame(1.0f, new Vector3(0, 0, 0)); - offsetAnimation.Direction = PlaybackDirection.Normal; - offsetAnimation.Duration = duration; - offsetAnimation.IterationBehavior = AnimationIterationBehavior.Count; - offsetAnimation.IterationCount = 1; - - compositionVisual.StartAnimation("Offset", offsetAnimation); + CompositionAnimationHelpers.StartOffsetAnimation( + visual, + compositionVisual, + duration, + new CompositionAnimationHelpers.Vector3KeyFrame[] + { + new(0.0f, new Vector3((float)offsetX, (float)offsetY, 0f)), + new(1.0f, Vector3.Zero) + }); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/SpecialAnimations.cs b/src/Xaml.Behaviors.Animations/Composition/SpecialAnimations.cs similarity index 100% rename from src/Xaml.Behaviors.Interactions.Custom/Composition/SpecialAnimations.cs rename to src/Xaml.Behaviors.Animations/Composition/SpecialAnimations.cs diff --git a/src/Xaml.Behaviors.Animations/Composition/TiltAnimation.cs b/src/Xaml.Behaviors.Animations/Composition/TiltAnimation.cs new file mode 100644 index 000000000..f9ae6aefd --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Composition/TiltAnimation.cs @@ -0,0 +1,129 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Numerics; +using Avalonia.Controls; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Calculates and applies composition-based pointer tilt animations. +/// +public static class TiltAnimation +{ + /// + /// Calculates a tilt orientation for a pointer position inside a target size. + /// + /// The target size. + /// The pointer position relative to the target. + /// The maximum tilt angle in degrees. + /// The calculated orientation, or identity for an empty size or central position. + public static Quaternion CalculateOrientation(Size size, Point pointerPosition, double tiltStrength) + { + return TryCalculateOrientation(size, pointerPosition, tiltStrength, out Quaternion orientation) + ? orientation + : Quaternion.Identity; + } + + /// + /// Tries to calculate a non-neutral tilt orientation for a pointer position inside a target size. + /// + /// The target size. + /// The pointer position relative to the target. + /// The maximum tilt angle in degrees. + /// The calculated orientation when the method returns true. + /// true when a non-neutral orientation was calculated; otherwise, false. + public static bool TryCalculateOrientation( + Size size, + Point pointerPosition, + double tiltStrength, + out Quaternion orientation) + { + orientation = Quaternion.Identity; + if (size.Width <= 0d || size.Height <= 0d) + { + return false; + } + + double centerX = size.Width / 2d; + double centerY = size.Height / 2d; + double xDifference = (pointerPosition.X - centerX) / centerX; + double yDifference = (pointerPosition.Y - centerY) / centerY; + var axis = new Vector3((float)-yDifference, (float)xDifference, 0f); + if (axis.LengthSquared() <= 0.001f) + { + return false; + } + + axis = Vector3.Normalize(axis); + double distance = Math.Min(Math.Sqrt((xDifference * xDifference) + (yDifference * yDifference)), 1d); + float angle = (float)(distance * tiltStrength * (Math.PI / 180d)); + orientation = Quaternion.CreateFromAxisAngle(axis, angle); + return true; + } + + /// + /// Applies a tilt animation for a pointer position. + /// + /// The target control. + /// The pointer position relative to the target. + /// The maximum tilt angle in degrees. + /// true when a composition visual was animated; otherwise, false. + public static bool Apply(Control? target, Point pointerPosition, double tiltStrength) + { + if (target is null || target.Bounds.Width <= 0d || target.Bounds.Height <= 0d) + { + return false; + } + + Quaternion orientation = CalculateOrientation(target.Bounds.Size, pointerPosition, tiltStrength); + return AnimateTo(target, orientation, TimeSpan.FromMilliseconds(50)); + } + + /// + /// Animates a control back to its neutral orientation. + /// + /// The target control. + /// true when a composition visual was animated; otherwise, false. + public static bool Reset(Control? target) + { + return AnimateTo(target, Quaternion.Identity, TimeSpan.FromMilliseconds(400)); + } + + /// + /// Updates a control's composition center point from its current bounds. + /// + /// The target control. + /// true when a composition visual was updated; otherwise, false. + public static bool UpdateCenterPoint(Control? target) + { + CompositionVisual? visual = target is null ? null : ElementComposition.GetElementVisual(target); + if (visual is null || target is null) + { + return false; + } + + visual.CenterPoint = new Vector3( + (float)target.Bounds.Width / 2f, + (float)target.Bounds.Height / 2f, + 0f); + return true; + } + + private static bool AnimateTo(Control? target, Quaternion orientation, TimeSpan duration) + { + CompositionVisual? visual = target is null ? null : ElementComposition.GetElementVisual(target); + if (visual is null) + { + return false; + } + + var animation = visual.Compositor.CreateQuaternionKeyFrameAnimation(); + animation.InsertKeyFrame(1f, orientation); + animation.Duration = duration; + visual.StartAnimation("Orientation", animation); + return true; + } +} diff --git a/src/Xaml.Behaviors.Animations/Properties/AssemblyInfo.cs b/src/Xaml.Behaviors.Animations/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..d3af0e075 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System.Runtime.CompilerServices; +using Avalonia.Metadata; + +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Xaml.Interactions.Custom")] +[assembly: InternalsVisibleTo("Xaml.Behaviors.Animations.UnitTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001002940ed211918fcf63c506fad1d3f7f958b21ff8f06fd2089398296173f9ca93a69b9b380a828bf13fa80d1745beeb917ec3692f4d10e44b4c941619fc7bbd5052b26880697e6fa3f0ce322c4fa902d20b67a48b4144371218f6d39ad39145ea1fe5484052dd51a2ee62af3acd0759bcf92aaefec03978ded3cfaa84798e92de8")] diff --git a/src/Xaml.Behaviors.Animations/README.md b/src/Xaml.Behaviors.Animations/README.md new file mode 100644 index 000000000..07833b658 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/README.md @@ -0,0 +1,11 @@ +# Xaml.Behaviors.Animations + +Reusable Avalonia key-frame animations, composition animation catalogs and effects, synchronous and asynchronous animation runners/builders, fluid transform preparation, selection-indicator primitives, and transition mutation/observation operations. The package depends only on Avalonia and can be installed without XAML Behaviors or Interactivity. + +```xml + +``` + +The CLR namespace remains `Avalonia.Xaml.Interactions.Custom` for compatibility with existing code and XAML. Behavior, action, and trigger adapters are available separately from `Xaml.Behaviors.Interactions.Custom`, use this shared implementation, and provide type forwarders for legacy assembly-qualified references. + +See the [standalone animations documentation](https://wieslawsoltes.github.io/Xaml.Behaviors/articles/animations/) for the API groups and examples. diff --git a/src/Xaml.Behaviors.Animations/Transitions/TransitionOperations.cs b/src/Xaml.Behaviors.Animations/Transitions/TransitionOperations.cs new file mode 100644 index 000000000..9fe494565 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Transitions/TransitionOperations.cs @@ -0,0 +1,92 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Animation; +using Avalonia.Reactive; + +namespace Avalonia.Xaml.Interactions.Custom; + +/// +/// Provides reusable operations for a styled element's transition collection. +/// +public static class TransitionOperations +{ + /// + /// Adds a transition to an element, creating its collection when necessary. + /// + /// The element whose transitions are changed. + /// The transition to add. + /// true when the transition was added; otherwise, false. + public static bool Add(StyledElement? target, TransitionBase? transition) + { + if (target is null || transition is null) + { + return false; + } + + target.Transitions ??= []; + target.Transitions.Add(transition); + return true; + } + + /// + /// Removes a transition from an element. + /// + /// The element whose transitions are changed. + /// The transition to remove. + /// true when the transition was removed; otherwise, false. + public static bool Remove(StyledElement? target, TransitionBase? transition) + { + return target?.Transitions is { } transitions + && transition is not null + && transitions.Remove(transition); + } + + /// + /// Clears all transitions from an element. + /// + /// The element whose transitions are cleared. + /// true when an existing collection was cleared; otherwise, false. + public static bool Clear(StyledElement? target) + { + if (target?.Transitions is not { } transitions) + { + return false; + } + + transitions.Clear(); + return true; + } + + /// + /// Replaces an element's transitions and returns the previous collection. + /// + /// The element whose transitions are replaced. + /// The new transitions collection. + /// The previous transitions collection. + public static Transitions? Replace(StyledElement target, Transitions? transitions) + { + Transitions? previous = target.Transitions; + target.Transitions = transitions; + return previous; + } + + /// + /// Observes replacements of an element's transition collection. + /// + /// The element whose transition property is observed. + /// The callback invoked with the current and subsequent transition collections. + /// A subscription that stops observation when disposed. + /// + /// Thrown when or is null. + /// + public static IDisposable Observe(StyledElement target, Action observer) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(observer); + + return target.GetObservable(StyledElement.TransitionsProperty) + .Subscribe(new AnonymousObserver(observer)); + } +} diff --git a/src/Xaml.Behaviors.Animations/Xaml.Behaviors.Animations.csproj b/src/Xaml.Behaviors.Animations/Xaml.Behaviors.Animations.csproj new file mode 100644 index 000000000..0df5151c5 --- /dev/null +++ b/src/Xaml.Behaviors.Animations/Xaml.Behaviors.Animations.csproj @@ -0,0 +1,29 @@ + + + + net8.0;net10.0 + Library + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + enable + + + + Xaml.Behaviors.Animations + Reusable Avalonia animations, composition effects, and animation helpers with no behaviors dependency. + Avalonia;Animation;Animations;Composition;Transition;Transitions;Xaml + README.md + + + + + + + + + + + + + + + diff --git a/src/Xaml.Behaviors.Avalonia/Xaml.Behaviors.Avalonia.csproj b/src/Xaml.Behaviors.Avalonia/Xaml.Behaviors.Avalonia.csproj index 8287f601e..55a2f28b1 100644 --- a/src/Xaml.Behaviors.Avalonia/Xaml.Behaviors.Avalonia.csproj +++ b/src/Xaml.Behaviors.Avalonia/Xaml.Behaviors.Avalonia.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimateOnAttachedBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimateOnAttachedBehavior.cs index a60369241..ee008adec 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimateOnAttachedBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimateOnAttachedBehavior.cs @@ -49,11 +49,7 @@ protected override IDisposable OnAttachedToVisualTreeOverride() return DisposableAction.Empty; } - var animation = Animation ?? AnimationBuilder?.Build(AssociatedObject); - if (animation is not null) - { - _ = animation.RunAsync(AssociatedObject); - } + AnimationRunner.TryBuildAndRun(AssociatedObject, Animation, AnimationBuilder); return DisposableAction.Empty; } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimationCompletedTrigger.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimationCompletedTrigger.cs index a49ed5aea..38ff6cdc1 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimationCompletedTrigger.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/AnimationCompletedTrigger.cs @@ -44,7 +44,7 @@ protected override void OnAttachedToVisualTree() async Task Run() { - await Animation.RunAsync(AssociatedObject); + await AnimationRunner.RunAsync(Animation, AssociatedObject); Dispatcher.UIThread.Post(() => Execute(parameter: null)); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/BeginAnimationAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/BeginAnimationAction.cs index d3cd15740..afdea37df 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/BeginAnimationAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/BeginAnimationAction.cs @@ -51,13 +51,7 @@ public override object Execute(object? sender, object? parameter) return false; } - var control = TargetControl ?? sender as Control; - if (control is null || Animation is null) - { - return false; - } - - _ = Animation.RunAsync(control); - return true; + Control? control = TargetControl ?? sender as Control; + return AnimationRunner.TryRun(Animation, control); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/FadeInBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/FadeInBehavior.cs index f0cfd23e1..438293634 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/FadeInBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/FadeInBehavior.cs @@ -1,8 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; -using Avalonia.Animation; -using Avalonia.Styling; using Avalonia.Xaml.Interactivity; namespace Avalonia.Xaml.Interactions.Custom; @@ -52,19 +50,8 @@ protected override System.IDisposable OnAttachedToVisualTreeOverride() return DisposableAction.Empty; } - var totalDuration = InitialDelay + Duration; - - var animation = new Animation.Animation - { - Duration = totalDuration, - Children = - { - new KeyFrame {KeyTime = TimeSpan.Zero, Setters = {new Setter(Visual.OpacityProperty, 0d),}}, - new KeyFrame {KeyTime = InitialDelay, Setters = {new Setter(Visual.OpacityProperty, 0d),}}, - new KeyFrame {KeyTime = Duration, Setters = {new Setter(Visual.OpacityProperty, 1d),}} - } - }; - animation.RunAsync(AssociatedObject); + Animation.Animation animation = AnimationFactory.CreateFadeIn(InitialDelay, Duration); + AnimationRunner.TryRun(animation, AssociatedObject); return DisposableAction.Empty; } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/PlayAnimationBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/PlayAnimationBehavior.cs index f3df4b466..0a820d54e 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/PlayAnimationBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/PlayAnimationBehavior.cs @@ -28,12 +28,7 @@ public Animation.Animation? Animation /// protected override IDisposable OnAttachedToVisualTreeOverride() { - if (AssociatedObject is null || Animation is null) - { - return DisposableAction.Empty; - } - - _ = Animation.RunAsync(AssociatedObject); + AnimationRunner.TryRun(Animation, AssociatedObject); return DisposableAction.Empty; } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/RunAnimationTrigger.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/RunAnimationTrigger.cs index abe64f71e..a4a3fe4a0 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/RunAnimationTrigger.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/RunAnimationTrigger.cs @@ -1,6 +1,7 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; +using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Threading; using Avalonia.Xaml.Interactivity; @@ -50,10 +51,12 @@ protected override IDisposable OnAttachedToVisualTreeOverride() return DisposableAction.Empty; } - var animation = Animation ?? AnimationBuilder?.Build(AssociatedObject); - if (animation is not null) + Task? task = AnimationRunner.TryBuildAndRunAsync( + AssociatedObject, + Animation, + AnimationBuilder); + if (task is not null) { - var task = animation.RunAsync(AssociatedObject); task.ContinueWith(_ => { Dispatcher.UIThread.Post(() => Interaction.ExecuteActions(AssociatedObject, Actions, null)); diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/StartAnimationAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/StartAnimationAction.cs index 6c9bd1b30..0791454f5 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/StartAnimationAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/StartAnimationAction.cs @@ -33,13 +33,6 @@ public Animation.Animation? Animation /// True if the animation was started; otherwise, false. public object Execute(object? sender, object? parameter) { - if (sender is not Control control || Animation is null) - { - return false; - } - - _ = Animation.RunAsync(control); - - return true; + return AnimationRunner.TryRun(Animation, sender as Control); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Animations/StartBuiltAnimationAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Animations/StartBuiltAnimationAction.cs index 3fa8aa36e..c9c2204ba 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Animations/StartBuiltAnimationAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Animations/StartBuiltAnimationAction.cs @@ -43,18 +43,6 @@ public IAnimationBuilder? AnimationBuilder /// public object Execute(object? sender, object? parameter) { - if (sender is not Control control) - { - return false; - } - - var animation = Animation ?? AnimationBuilder?.Build(control); - if (animation is null) - { - return false; - } - - _ = animation.RunAsync(control); - return true; + return AnimationRunner.TryBuildAndRun(sender as Control, Animation, AnimationBuilder); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/OrbitEffectBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Composition/OrbitEffectBehavior.cs index 445eadfe7..467c02108 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/OrbitEffectBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Composition/OrbitEffectBehavior.cs @@ -1,9 +1,9 @@ -using System; -using System.Numerics; +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Rendering.Composition; using Avalonia.Xaml.Interactivity; namespace Avalonia.Xaml.Interactions.Custom; @@ -13,10 +13,9 @@ namespace Avalonia.Xaml.Interactions.Custom; /// public class OrbitEffectBehavior : StyledElementBehavior { - private CompositionVisual? _visual; + private readonly OrbitAnimation _animation = new(); private bool _isPressed; private Point _lastPosition; - private Quaternion _currentOrientation = Quaternion.Identity; /// /// Identifies the avalonia property. @@ -42,7 +41,8 @@ protected override void OnAttachedToVisualTree() AssociatedObject.PointerPressed += OnPointerPressed; AssociatedObject.PointerMoved += OnPointerMoved; AssociatedObject.PointerReleased += OnPointerReleased; - UpdateVisual(); + AssociatedObject.SizeChanged += OnSizeChanged; + OrbitAnimation.UpdateCenterPoint(AssociatedObject); } } @@ -55,28 +55,9 @@ protected override void OnDetachedFromVisualTree() AssociatedObject.PointerPressed -= OnPointerPressed; AssociatedObject.PointerMoved -= OnPointerMoved; AssociatedObject.PointerReleased -= OnPointerReleased; + AssociatedObject.SizeChanged -= OnSizeChanged; } - _visual = null; - } - - private void UpdateVisual() - { - if (AssociatedObject is null) return; - - var visual = ElementComposition.GetElementVisual(AssociatedObject); - if (visual != null && _visual != visual) - { - _visual = visual; - _visual.CenterPoint = new Vector3((float)AssociatedObject.Bounds.Width / 2, (float)AssociatedObject.Bounds.Height / 2, 0); - - AssociatedObject.SizeChanged += (s, e) => - { - if (_visual != null) - { - _visual.CenterPoint = new Vector3((float)AssociatedObject.Bounds.Width / 2, (float)AssociatedObject.Bounds.Height / 2, 0); - } - }; - } + _isPressed = false; } private void OnPointerPressed(object? sender, PointerPressedEventArgs e) @@ -94,24 +75,20 @@ private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) private void OnPointerMoved(object? sender, PointerEventArgs e) { - if (!_isPressed || _visual == null) return; + if (!_isPressed || AssociatedObject is null) + { + return; + } var currentPosition = e.GetPosition(AssociatedObject); var delta = currentPosition - _lastPosition; _lastPosition = currentPosition; - float rotX = (float)(delta.Y * Sensitivity * 0.01); - float rotY = (float)(delta.X * Sensitivity * 0.01); - - var rotationX = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -rotX); - var rotationY = Quaternion.CreateFromAxisAngle(Vector3.UnitY, rotY); - - _currentOrientation = _currentOrientation * rotationX * rotationY; - _currentOrientation = Quaternion.Normalize(_currentOrientation); + _animation.Rotate(AssociatedObject, delta, Sensitivity); + } - var orientationAnimation = _visual.Compositor.CreateQuaternionKeyFrameAnimation(); - orientationAnimation.InsertKeyFrame(1.0f, _currentOrientation); - orientationAnimation.Duration = TimeSpan.FromMilliseconds(1); - _visual.StartAnimation("Orientation", orientationAnimation); + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + OrbitAnimation.UpdateCenterPoint(AssociatedObject); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/ParallaxBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Composition/ParallaxBehavior.cs index abd0f3c8b..7d8a120fc 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/ParallaxBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Composition/ParallaxBehavior.cs @@ -1,9 +1,7 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. using System; -using System.Numerics; using Avalonia.Controls; -using Avalonia.Rendering.Composition; using Avalonia.Xaml.Interactivity; namespace Avalonia.Xaml.Interactions.Custom; @@ -48,15 +46,14 @@ public double ParallaxRatio set => SetValue(ParallaxRatioProperty, value); } - private CompositionVisual? _visual; private IDisposable? _scrollSubscription; + private ParallaxAnimation? _animation; /// protected override void OnAttachedToVisualTree() { base.OnAttachedToVisualTree(); - UpdateVisual(); - + _animation = ParallaxAnimation.TryCreate(AssociatedObject); if (SourceScrollViewer == null) { // Try to find parent ScrollViewer @@ -84,13 +81,8 @@ protected override void OnDetachedFromVisualTree() { base.OnDetachedFromVisualTree(); _scrollSubscription?.Dispose(); - _visual = null; - } - - private void UpdateVisual() - { - if (AssociatedObject is null) return; - _visual = ElementComposition.GetElementVisual(AssociatedObject); + _scrollSubscription = null; + _animation = null; } /// @@ -106,29 +98,7 @@ public void OnError(Exception error) /// public void OnNext(Avalonia.Vector value) { - OnScrollChanged(value); - } - - private void OnScrollChanged(Vector offset) - { - if (_visual == null) UpdateVisual(); - if (_visual == null) return; - - // We want to offset the element based on the scroll position. - // If ParallaxRatio is 0.2, we want the element to move 0.2 * ScrollOffset. - // But wait, if we are inside the ScrollViewer, we are already moving with it. - // If we want to appear "slower", we need to offset it in the direction of scroll? - // Or opposite? - - // Standard Parallax: Background moves slower than foreground. - // If we scroll down (Offset.Y increases), the content moves UP relative to the viewport. - // To make something move slower, we need to push it DOWN (positive Y translation). - - // Offset = ScrollOffset * Ratio - - var yOffset = offset.Y * ParallaxRatio; - var xOffset = offset.X * ParallaxRatio; - - _visual.Offset = new Vector3((float)xOffset, (float)yOffset, 0); + _animation ??= ParallaxAnimation.TryCreate(AssociatedObject); + _animation?.Apply(value, ParallaxRatio); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/SelectingItemsControlBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Composition/SelectingItemsControlBehavior.cs deleted file mode 100644 index b630dd5d1..000000000 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/SelectingItemsControlBehavior.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) Wiesław Šoltés. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for details. - -// Based on code: https://github.com/adirh3/Avalonia.ListBoxAnimation.Samples - -using System; -using System.Linq; -using System.Numerics; -using Avalonia.Animation.Easings; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Rendering.Composition; -using Avalonia.VisualTree; - -namespace Avalonia.Xaml.Interactions.Custom; - -/// -/// -/// -public class SelectingItemsControlBehavior -{ - /// - /// - /// - public static readonly AttachedProperty EnableSelectionAnimationProperty = - AvaloniaProperty.RegisterAttached("EnableSelectionAnimation", - typeof(SelectingItemsControlBehavior)); - - /// - /// - /// - static SelectingItemsControlBehavior() - { - EnableSelectionAnimationProperty.Changed.AddClassHandler(OnEnableSelectionAnimation); - } - - private static void OnEnableSelectionAnimation(Control control, AvaloniaPropertyChangedEventArgs args) - { - if (control is SelectingItemsControl selectingItemsControl) - { - if (args.NewValue is true) - { - selectingItemsControl.PropertyChanged += SelectingItemsControlPropertyChanged; - } - else - { - selectingItemsControl.PropertyChanged -= SelectingItemsControlPropertyChanged; - } - } - } - - private static void SelectingItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs args) - { - if (sender is not SelectingItemsControl selectingItemsControl || - args.Property != SelectingItemsControl.SelectedIndexProperty || - args.OldValue is not int oldIndex || args.NewValue is not int newIndex) - { - return; - } - - if (selectingItemsControl.ContainerFromIndex(newIndex) is not TemplatedControl newSelection - || selectingItemsControl.ContainerFromIndex(oldIndex) is not TemplatedControl oldSelection) - { - return; - } - - StartOffsetAnimation(newSelection, oldSelection); - } - - private static void StartOffsetAnimation(TemplatedControl newSelection, TemplatedControl oldSelection) - { - // Find the indicator border - // NOTE: - // The original required putting PART_SelectedPipe in template (e.g. ListBox > ListBoxItem) - // and used GetTemplateChildren() instead of GetVisualDescendants() - if (newSelection.GetVisualDescendants().FirstOrDefault(s => s.Name == "PART_SelectedPipe") is not { } borderPipe - || oldSelection.GetVisualDescendants().FirstOrDefault(s => s.Name == "PART_SelectedPipe") is not - { } oldPipe) - { - return; - } - - // Clear old implicit animations if any - ElementComposition.GetElementVisual(oldPipe)?.ImplicitAnimations?.Clear(); - - // Get the composition visuals for all controls - var pipeVisual = ElementComposition.GetElementVisual(borderPipe); - var newSelectionVisual = ElementComposition.GetElementVisual(newSelection); - var oldSelectionVisual = ElementComposition.GetElementVisual(oldSelection); - if (pipeVisual == null || newSelectionVisual == null || oldSelectionVisual == null) - { - return; - } - - // Calculate the offset between old and new selections - var selectionOffset = oldSelectionVisual.Offset - newSelectionVisual.Offset; - - // Check whether the offset is vertical (e.g. ListBox) or horizontal (e.g. TabControl) - // Note this code assumes the items are aligned in the SelectingItemsControl - var isVerticalOffset = selectionOffset.Y != 0; - var offset = isVerticalOffset ? selectionOffset.Y : selectionOffset.X; - var compositor = pipeVisual.Compositor; - - // This is required - var quadraticEaseIn = new SpringEasing(); - - // Create new offset animation between old selection position to the current position - var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); - offsetAnimation.Target = "Offset"; - var expression = (offset > 0 ? "+" : "-") + Math.Abs(offset); - offsetAnimation.InsertExpressionKeyFrame( - 0f, - isVerticalOffset - ? $"Vector3(this.FinalValue.X, this.FinalValue.Y{expression}, 0)" - : $"Vector3(this.FinalValue.X{expression}, this.FinalValue.Y, 0)"); - offsetAnimation.InsertExpressionKeyFrame(1f, "this.FinalValue"); - offsetAnimation.Duration = TimeSpan.FromMilliseconds(250); - - // Create small scale animation so the pipe will "stretch" while it's moving - var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); - scaleAnimation.Target = "Scale"; - scaleAnimation.InsertKeyFrame(0f, Vector3.One, quadraticEaseIn); - scaleAnimation.InsertKeyFrame(0.5f, - new Vector3(1f + (!isVerticalOffset ? 0.75f : 0f), 1f + (isVerticalOffset ? 0.75f : 0f), 1f), - quadraticEaseIn); - scaleAnimation.InsertKeyFrame(1f, Vector3.One, quadraticEaseIn); - scaleAnimation.Duration = TimeSpan.FromMilliseconds(250); - - var compositionAnimationGroup = compositor.CreateAnimationGroup(); - compositionAnimationGroup.Add(offsetAnimation); - compositionAnimationGroup.Add(scaleAnimation); - var pipeVisualImplicitAnimations = compositor.CreateImplicitAnimationCollection(); - var currentOffset = isVerticalOffset ? pipeVisual.Offset.Y : pipeVisual.Offset.X; - if (currentOffset == 0) - { - // Visual first shown, offset not calculated, lets trigger using Offset - pipeVisualImplicitAnimations["Offset"] = compositionAnimationGroup; - } - else - { - // Visual already shown, we can't trigger on Offset as it won't change - pipeVisualImplicitAnimations["Visible"] = compositionAnimationGroup; - } - - pipeVisual.ImplicitAnimations = pipeVisualImplicitAnimations; - } - - /// - /// - /// - /// - /// - public static bool GetEnableSelectionAnimation(SelectingItemsControl element) - { - return element.GetValue(EnableSelectionAnimationProperty); - } - - /// - /// - /// - /// - /// - public static void SetEnableSelectionAnimation(SelectingItemsControl element, bool value) - { - element.SetValue(EnableSelectionAnimationProperty, value); - } -} diff --git a/src/Xaml.Behaviors.Interactions.Custom/Composition/TiltEffectBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Composition/TiltEffectBehavior.cs index f4b05532b..5b9ca302a 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Composition/TiltEffectBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Composition/TiltEffectBehavior.cs @@ -1,11 +1,7 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. -using System; -using System.Numerics; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Rendering.Composition; -using Avalonia.Rendering.Composition.Animations; using Avalonia.Xaml.Interactivity; namespace Avalonia.Xaml.Interactions.Custom; @@ -30,8 +26,6 @@ public double TiltStrength set => SetValue(TiltStrengthProperty, value); } - private CompositionVisual? _visual; - /// protected override void OnAttachedToVisualTree() { @@ -40,8 +34,8 @@ protected override void OnAttachedToVisualTree() { AssociatedObject.PointerMoved += OnPointerMoved; AssociatedObject.PointerExited += OnPointerExited; - - UpdateVisual(); + AssociatedObject.SizeChanged += OnSizeChanged; + TiltAnimation.UpdateCenterPoint(AssociatedObject); } } @@ -53,79 +47,25 @@ protected override void OnDetachedFromVisualTree() { AssociatedObject.PointerMoved -= OnPointerMoved; AssociatedObject.PointerExited -= OnPointerExited; + AssociatedObject.SizeChanged -= OnSizeChanged; } - _visual = null; } - private void UpdateVisual() + private void OnPointerMoved(object? sender, PointerEventArgs e) { - if (AssociatedObject is null) return; - - var visual = ElementComposition.GetElementVisual(AssociatedObject); - if (visual != null && _visual != visual) + if (AssociatedObject is not null) { - _visual = visual; - - // Ensure center point is center of the control - _visual.CenterPoint = new Vector3((float)AssociatedObject.Bounds.Width / 2, (float)AssociatedObject.Bounds.Height / 2, 0); - - // We need to update CenterPoint when bounds change - AssociatedObject.SizeChanged += (s, e) => - { - if (_visual != null) - { - _visual.CenterPoint = new Vector3((float)e.NewSize.Width / 2, (float)e.NewSize.Height / 2, 0); - } - }; + TiltAnimation.Apply(AssociatedObject, e.GetPosition(AssociatedObject), TiltStrength); } } - private void OnPointerMoved(object? sender, PointerEventArgs e) + private void OnPointerExited(object? sender, PointerEventArgs e) { - if (AssociatedObject is null) return; - if (_visual is null) UpdateVisual(); - if (_visual is null) return; - - var p = e.GetPosition(AssociatedObject); - var center = new Point(AssociatedObject.Bounds.Width / 2, AssociatedObject.Bounds.Height / 2); - - // Calculate offset from center (-1 to 1 range roughly) - var xDiff = (p.X - center.X) / center.X; - var yDiff = (p.Y - center.Y) / center.Y; - - // Invert Y because moving mouse up (negative Y) should tilt "away" (positive rotation around X) - // Moving mouse right (positive X) should tilt "right" (positive rotation around Y) - - var axis = new Vector3((float)-yDiff, (float)xDiff, 0); - - if (axis.LengthSquared() > 0.001) - { - axis = Vector3.Normalize(axis); - - // Distance from center determines angle - var dist = Math.Sqrt(xDiff * xDiff + yDiff * yDiff); - // Clamp dist to 1.0 - dist = Math.Min(dist, 1.0); - - var angle = (float)(dist * TiltStrength * (Math.PI / 180.0)); // Convert to radians - - var quaternion = Quaternion.CreateFromAxisAngle(axis, angle); - - var orientationAnimation = _visual.Compositor.CreateQuaternionKeyFrameAnimation(); - orientationAnimation.InsertKeyFrame(1.0f, quaternion); - orientationAnimation.Duration = TimeSpan.FromMilliseconds(50); - _visual.StartAnimation("Orientation", orientationAnimation); - } + TiltAnimation.Reset(AssociatedObject); } - private void OnPointerExited(object? sender, PointerEventArgs e) + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) { - if (_visual != null) - { - var orientationAnimation = _visual.Compositor.CreateQuaternionKeyFrameAnimation(); - orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity); - orientationAnimation.Duration = TimeSpan.FromMilliseconds(400); - _visual.StartAnimation("Orientation", orientationAnimation); - } + TiltAnimation.UpdateCenterPoint(AssociatedObject); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Layout/FluidMoveBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Layout/FluidMoveBehavior.cs index 21b57f76d..2c15ff9c3 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Layout/FluidMoveBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Layout/FluidMoveBehavior.cs @@ -3,11 +3,8 @@ using System; using System.Collections.Generic; using System.Linq; -using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Layout; -using Avalonia.Media; -using Avalonia.Styling; using Avalonia.Xaml.Interactivity; namespace Avalonia.Xaml.Interactions.Custom; @@ -128,42 +125,7 @@ private void UpdateControl(Control control, Visual root) var dx = previous.X - current.X; var dy = previous.Y - current.Y; - if (control.RenderTransform is not TranslateTransform transform) - { - transform = new TranslateTransform(); - control.RenderTransform = transform; - } - - transform.X = dx; - transform.Y = dy; - - var animation = new Animation.Animation - { - Duration = Duration, - Children = - { - new KeyFrame - { - Cue = new Cue(0d), - Setters = - { - new Setter(TranslateTransform.XProperty, dx), - new Setter(TranslateTransform.YProperty, dy) - } - }, - new KeyFrame - { - Cue = new Cue(1d), - Setters = - { - new Setter(TranslateTransform.XProperty, 0d), - new Setter(TranslateTransform.YProperty, 0d) - } - } - } - }; - - _ = animation.RunAsync(transform); + FluidMoveAnimation.TryRun(control, dx, dy, Duration); _positions[control] = current; } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Properties/AnimationTypeForwards.cs b/src/Xaml.Behaviors.Interactions.Custom/Properties/AnimationTypeForwards.cs new file mode 100644 index 000000000..05699f3f3 --- /dev/null +++ b/src/Xaml.Behaviors.Interactions.Custom/Properties/AnimationTypeForwards.cs @@ -0,0 +1,16 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System.Runtime.CompilerServices; + +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.AttentionAnimations))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.EntranceAnimations))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.ExitAnimations))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.FadeAnimation))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.FramerMotionAnimations))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.IAnimationBuilder))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.RotateAnimation))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.ScaleAnimation))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.SelectingItemsControlBehavior))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.SlidingAnimation))] +[assembly: TypeForwardedTo(typeof(Avalonia.Xaml.Interactions.Custom.SpecialAnimations))] diff --git a/src/Xaml.Behaviors.Interactions.Custom/Transitions/AddTransitionAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Transitions/AddTransitionAction.cs index a105772b4..08b23ba87 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Transitions/AddTransitionAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Transitions/AddTransitionAction.cs @@ -50,15 +50,7 @@ public override object Execute(object? sender, object? parameter) return false; } - var target = GetValue(StyledElementProperty) ?? sender as StyledElement; - if (target is null || Transition is null) - { - return false; - } - - target.Transitions ??= []; - target.Transitions.Add(Transition); - - return true; + StyledElement? target = GetValue(StyledElementProperty) ?? sender as StyledElement; + return TransitionOperations.Add(target, Transition); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Transitions/ClearTransitionsAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Transitions/ClearTransitionsAction.cs index a851dda1a..9b4b944cb 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Transitions/ClearTransitionsAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Transitions/ClearTransitionsAction.cs @@ -34,13 +34,7 @@ public override object Execute(object? sender, object? parameter) return false; } - var target = GetValue(StyledElementProperty) ?? sender as StyledElement; - if (target?.Transitions is null) - { - return false; - } - - target.Transitions.Clear(); - return true; + StyledElement? target = GetValue(StyledElementProperty) ?? sender as StyledElement; + return TransitionOperations.Clear(target); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Transitions/RemoveTransitionAction.cs b/src/Xaml.Behaviors.Interactions.Custom/Transitions/RemoveTransitionAction.cs index 7dc12eb98..e3b71e9ff 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Transitions/RemoveTransitionAction.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Transitions/RemoveTransitionAction.cs @@ -50,17 +50,7 @@ public override object Execute(object? sender, object? parameter) return false; } - var target = GetValue(StyledElementProperty) ?? sender as StyledElement; - if (target?.Transitions is null) - { - return false; - } - - if (Transition is not null) - { - return target.Transitions.Remove(Transition); - } - - return false; + StyledElement? target = GetValue(StyledElementProperty) ?? sender as StyledElement; + return TransitionOperations.Remove(target, Transition); } } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsBehavior.cs b/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsBehavior.cs index 9b75267df..9fa5c2a92 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsBehavior.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsBehavior.cs @@ -24,8 +24,8 @@ public class TransitionsBehavior : AttachedToVisualTreeBehavior /// public Transitions? TransitionsSource { - get => GetValue(TransitionsProperty); - set => SetValue(TransitionsProperty, value); + get => GetValue(TransitionsSourceProperty); + set => SetValue(TransitionsSourceProperty, value); } /// @@ -36,14 +36,13 @@ protected override System.IDisposable OnAttachedToVisualTreeOverride() return DisposableAction.Empty; } - _oldTransitions = AssociatedObject.Transitions; - AssociatedObject.Transitions = TransitionsSource; + _oldTransitions = TransitionOperations.Replace(AssociatedObject, TransitionsSource); return DisposableAction.Create(() => { if (AssociatedObject is not null) { - AssociatedObject.Transitions = _oldTransitions; + TransitionOperations.Replace(AssociatedObject, _oldTransitions); } }); } diff --git a/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsChangedTrigger.cs b/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsChangedTrigger.cs index 02d2f95f6..2e5af5d49 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsChangedTrigger.cs +++ b/src/Xaml.Behaviors.Interactions.Custom/Transitions/TransitionsChangedTrigger.cs @@ -1,8 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. -using Avalonia.Animation; using Avalonia.Controls; -using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.Xaml.Interactivity; @@ -21,11 +19,12 @@ protected override System.IDisposable OnAttachedOverride() return DisposableAction.Empty; } - return AssociatedObject.GetObservable(StyledElement.TransitionsProperty) - .Subscribe(new AnonymousObserver(_ => + return TransitionOperations.Observe( + AssociatedObject, + _ => { Dispatcher.UIThread.Post(() => Execute(null)); - })); + }); } private void Execute(object? parameter) diff --git a/src/Xaml.Behaviors.Interactions.Custom/Xaml.Behaviors.Interactions.Custom.csproj b/src/Xaml.Behaviors.Interactions.Custom/Xaml.Behaviors.Interactions.Custom.csproj index da282af3a..f8c9cb58e 100644 --- a/src/Xaml.Behaviors.Interactions.Custom/Xaml.Behaviors.Interactions.Custom.csproj +++ b/src/Xaml.Behaviors.Interactions.Custom/Xaml.Behaviors.Interactions.Custom.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Xaml.Behaviors/Xaml.Behaviors.csproj b/src/Xaml.Behaviors/Xaml.Behaviors.csproj index f11411210..7cf244fd1 100644 --- a/src/Xaml.Behaviors/Xaml.Behaviors.csproj +++ b/src/Xaml.Behaviors/Xaml.Behaviors.csproj @@ -31,6 +31,7 @@ + ..\Xaml.Behaviors.Animations ..\Xaml.Behaviors.Interactivity ..\Xaml.Behaviors.Interactions ..\Xaml.Behaviors.Interactions.Custom @@ -42,6 +43,7 @@ + diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/AnimationFactoryTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/AnimationFactoryTests.cs new file mode 100644 index 000000000..2c846dced --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/AnimationFactoryTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class AnimationFactoryTests +{ + [AvaloniaFact] + public void CreateFadeIn_CreatesDelayedOpacityAnimation() + { + TimeSpan delay = TimeSpan.FromMilliseconds(200); + TimeSpan duration = TimeSpan.FromMilliseconds(300); + + var animation = AnimationFactory.CreateFadeIn(delay, duration); + + Assert.Equal(TimeSpan.FromMilliseconds(500), animation.Duration); + Assert.Collection( + animation.Children, + keyFrame => Assert.Equal(TimeSpan.Zero, keyFrame.KeyTime), + keyFrame => Assert.Equal(delay, keyFrame.KeyTime), + keyFrame => Assert.Equal(delay + duration, keyFrame.KeyTime)); + } + + [AvaloniaTheory] + [InlineData(-1, 0)] + [InlineData(0, -1)] + public void CreateFadeIn_RejectsNegativeTimes(double delayMilliseconds, double durationMilliseconds) + { + Assert.Throws(() => AnimationFactory.CreateFadeIn( + TimeSpan.FromMilliseconds(delayMilliseconds), + TimeSpan.FromMilliseconds(durationMilliseconds))); + } + + [AvaloniaFact] + public void CreateFadeInTimeline_PreservesExplicitKeyTimes() + { + TimeSpan delay = TimeSpan.FromMilliseconds(500); + TimeSpan completion = TimeSpan.FromMilliseconds(650); + TimeSpan totalDuration = TimeSpan.FromMilliseconds(750); + + Avalonia.Animation.Animation animation = AnimationFactory.CreateFadeInTimeline( + delay, + completion, + totalDuration); + + Assert.Equal(totalDuration, animation.Duration); + Assert.Collection( + animation.Children, + keyFrame => Assert.Equal(TimeSpan.Zero, keyFrame.KeyTime), + keyFrame => Assert.Equal(delay, keyFrame.KeyTime), + keyFrame => Assert.Equal(completion, keyFrame.KeyTime)); + } + + [AvaloniaTheory] + [InlineData(500, 250, 750)] + [InlineData(500, 800, 750)] + public void CreateFadeInTimeline_RejectsNonChronologicalKeyTimes( + double delayMilliseconds, + double completionMilliseconds, + double durationMilliseconds) + { + Assert.Throws(() => AnimationFactory.CreateFadeInTimeline( + TimeSpan.FromMilliseconds(delayMilliseconds), + TimeSpan.FromMilliseconds(completionMilliseconds), + TimeSpan.FromMilliseconds(durationMilliseconds))); + } + + [AvaloniaFact] + public void FluidMoveAnimation_CreatesTranslationAnimation() + { + TimeSpan duration = TimeSpan.FromMilliseconds(250); + + var animation = FluidMoveAnimation.Create(12d, -8d, duration); + + Assert.Equal(duration, animation.Duration); + Assert.Equal(FillMode.Forward, animation.FillMode); + Assert.Collection( + animation.Children, + keyFrame => + { + Assert.Equal(new Cue(0d), keyFrame.Cue); + Assert.Equal(2, keyFrame.Setters.Count); + }, + keyFrame => + { + Assert.Equal(new Cue(1d), keyFrame.Cue); + Assert.Equal(2, keyFrame.Setters.Count); + }); + } + + [AvaloniaFact] + public void FluidMoveAnimation_RejectsNegativeDuration() + { + Assert.Throws(() => + FluidMoveAnimation.Create(0d, 0d, TimeSpan.FromMilliseconds(-1d))); + } + + [AvaloniaFact] + public void FluidMoveAnimation_TryRunPreparesControlTransform() + { + var target = new Border { RenderTransform = new RotateTransform() }; + + bool started = FluidMoveAnimation.TryRun( + target, + 12d, + -8d, + TimeSpan.Zero); + + Assert.True(started); + TranslateTransform transform = Assert.IsType(target.RenderTransform); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(0d, transform.X); + Assert.Equal(0d, transform.Y); + Assert.False(FluidMoveAnimation.TryRun(null, 0d, 0d, TimeSpan.Zero)); + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/AnimationRunnerTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/AnimationRunnerTests.cs new file mode 100644 index 000000000..98450f7d3 --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/AnimationRunnerTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Threading.Tasks; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class AnimationRunnerTests +{ + private sealed class NullAnimationBuilder : IAnimationBuilder + { + public int BuildCount { get; private set; } + + public Animation? Build(Control control) + { + BuildCount++; + return null; + } + } + + [AvaloniaFact] + public void TryRun_ReturnsFalseForMissingInputs() + { + Assert.False(AnimationRunner.TryRun(null, null)); + Assert.False(AnimationRunner.TryRun(null, new Border())); + Assert.Null(AnimationRunner.TryRunAsync(null, null)); + Assert.Null(AnimationRunner.TryRunAsync(null, new Border())); + } + + [AvaloniaFact] + public void TryBuildAndRun_UsesBuilderWhenExplicitAnimationIsMissing() + { + var builder = new NullAnimationBuilder(); + + bool started = AnimationRunner.TryBuildAndRun(new Border(), null, builder); + + Assert.False(started); + Assert.Equal(1, builder.BuildCount); + } + + [AvaloniaFact] + public void TryBuildAndRun_DoesNotUseBuilderWithoutTarget() + { + var builder = new NullAnimationBuilder(); + + bool started = AnimationRunner.TryBuildAndRun(null, null, builder); + + Assert.False(started); + Assert.Equal(0, builder.BuildCount); + } + + [AvaloniaFact] + public async Task TryBuildAndRunAsync_ReturnsCompletionTaskForExplicitAnimation() + { + var builder = new NullAnimationBuilder(); + var animation = new Animation { Duration = TimeSpan.Zero }; + + Task? task = AnimationRunner.TryBuildAndRunAsync(new Border(), animation, builder); + + Assert.NotNull(task); + Assert.Equal(0, builder.BuildCount); + await task; + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/CompositionAnimationTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/CompositionAnimationTests.cs new file mode 100644 index 000000000..b2714f1c6 --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/CompositionAnimationTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Numerics; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class CompositionAnimationTests +{ + [AvaloniaFact] + public void ParallaxAnimation_CalculatesProportionalOffset() + { + Vector3 offset = ParallaxAnimation.CalculateOffset(new Avalonia.Vector(20d, 50d), 0.25d); + + Assert.Equal(new Vector3(5f, 12.5f, 0f), offset); + } + + [AvaloniaFact] + public void ParallaxAnimation_AppliesOffsetToAttachedCompositionVisual() + { + var target = new Border { Width = 100d, Height = 100d }; + Canvas.SetLeft(target, 30d); + Canvas.SetTop(target, 40d); + var canvas = new Canvas { Width = 300d, Height = 300d, Children = { target } }; + var window = new Window { Content = canvas }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + bool applied = ParallaxAnimation.Apply(target, new Avalonia.Vector(20d, 50d), 0.25d); + + Assert.True(applied); + Assert.Equal(new Vector3(35f, 52.5f, 0f), ElementComposition.GetElementVisual(target)?.Offset); + window.Close(); + } + + [AvaloniaFact] + public void ParallaxAnimation_RetainsAttachedCompositionVisualAcrossUpdates() + { + var target = new Border { Width = 100d, Height = 100d }; + Canvas.SetLeft(target, 30d); + Canvas.SetTop(target, 40d); + var canvas = new Canvas { Width = 300d, Height = 300d, Children = { target } }; + var window = new Window { Content = canvas }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + ParallaxAnimation? animation = ParallaxAnimation.TryCreate(target); + + Assert.NotNull(animation); + animation.Apply(new Avalonia.Vector(10d, 20d), 0.5d); + animation.Apply(new Avalonia.Vector(20d, 50d), 0.25d); + + Assert.Equal(new Vector3(35f, 52.5f, 0f), ElementComposition.GetElementVisual(target)?.Offset); + window.Close(); + } + + [AvaloniaFact] + public void OrbitAnimation_CalculatesNormalizedOrientation() + { + Quaternion orientation = OrbitAnimation.CalculateOrientation( + Quaternion.Identity, + new Avalonia.Vector(10d, 5d), + 0.5d); + + Assert.NotEqual(Quaternion.Identity, orientation); + Assert.InRange(orientation.Length(), 0.9999f, 1.0001f); + } + + [AvaloniaFact] + public void TiltAnimation_ReturnsIdentityAtCenterAndForEmptySize() + { + Quaternion center = TiltAnimation.CalculateOrientation(new Size(100d, 80d), new Point(50d, 40d), 5d); + Quaternion empty = TiltAnimation.CalculateOrientation(default, new Point(10d, 10d), 5d); + + Assert.Equal(Quaternion.Identity, center); + Assert.Equal(Quaternion.Identity, empty); + } + + [AvaloniaFact] + public void TiltAnimation_CalculatesOrientationAwayFromCenter() + { + Quaternion orientation = TiltAnimation.CalculateOrientation( + new Size(100d, 100d), + new Point(100d, 50d), + 5d); + + Assert.NotEqual(Quaternion.Identity, orientation); + } + + [AvaloniaFact] + public void TiltAnimation_DoesNotCalculateOrientationAtCenterOrForEmptySize() + { + bool center = TiltAnimation.TryCalculateOrientation( + new Size(100d, 80d), + new Point(50d, 40d), + 5d, + out Quaternion centerOrientation); + bool empty = TiltAnimation.TryCalculateOrientation( + default, + new Point(10d, 10d), + 5d, + out Quaternion emptyOrientation); + + Assert.False(center); + Assert.False(empty); + Assert.Equal(Quaternion.Identity, centerOrientation); + Assert.Equal(Quaternion.Identity, emptyOrientation); + } + + [AvaloniaFact] + public void TiltAnimation_AnimatesBackToNeutralAtCenter() + { + var target = new Border { Width = 100d, Height = 80d }; + var window = new Window { Content = target }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + try + { + Assert.True(TiltAnimation.Apply(target, new Point(100d, 40d), 5d)); + Assert.True(TiltAnimation.Apply(target, new Point(50d, 40d), 5d)); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void CompositionEffects_ReturnFalseWithoutTargetVisuals() + { + var orbit = new OrbitAnimation(); + + Assert.Null(ParallaxAnimation.TryCreate(null)); + Assert.False(ParallaxAnimation.Apply(null, default, 0.25d)); + Assert.False(orbit.Rotate(null, new Avalonia.Vector(10d, 5d), 0.5d)); + Assert.False(TiltAnimation.Apply(null, default, 5d)); + Assert.False(TiltAnimation.Reset(null)); + Assert.Equal(Quaternion.Identity, orbit.Orientation); + } + + [AvaloniaFact] + public async Task AttentionAnimation_StartsFromPositionedControlLayoutOffset() + { + await AssertCompositionOffsetAsync( + target => AttentionAnimations.SetBounce(target, 10_000d), + new Vector3(30f, 40f, 0f)); + } + + [AvaloniaFact] + public async Task EntranceAnimation_StartsFromLayoutRelativeOffset() + { + await AssertCompositionOffsetAsync( + target => EntranceAnimations.SetSlideInLeft(target, 10_000d), + new Vector3(-210f, 40f, 0f)); + } + + [AvaloniaFact] + public async Task ExitAnimation_StartsFromPositionedControlLayoutOffset() + { + await AssertCompositionOffsetAsync( + target => ExitAnimations.SetSlideOutDown(target, 10_000d), + new Vector3(30f, 40f, 0f)); + } + + [AvaloniaFact] + public async Task FramerMotionAnimation_StartsFromLayoutRelativeOffset() + { + await AssertCompositionOffsetAsync( + target => FramerMotionAnimations.SetSlideInFromLeft(target, 10_000d), + new Vector3(-90f, 40f, 0f)); + } + + [AvaloniaFact] + public async Task SlidingAnimation_DoesNotOverwritePositionedControlLayoutOffset() + { + await AssertCompositionOffsetAsync( + target => SlidingAnimation.SetLeft(target, 10_000d), + new Vector3(30f, 40f, 0f)); + } + + [AvaloniaFact] + public void CompositionAnimationHelpers_AddRelativeMotionToLayoutOffset() + { + var target = new Border(); + target.Arrange(new Rect(30d, 40d, 100d, 100d)); + + Vector3 offset = CompositionAnimationHelpers.GetLayoutOffset( + target, + new Vector3(-100f, 25f, 0f)); + + Assert.Equal(new Vector3(-70f, 65f, 0f), offset); + } + + private static async Task AssertCompositionOffsetAsync(Action configure, Vector3 expectedOffset) + { + var target = new Border { Width = 100d, Height = 100d }; + Canvas.SetLeft(target, 30d); + Canvas.SetTop(target, 40d); + var canvas = new Canvas { Width = 300d, Height = 300d, Children = { target } }; + var window = new Window { Content = canvas }; + configure(target); + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(expectedOffset, ElementComposition.GetElementVisual(target)?.Offset); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/CompositionCatalogSmokeTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/CompositionCatalogSmokeTests.cs new file mode 100644 index 000000000..77fb944f6 --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/CompositionCatalogSmokeTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Numerics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class CompositionCatalogSmokeTests +{ + private const double DurationMilliseconds = 10_000d; + + [AvaloniaFact] + public void AttentionCatalog_StartsEveryAnimation() + { + AssertCatalogStarts( + target => AttentionAnimations.SetBounce(target, DurationMilliseconds), + target => AttentionAnimations.SetFlash(target, DurationMilliseconds), + target => AttentionAnimations.SetPulse(target, DurationMilliseconds), + target => AttentionAnimations.SetRubberBand(target, DurationMilliseconds), + target => AttentionAnimations.SetShakeX(target, DurationMilliseconds), + target => AttentionAnimations.SetShakeY(target, DurationMilliseconds), + target => AttentionAnimations.SetHeadShake(target, DurationMilliseconds), + target => AttentionAnimations.SetSwing(target, DurationMilliseconds), + target => AttentionAnimations.SetTada(target, DurationMilliseconds), + target => AttentionAnimations.SetWobble(target, DurationMilliseconds), + target => AttentionAnimations.SetJello(target, DurationMilliseconds), + target => AttentionAnimations.SetHeartBeat(target, DurationMilliseconds)); + } + + [AvaloniaFact] + public void EntranceCatalog_StartsEveryAnimation() + { + AssertCatalogStarts( + target => EntranceAnimations.SetBackInDown(target, DurationMilliseconds), + target => EntranceAnimations.SetBackInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetBackInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetBackInUp(target, DurationMilliseconds), + target => EntranceAnimations.SetBounceIn(target, DurationMilliseconds), + target => EntranceAnimations.SetBounceInDown(target, DurationMilliseconds), + target => EntranceAnimations.SetBounceInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetBounceInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetBounceInUp(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeIn(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInDown(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInDownBig(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInLeftBig(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInRightBig(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInUp(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInUpBig(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInTopLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInTopRight(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInBottomLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInBottomRight(target, DurationMilliseconds), + target => EntranceAnimations.SetFadeInZoom(target, DurationMilliseconds), + target => EntranceAnimations.SetFlipInX(target, DurationMilliseconds), + target => EntranceAnimations.SetFlipInY(target, DurationMilliseconds), + target => EntranceAnimations.SetLightSpeedInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetLightSpeedInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetRotateIn(target, DurationMilliseconds), + target => EntranceAnimations.SetRotateInDownLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetRotateInDownRight(target, DurationMilliseconds), + target => EntranceAnimations.SetRotateInUpLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetRotateInUpRight(target, DurationMilliseconds), + target => EntranceAnimations.SetSlideInDown(target, DurationMilliseconds), + target => EntranceAnimations.SetSlideInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetSlideInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetSlideInUp(target, DurationMilliseconds), + target => EntranceAnimations.SetZoomIn(target, DurationMilliseconds), + target => EntranceAnimations.SetZoomInDown(target, DurationMilliseconds), + target => EntranceAnimations.SetZoomInLeft(target, DurationMilliseconds), + target => EntranceAnimations.SetZoomInRight(target, DurationMilliseconds), + target => EntranceAnimations.SetZoomInUp(target, DurationMilliseconds), + target => EntranceAnimations.SetJackInTheBox(target, DurationMilliseconds), + target => EntranceAnimations.SetRollIn(target, DurationMilliseconds)); + } + + [AvaloniaFact] + public void ExitCatalog_StartsEveryAnimation() + { + AssertCatalogStarts( + target => ExitAnimations.SetBackOutDown(target, DurationMilliseconds), + target => ExitAnimations.SetBackOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetBackOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetBackOutUp(target, DurationMilliseconds), + target => ExitAnimations.SetBounceOut(target, DurationMilliseconds), + target => ExitAnimations.SetBounceOutDown(target, DurationMilliseconds), + target => ExitAnimations.SetBounceOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetBounceOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetBounceOutUp(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOut(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutDown(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutDownBig(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutLeftBig(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutRightBig(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutUp(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutUpBig(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutTopLeft(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutTopRight(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutBottomLeft(target, DurationMilliseconds), + target => ExitAnimations.SetFadeOutBottomRight(target, DurationMilliseconds), + target => ExitAnimations.SetFlipOutX(target, DurationMilliseconds), + target => ExitAnimations.SetFlipOutY(target, DurationMilliseconds), + target => ExitAnimations.SetLightSpeedOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetLightSpeedOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetRotateOut(target, DurationMilliseconds), + target => ExitAnimations.SetRotateOutDownLeft(target, DurationMilliseconds), + target => ExitAnimations.SetRotateOutDownRight(target, DurationMilliseconds), + target => ExitAnimations.SetRotateOutUpLeft(target, DurationMilliseconds), + target => ExitAnimations.SetRotateOutUpRight(target, DurationMilliseconds), + target => ExitAnimations.SetSlideOutDown(target, DurationMilliseconds), + target => ExitAnimations.SetSlideOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetSlideOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetSlideOutUp(target, DurationMilliseconds), + target => ExitAnimations.SetZoomOut(target, DurationMilliseconds), + target => ExitAnimations.SetZoomOutDown(target, DurationMilliseconds), + target => ExitAnimations.SetZoomOutLeft(target, DurationMilliseconds), + target => ExitAnimations.SetZoomOutRight(target, DurationMilliseconds), + target => ExitAnimations.SetZoomOutUp(target, DurationMilliseconds), + target => ExitAnimations.SetHinge(target, DurationMilliseconds), + target => ExitAnimations.SetRollOut(target, DurationMilliseconds)); + } + + [AvaloniaFact] + public void FramerMotionCatalog_StartsEveryAnimation() + { + AssertCatalogStarts( + target => FramerMotionAnimations.SetFadeIn(target, DurationMilliseconds), + target => FramerMotionAnimations.SetFadeInUp(target, DurationMilliseconds), + target => FramerMotionAnimations.SetFadeInDown(target, DurationMilliseconds), + target => FramerMotionAnimations.SetFadeInLeft(target, DurationMilliseconds), + target => FramerMotionAnimations.SetFadeInRight(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSlideInFromLeft(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSlideInFromRight(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSlideInFromTop(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSlideInFromBottom(target, DurationMilliseconds), + target => FramerMotionAnimations.SetPopIn(target, DurationMilliseconds), + target => FramerMotionAnimations.SetPopOut(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSpringIn(target, DurationMilliseconds), + target => FramerMotionAnimations.SetSpringOut(target, DurationMilliseconds), + target => FramerMotionAnimations.SetRotateIn(target, DurationMilliseconds), + target => FramerMotionAnimations.SetRotateOut(target, DurationMilliseconds), + target => FramerMotionAnimations.SetScaleIn(target, DurationMilliseconds), + target => FramerMotionAnimations.SetScaleOut(target, DurationMilliseconds)); + } + + [AvaloniaFact] + public void PrimitiveCatalogs_StartEveryAnimation() + { + AssertCatalogStarts( + target => FadeAnimation.SetFadeIn(target, DurationMilliseconds), + target => FadeAnimation.SetFadeOut(target, DurationMilliseconds), + target => FadeAnimation.SetCustomFade(target, 0.2d, 0.8d, DurationMilliseconds), + target => SlidingAnimation.SetLeft(target, DurationMilliseconds), + target => SlidingAnimation.SetRight(target, DurationMilliseconds), + target => SlidingAnimation.SetTop(target, DurationMilliseconds), + target => SlidingAnimation.SetBottom(target, DurationMilliseconds), + target => ScaleAnimation.SetScaleIn(target, DurationMilliseconds), + target => ScaleAnimation.SetScaleOut(target, DurationMilliseconds), + target => ScaleAnimation.SetZoomIn(target, DurationMilliseconds), + target => ScaleAnimation.SetZoomOut(target, DurationMilliseconds), + target => ScaleAnimation.SetBounce(target, DurationMilliseconds), + target => ScaleAnimation.SetCustomScale(target, 0.8d, 0.9d, 1d, 1d, DurationMilliseconds), + target => RotateAnimation.SetRotateClockwise(target, DurationMilliseconds), + target => RotateAnimation.SetRotateCounterClockwise(target, DurationMilliseconds), + target => RotateAnimation.SetRotateIn(target, DurationMilliseconds), + target => RotateAnimation.SetRotateOut(target, DurationMilliseconds), + target => RotateAnimation.SetFlip(target, DurationMilliseconds), + target => RotateAnimation.SetSwing(target, DurationMilliseconds), + target => RotateAnimation.SetCustomRotate(target, -30d, 30d, DurationMilliseconds), + target => SpecialAnimations.SetFlip(target, DurationMilliseconds)); + } + + [AvaloniaFact] + public void ZeroDuration_AppliesFinalCompositionStatesWithoutStartingInvalidAnimations() + { + var attention = new Border { Width = 32d, Height = 32d }; + var entrance = new Border { Width = 32d, Height = 32d }; + var exit = new Border { Width = 32d, Height = 32d }; + var fade = new Border { Width = 32d, Height = 32d }; + var scale = new Border { Width = 32d, Height = 32d }; + var rotate = new Border { Width = 32d, Height = 32d }; + var sliding = new Border { Width = 32d, Height = 32d }; + var special = new Border { Width = 32d, Height = 32d }; + var framerMotion = new Border { Width = 32d, Height = 32d }; + var targets = new[] { attention, entrance, exit, fade, scale, rotate, sliding, special, framerMotion }; + var canvas = new Canvas { Width = 300d, Height = 400d }; + foreach (Border target in targets) + { + Canvas.SetLeft(target, 30d); + Canvas.SetTop(target, 40d); + canvas.Children.Add(target); + } + + AttentionAnimations.SetBounce(attention, 0d); + EntranceAnimations.SetSlideInLeft(entrance, 0d); + ExitAnimations.SetSlideOutDown(exit, 0d); + FadeAnimation.SetCustomFade(fade, 0.2d, 0.8d, 0d); + ScaleAnimation.SetScaleOut(scale, 0d); + RotateAnimation.SetCustomRotate(rotate, -30d, 30d, 0d); + SlidingAnimation.SetLeft(sliding, 0d); + SpecialAnimations.SetFlip(special, 0d); + FramerMotionAnimations.SetSlideInFromLeft(framerMotion, 0d); + + var window = new Window { Content = canvas }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(new Vector3(30f, 40f, 0f), GetVisual(attention).Offset); + Assert.Equal(new Vector3(30f, 40f, 0f), GetVisual(entrance).Offset); + Assert.Equal(new Vector3(30f, 280f, 0f), GetVisual(exit).Offset); + Assert.Equal(0.8f, GetVisual(fade).Opacity); + Assert.Equal(Vector3.Zero, GetVisual(scale).Scale); + Assert.Equal(CompositionAnimationHelpers.DegreesToRadians(30f), GetVisual(rotate).RotationAngle); + Assert.Equal(new Vector3(30f, 40f, 0f), GetVisual(sliding).Offset); + Assert.Equal(Vector3.One, GetVisual(special).Scale); + Assert.Equal(0f, GetVisual(special).RotationAngle); + Assert.Equal(new Vector3(30f, 40f, 0f), GetVisual(framerMotion).Offset); + } + finally + { + window.Close(); + } + } + + private static void AssertCatalogStarts(params Action[] configureAnimations) + { + var panel = new StackPanel(); + foreach (Action configure in configureAnimations) + { + var target = new Border { Width = 32d, Height = 32d }; + configure(target); + panel.Children.Add(target); + } + + var window = new Window { Content = panel }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.All( + panel.Children, + target => Assert.NotNull(ElementComposition.GetElementVisual(target))); + } + finally + { + window.Close(); + } + } + + private static CompositionVisual GetVisual(Visual target) + { + return Assert.IsAssignableFrom(ElementComposition.GetElementVisual(target)); + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/SelectingItemsControlAnimationTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/SelectingItemsControlAnimationTests.cs new file mode 100644 index 000000000..3efe5778f --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/SelectingItemsControlAnimationTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class SelectingItemsControlAnimationTests +{ + [AvaloniaFact] + public void EnableSelectionAnimation_AttachedPropertyRoundTrips() + { + var target = new ListBox(); + + SelectingItemsControlBehavior.SetEnableSelectionAnimation(target, true); + + Assert.True(SelectingItemsControlBehavior.GetEnableSelectionAnimation(target)); + } + + [AvaloniaFact] + public void SelectionIndicatorAnimation_InstallsImplicitAnimationForTemplatedContainers() + { + var oldIndicator = new Border { Name = "PART_SelectedPipe", Width = 4d, Height = 30d }; + var newIndicator = new Border { Name = "PART_SelectedPipe", Width = 4d, Height = 30d }; + var oldSelection = new ContentControl { Content = oldIndicator, Height = 40d }; + var newSelection = new ContentControl { Content = newIndicator, Height = 40d }; + var panel = new StackPanel { Children = { oldSelection, newSelection } }; + var window = new Window { Content = panel }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + bool started = SelectionIndicatorAnimation.TryStart( + newSelection, + oldSelection, + SelectionIndicatorAnimation.DefaultDuration); + + Assert.True(started); + Assert.NotNull(ElementComposition.GetElementVisual(newIndicator)?.ImplicitAnimations); + window.Close(); + } + + [AvaloniaFact] + public void SelectionIndicatorAnimation_RejectsNegativeDurationAndMissingVisuals() + { + Assert.Equal(TimeSpan.FromMilliseconds(250), SelectionIndicatorAnimation.DefaultDuration); + Assert.False(SelectionIndicatorAnimation.TryStart( + new ContentControl(), + new ContentControl(), + TimeSpan.Zero)); + Assert.Throws(() => SelectionIndicatorAnimation.TryStart( + new ContentControl(), + new ContentControl(), + TimeSpan.FromMilliseconds(-1d))); + } + + [AvaloniaFact] + public void SelectionIndicatorAnimation_ReturnsFalseForZeroDurationWithAvailableVisuals() + { + var oldIndicator = new Border { Width = 4d, Height = 30d }; + var newIndicator = new Border { Width = 4d, Height = 30d }; + var oldSelection = new ContentControl { Content = oldIndicator, Height = 40d }; + var newSelection = new ContentControl { Content = newIndicator, Height = 40d }; + var panel = new StackPanel { Children = { oldSelection, newSelection } }; + var window = new Window { Content = panel }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + try + { + Assert.False(SelectionIndicatorAnimation.TryStart( + newIndicator, + oldIndicator, + newSelection, + oldSelection, + TimeSpan.Zero)); + } + finally + { + window.Close(); + } + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/TestAppBuilder.cs b/tests/Xaml.Behaviors.Animations.UnitTests/TestAppBuilder.cs new file mode 100644 index 000000000..e44142e4c --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/TestAppBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using Avalonia; +using Avalonia.Headless; + +[assembly: AvaloniaTestApplication(typeof(Xaml.Behaviors.Animations.UnitTests.TestAppBuilder))] + +namespace Xaml.Behaviors.Animations.UnitTests; + +public static class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder + .Configure() + .UseSkia() + .UseHarfBuzz() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/TransitionOperationsTests.cs b/tests/Xaml.Behaviors.Animations.UnitTests/TransitionOperationsTests.cs new file mode 100644 index 000000000..30fb30950 --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/TransitionOperationsTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Xaml.Interactions.Custom; +using Xunit; + +namespace Xaml.Behaviors.Animations.UnitTests; + +public class TransitionOperationsTests +{ + [AvaloniaFact] + public void AddRemoveAndClear_ManageTransitions() + { + var target = new Border(); + var first = new DoubleTransition { Property = Visual.OpacityProperty }; + var second = new DoubleTransition { Property = Border.WidthProperty }; + + Assert.True(TransitionOperations.Add(target, first)); + Assert.True(TransitionOperations.Add(target, second)); + Assert.Equal(2, target.Transitions?.Count); + Assert.True(TransitionOperations.Remove(target, first)); + Assert.Single(target.Transitions!); + Assert.True(TransitionOperations.Clear(target)); + Assert.Empty(target.Transitions!); + } + + [AvaloniaFact] + public void Replace_ReturnsPreviousCollection() + { + var target = new Border(); + var previous = new Transitions(); + var replacement = new Transitions(); + target.Transitions = previous; + + Transitions? result = TransitionOperations.Replace(target, replacement); + + Assert.Same(previous, result); + Assert.Same(replacement, target.Transitions); + } + + [AvaloniaFact] + public void Operations_ReturnFalseForMissingInputs() + { + Assert.False(TransitionOperations.Add(null, null)); + Assert.False(TransitionOperations.Remove(null, null)); + Assert.False(TransitionOperations.Clear(null)); + } + + [AvaloniaFact] + public void Observe_ReportsCurrentAndReplacementCollectionsUntilDisposed() + { + var target = new Border(); + var replacement = new Transitions(); + var afterDisposal = new Transitions(); + var observed = new List(); + + using (TransitionOperations.Observe(target, observed.Add)) + { + target.Transitions = replacement; + } + + target.Transitions = afterDisposal; + + Assert.Collection( + observed, + transitions => Assert.Null(transitions), + transitions => Assert.Same(replacement, transitions)); + } + + [AvaloniaFact] + public void Observe_RejectsMissingArguments() + { + var target = new Border(); + + Assert.Throws(() => TransitionOperations.Observe(null!, _ => { })); + Assert.Throws(() => TransitionOperations.Observe(target, null!)); + } +} diff --git a/tests/Xaml.Behaviors.Animations.UnitTests/Xaml.Behaviors.Animations.UnitTests.csproj b/tests/Xaml.Behaviors.Animations.UnitTests/Xaml.Behaviors.Animations.UnitTests.csproj new file mode 100644 index 000000000..022d396e2 --- /dev/null +++ b/tests/Xaml.Behaviors.Animations.UnitTests/Xaml.Behaviors.Animations.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + Library + False + enable + + + + + + + + + + + + + + + + + + diff --git a/tests/Xaml.Behaviors.Interactions.UnitTests/Custom/AnimationAdapterTests.cs b/tests/Xaml.Behaviors.Interactions.UnitTests/Custom/AnimationAdapterTests.cs new file mode 100644 index 000000000..4dfc4f040 --- /dev/null +++ b/tests/Xaml.Behaviors.Interactions.UnitTests/Custom/AnimationAdapterTests.cs @@ -0,0 +1,287 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.Xaml.Interactions.Custom; +using Avalonia.Xaml.Interactivity; +using Xunit; + +namespace Avalonia.Xaml.Interactions.UnitTests.Custom; + +public class AnimationAdapterTests +{ + private sealed class RecordingAnimationBuilder : IAnimationBuilder + { + public int BuildCount { get; private set; } + + public Avalonia.Animation.Animation? Build(Control control) + { + BuildCount++; + return new Avalonia.Animation.Animation { Duration = TimeSpan.Zero }; + } + } + + private sealed class RecordingAction : AvaloniaObject, IAction + { + public int ExecutionCount { get; private set; } + + public object Execute(object? sender, object? parameter) + { + ExecutionCount++; + return true; + } + } + + [AvaloniaFact] + public void TransitionsBehavior_AppliesAndRestoresTransitions() + { + var original = new Transitions(); + var replacement = new Transitions(); + var target = new Border { Transitions = original }; + var panel = new Panel { Children = { target } }; + var behavior = new TransitionsBehavior(); + behavior.SetValue(TransitionsBehavior.TransitionsSourceProperty, replacement); + Assert.Same(replacement, behavior.TransitionsSource); + Interaction.GetBehaviors(target).Add(behavior); + var window = new Window { Content = panel }; + + window.Show(); + + Assert.Same(replacement, target.Transitions); + + panel.Children.Remove(target); + + Assert.Same(original, target.Transitions); + window.Close(); + } + + [AvaloniaFact] + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "This test intentionally exercises Avalonia's runtime XAML compiler.")] + public void RuntimeLoader_ResolvesStandaloneAnimationsAndBehaviorAdapters() + { + const string xaml = """ + + + + + + + + + """; + + object result = AvaloniaRuntimeXamlLoader.Load( + xaml, + typeof(AnimationAdapterTests).Assembly, + designMode: true); + + var panel = Assert.IsType(result); + Assert.Equal(2, panel.Children.Count); + var border = Assert.IsType(panel.Children[1]); + Assert.IsType(Assert.Single(Interaction.GetBehaviors(border))); + } + + [AvaloniaFact] + public void TransitionsChangedTrigger_ExecutesForSharedTransitionObservation() + { + var target = new Border(); + var action = new RecordingAction(); + var trigger = new TransitionsChangedTrigger(); + trigger.Actions!.Add(action); + Interaction.GetBehaviors(target).Add(trigger); + Dispatcher.UIThread.RunJobs(); + int initialCount = action.ExecutionCount; + + target.Transitions = new Transitions(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(initialCount + 1, action.ExecutionCount); + } + + [AvaloniaFact] + public void FluidMoveBehavior_AnimatesChangedChildPositionThroughSharedPrimitive() + { + var child = new Border { Width = 40d, Height = 40d }; + Canvas.SetLeft(child, 0d); + var canvas = new Canvas { Width = 200d, Height = 100d, Children = { child } }; + var behavior = new FluidMoveBehavior + { + AppliesTo = FluidMoveScope.Children, + Duration = TimeSpan.Zero + }; + Interaction.GetBehaviors(canvas).Add(behavior); + var window = new Window { Content = canvas }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Canvas.SetLeft(child, 80d); + Dispatcher.UIThread.RunJobs(); + + Assert.IsType(child.RenderTransform); + window.Close(); + } + + [AvaloniaFact] + public void ParallaxBehavior_TracksScrollOffsetThroughSharedPrimitive() + { + var target = new Border { Width = 100d, Height = 100d }; + Canvas.SetLeft(target, 30d); + Canvas.SetTop(target, 40d); + var content = new Canvas { Width = 500d, Height = 1000d, Children = { target } }; + var scrollViewer = new ScrollViewer + { + Width = 300d, + Height = 300d, + Content = content + }; + var behavior = new ParallaxBehavior + { + SourceScrollViewer = scrollViewer, + ParallaxRatio = 0.25d + }; + Interaction.GetBehaviors(target).Add(behavior); + var window = new Window { Content = scrollViewer }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + scrollViewer.Offset = new Vector(20d, 100d); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal( + new System.Numerics.Vector3(35f, 65f, 0f), + ElementComposition.GetElementVisual(target)?.Offset); + window.Close(); + } + + [AvaloniaFact] + public void AnimationActions_PreserveTargetsResultsAndBuilderPrecedence() + { + var target = new Border(); + var animation = new Avalonia.Animation.Animation { Duration = TimeSpan.Zero }; + + var start = new StartAnimationAction { Animation = animation }; + Assert.False(Assert.IsType(start.Execute(null, null))); + Assert.True(Assert.IsType(start.Execute(target, null))); + + var begin = new BeginAnimationAction { Animation = animation, TargetControl = target }; + Assert.True(Assert.IsType(begin.Execute(sender: null, parameter: null))); + begin.IsEnabled = false; + Assert.False(Assert.IsType(begin.Execute(target, null))); + + var builder = new RecordingAnimationBuilder(); + var built = new StartBuiltAnimationAction { AnimationBuilder = builder }; + Assert.True(Assert.IsType(built.Execute(target, null))); + Assert.Equal(1, builder.BuildCount); + + built.Animation = animation; + Assert.True(Assert.IsType(built.Execute(target, null))); + Assert.Equal(1, builder.BuildCount); + } + + [AvaloniaFact] + public void AnimateOnAttachedBehavior_UsesBuilderWhenAnimationIsMissing() + { + var target = new Border(); + var builder = new RecordingAnimationBuilder(); + Interaction.GetBehaviors(target).Add(new AnimateOnAttachedBehavior + { + AnimationBuilder = builder + }); + var window = new Window { Content = target }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1, builder.BuildCount); + window.Close(); + } + + [AvaloniaFact] + public void PlayAnimationBehavior_RunsConfiguredAnimationOnAssociatedVisual() + { + var target = new Border(); + var animation = new Avalonia.Animation.Animation + { + Duration = TimeSpan.Zero, + FillMode = FillMode.Forward, + Children = + { + new KeyFrame + { + Cue = new Cue(1d), + Setters = { new Setter(Visual.OpacityProperty, 0.25d) } + } + } + }; + Interaction.GetBehaviors(target).Add(new PlayAnimationBehavior { Animation = animation }); + var window = new Window { Content = target }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0.25d, target.Opacity); + window.Close(); + } + + [AvaloniaFact] + public void AnimationCompletedTrigger_ExecutesImmediatelyWithoutAnimation() + { + var target = new Border(); + var action = new RecordingAction(); + var trigger = new AnimationCompletedTrigger(); + trigger.Actions!.Add(action); + Interaction.GetBehaviors(target).Add(trigger); + var window = new Window { Content = target }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1, action.ExecutionCount); + window.Close(); + } + + [AvaloniaFact] + public async Task RunAnimationTrigger_UsesBuilderAndExecutesAfterCompletion() + { + var target = new Border(); + var builder = new RecordingAnimationBuilder(); + var action = new RecordingAction(); + var trigger = new RunAnimationTrigger { AnimationBuilder = builder }; + trigger.Actions!.Add(action); + Interaction.GetBehaviors(target).Add(trigger); + var window = new Window { Content = target }; + + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(1, builder.BuildCount); + Assert.Equal(1, action.ExecutionCount); + } + finally + { + window.Close(); + } + } +}