diff --git a/Photos/Animations/CardHoverEffectBehavior.cs b/Photos/Animations/CardHoverEffectBehavior.cs new file mode 100644 index 0000000..6349461 --- /dev/null +++ b/Photos/Animations/CardHoverEffectBehavior.cs @@ -0,0 +1,159 @@ +using System; +using System.Numerics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Rendering.Composition; + +namespace Photos.Animations; + +public static class CardHoverEffectBehavior +{ + private static readonly TimeSpan _animationDuration = TimeSpan.FromMilliseconds(150); + + public static readonly AttachedProperty IsEnabledProperty = + AvaloniaProperty.RegisterAttached( + "IsEnabled", + typeof(CardHoverEffectBehavior), + false); + + public static readonly AttachedProperty MaxRotationAngleProperty = + AvaloniaProperty.RegisterAttached( + "MaxRotationAngle", + typeof(CardHoverEffectBehavior), + 5.5); + + public static readonly AttachedProperty ScaleFactorProperty = + AvaloniaProperty.RegisterAttached( + "ScaleFactor", + typeof(CardHoverEffectBehavior), + 0.99); + + static CardHoverEffectBehavior() + { + IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); + } + + public static bool GetIsEnabled(AvaloniaObject obj) => obj.GetValue(IsEnabledProperty); + public static void SetIsEnabled(AvaloniaObject obj, bool value) => obj.SetValue(IsEnabledProperty, value); + + public static double GetMaxRotationAngle(AvaloniaObject obj) => obj.GetValue(MaxRotationAngleProperty); + public static void SetMaxRotationAngle(AvaloniaObject obj, double value) => obj.SetValue(MaxRotationAngleProperty, value); + + public static double GetScaleFactor(AvaloniaObject obj) => obj.GetValue(ScaleFactorProperty); + public static void SetScaleFactor(AvaloniaObject obj, double value) => obj.SetValue(ScaleFactorProperty, value); + + private static void OnIsEnabledChanged(Control c, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is not bool enabled) + return; + + if (enabled) + { + c.PointerEntered += Element_PointerEntered; + c.PointerMoved += Element_PointerMoved; + c.PointerExited += Element_PointerExited; + } + else + { + c.PointerEntered -= Element_PointerEntered; + c.PointerMoved -= Element_PointerMoved; + c.PointerExited -= Element_PointerExited; + } + } + + private static void Element_PointerEntered(object? sender, PointerEventArgs e) + { + if (sender is not Control element) + return; + + element.ZIndex = 1; + var visual = ElementComposition.GetElementVisual(element); + var compositor = visual?.Compositor; + + if (visual is null || compositor is null) + return; + + var scaleFactor = (float)GetScaleFactor(element); + + var width = (float)element.Bounds.Width; + var height = (float)element.Bounds.Height; + + if (width <= 0 || height <= 0) + return; + + visual.CenterPoint = new Vector3(width / 2f, height / 2f, 0f); + + var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.InsertKeyFrame(1.0f, new Vector3(scaleFactor, scaleFactor, 1f)); + scaleAnimation.Duration = _animationDuration; + + var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation(); + orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity); + orientationAnimation.Duration = _animationDuration; + + visual.StartAnimation("Scale", scaleAnimation); + visual.StartAnimation(nameof(visual.Orientation), orientationAnimation); + } + + private static void Element_PointerMoved(object? sender, PointerEventArgs e) + { + if (sender is not Control element) + return; + + var visual = ElementComposition.GetElementVisual(element); + + if (visual is null) + return; + + var maxRotationAngle = (float)GetMaxRotationAngle(element); + + var width = element.Bounds.Width; + var height = element.Bounds.Height; + + if (width <= 0 || height <= 0) + return; + + var position = e.GetPosition(element); + var centerX = width / 2.0; + var centerY = height / 2.0; + + var normalizedX = (position.X - centerX) / centerX; + var normalizedY = (position.Y - centerY) / centerY; + + var rotationY_deg = (float)(normalizedX * maxRotationAngle); + var rotationX_deg = (float)(-normalizedY * maxRotationAngle); + + var rotationX_rad = (float)(Math.PI / 180.0 * rotationX_deg); + var rotationY_rad = (float)(Math.PI / 180.0 * rotationY_deg); + + var qx = Quaternion.CreateFromAxisAngle(Vector3.UnitX, rotationX_rad); + var qy = Quaternion.CreateFromAxisAngle(Vector3.UnitY, rotationY_rad); + + visual.Orientation = qy * qx; + } + + private static void Element_PointerExited(object? sender, PointerEventArgs e) + { + if (sender is not Control element) + return; + + element.ZIndex = 0; + var visual = ElementComposition.GetElementVisual(element); + var compositor = visual?.Compositor; + + if (visual is null || compositor is null) + return; + + var scaleAnimation = compositor.CreateVector3KeyFrameAnimation(); + scaleAnimation.InsertKeyFrame(1.0f, new Vector3(1f, 1f, 1f)); + scaleAnimation.Duration = _animationDuration; + + var orientationAnimation = compositor.CreateQuaternionKeyFrameAnimation(); + orientationAnimation.InsertKeyFrame(1.0f, Quaternion.Identity); + orientationAnimation.Duration = _animationDuration; + + visual.StartAnimation("Scale", scaleAnimation); + visual.StartAnimation(nameof(visual.Orientation), orientationAnimation); + } +} \ No newline at end of file diff --git a/Photos/Models/Photo.cs b/Photos/Models/Photo.cs index 5c9979d..aa44e2e 100644 --- a/Photos/Models/Photo.cs +++ b/Photos/Models/Photo.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -96,7 +97,7 @@ public static partial class PhotoExtension .ToList(); } - public static void CreateAndAddPhotosToList(List photos, string rootPath, IList toAddTo) + public static void CreateAndAddPhotosToList(List photos, string rootPath, ObservableCollection toAddTo) { var bag = new ConcurrentBag(); @@ -105,12 +106,12 @@ public static partial class PhotoExtension bag.Add(new Photo(p, rootPath)); }); - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.Invoke(() => { foreach (var photo in bag) { toAddTo.Add(photo); } - }).GetAwaiter().GetResult(); // TODO: When reopening folder this throws exception + }); // TODO: When reopening folder this throws exception } } \ No newline at end of file diff --git a/Photos/ViewModels/MainWindowViewModel.cs b/Photos/ViewModels/MainWindowViewModel.cs index a332a8e..e0b1d11 100644 --- a/Photos/ViewModels/MainWindowViewModel.cs +++ b/Photos/ViewModels/MainWindowViewModel.cs @@ -35,7 +35,8 @@ public partial class MainWindowViewModel : ViewModelBase var folder = await _folderPickerService.PickFolderAsync(); if (folder is null) return; - foreach(var p in Photos) p.Dispose(); + // foreach(var p in Photos) p.Dispose(); + Photos.Clear(); LibraryPath = folder; await AddPhotosAsync(folder); diff --git a/Photos/Views/MainWindow.axaml b/Photos/Views/MainWindow.axaml index 2efa74c..79f2935 100644 --- a/Photos/Views/MainWindow.axaml +++ b/Photos/Views/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:i="using:Avalonia.Xaml.Interactivity" xmlns:ic="using:Avalonia.Xaml.Interactions.Core" xmlns:models="using:Photos.Models" + xmlns:anim="using:Photos.Animations" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Photos.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -31,42 +32,72 @@