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) | [](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) | [](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) | [](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) | [](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) | [](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();
+ }
+ }
+}