// 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);
}
}