Added some animations, fixed crash when reopening folder

This commit is contained in:
Ano-sys
2026-03-21 17:13:20 +01:00
parent 54268fc0b4
commit d286282f71
4 changed files with 221 additions and 29 deletions
@@ -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<bool> IsEnabledProperty =
AvaloniaProperty.RegisterAttached<Control, bool>(
"IsEnabled",
typeof(CardHoverEffectBehavior),
false);
public static readonly AttachedProperty<double> MaxRotationAngleProperty =
AvaloniaProperty.RegisterAttached<Control, double>(
"MaxRotationAngle",
typeof(CardHoverEffectBehavior),
5.5);
public static readonly AttachedProperty<double> ScaleFactorProperty =
AvaloniaProperty.RegisterAttached<Control, double>(
"ScaleFactor",
typeof(CardHoverEffectBehavior),
0.99);
static CardHoverEffectBehavior()
{
IsEnabledProperty.Changed.AddClassHandler<Control>(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);
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -96,7 +97,7 @@ public static partial class PhotoExtension
.ToList(); .ToList();
} }
public static void CreateAndAddPhotosToList(List<string> photos, string rootPath, IList<Photo> toAddTo) public static void CreateAndAddPhotosToList(List<string> photos, string rootPath, ObservableCollection<Photo> toAddTo)
{ {
var bag = new ConcurrentBag<Photo>(); var bag = new ConcurrentBag<Photo>();
@@ -105,12 +106,12 @@ public static partial class PhotoExtension
bag.Add(new Photo(p, rootPath)); bag.Add(new Photo(p, rootPath));
}); });
Dispatcher.UIThread.InvokeAsync(() => Dispatcher.UIThread.Invoke(() =>
{ {
foreach (var photo in bag) foreach (var photo in bag)
{ {
toAddTo.Add(photo); toAddTo.Add(photo);
} }
}).GetAwaiter().GetResult(); // TODO: When reopening folder this throws exception }); // TODO: When reopening folder this throws exception
} }
} }
+2 -1
View File
@@ -35,7 +35,8 @@ public partial class MainWindowViewModel : ViewModelBase
var folder = await _folderPickerService.PickFolderAsync(); var folder = await _folderPickerService.PickFolderAsync();
if (folder is null) return; if (folder is null) return;
foreach(var p in Photos) p.Dispose(); // foreach(var p in Photos) p.Dispose();
Photos.Clear();
LibraryPath = folder; LibraryPath = folder;
await AddPhotosAsync(folder); await AddPhotosAsync(folder);
+56 -25
View File
@@ -6,6 +6,7 @@
xmlns:i="using:Avalonia.Xaml.Interactivity" xmlns:i="using:Avalonia.Xaml.Interactivity"
xmlns:ic="using:Avalonia.Xaml.Interactions.Core" xmlns:ic="using:Avalonia.Xaml.Interactions.Core"
xmlns:models="using:Photos.Models" xmlns:models="using:Photos.Models"
xmlns:anim="using:Photos.Animations"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Photos.Views.MainWindow" x:Class="Photos.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
@@ -31,42 +32,72 @@
<Button Content="Open Directory" Command="{Binding OpenDirectory}"/> <Button Content="Open Directory" Command="{Binding OpenDirectory}"/>
</Menu> </Menu>
<ScrollViewer Grid.Column="1"> <ScrollViewer Grid.Column="1">
<ItemsRepeater ItemsSource="{Binding Photos}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> <ItemsRepeater ItemsSource="{Binding Photos}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Margin="5"
>
<ItemsRepeater.Layout> <ItemsRepeater.Layout>
<UniformGridLayout MinItemWidth="190" <UniformGridLayout MinItemWidth="190"
MinItemHeight="240" MinItemHeight="240"
MinRowSpacing="12" MinRowSpacing="12"
MinColumnSpacing="12" /> MinColumnSpacing="12"
>
</UniformGridLayout>
</ItemsRepeater.Layout> </ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate> <ItemsRepeater.ItemTemplate>
<DataTemplate DataType="models:Photo"> <DataTemplate DataType="models:Photo">
<StackPanel Orientation="Vertical" Margin="12"> <Border BorderBrush="Black"
<i:Interaction.Behaviors> Background="#aa000000"
<ic:EventTriggerBehavior EventName="PointerReleased"> BorderThickness="1"
<ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}" PassEventArgsToCommand="True"/> CornerRadius="7.5"
</ic:EventTriggerBehavior> anim:CardHoverEffectBehavior.IsEnabled="True"
</i:Interaction.Behaviors> anim:CardHoverEffectBehavior.MaxRotationAngle="3.5"
<Image Source="{Binding PreviewImage}" anim:CardHoverEffectBehavior.ScaleFactor="1.5"
Width="150" >
Height="150" /> <StackPanel Orientation="Vertical" Margin="12">
<TextBlock Text="{Binding DisplayName}" <i:Interaction.Behaviors>
HorizontalAlignment="Center" <ic:EventTriggerBehavior EventName="PointerReleased">
Width="150" /> <ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}" PassEventArgsToCommand="True"/>
<TextBlock Text="{Binding DisplayPath}" HorizontalAlignment="Center" Width="150"/> </ic:EventTriggerBehavior>
</StackPanel> </i:Interaction.Behaviors>
<Image Source="{Binding PreviewImage}"
Width="150"
Height="150"/>
<TextBlock Text="{Binding DisplayName}"
HorizontalAlignment="Center"
Width="150" />
<TextBlock Text="{Binding DisplayPath}" HorizontalAlignment="Center" Width="150"/>
</StackPanel>
</Border>
</DataTemplate> </DataTemplate>
</ItemsRepeater.ItemTemplate> </ItemsRepeater.ItemTemplate>
</ItemsRepeater> </ItemsRepeater>
</ScrollViewer> </ScrollViewer>
<Grid Grid.Column="0" Grid.ColumnSpan="2" IsEnabled="{Binding SelectedPhoto}"> <TransitioningContentControl Grid.Column="0"
<Image Source="{Binding SelectedPhoto.FullImage}" Margin="50"> Grid.ColumnSpan="2"
<i:Interaction.Behaviors> Content="{Binding SelectedPhoto}">
<ic:EventTriggerBehavior EventName="PointerReleased"> <TransitioningContentControl.PageTransition>
<ic:InvokeCommandAction Command="{Binding Path=SelectedPhotoClicked}"/> <CrossFade Duration="0:0:0.18" />
</ic:EventTriggerBehavior> </TransitioningContentControl.PageTransition>
</i:Interaction.Behaviors>
</Image> <TransitioningContentControl.ContentTemplate>
</Grid> <DataTemplate DataType="models:Photo">
<Border Background="#AA000000">
<Image Source="{Binding FullImage}"
Margin="50"
Stretch="Uniform"
>
<i:Interaction.Behaviors>
<ic:EventTriggerBehavior EventName="PointerReleased">
<ic:InvokeCommandAction Command="{Binding Path=DataContext.SelectedPhotoClicked, ElementName=Root}"/>
</ic:EventTriggerBehavior>
</i:Interaction.Behaviors>
</Image>
</Border>
</DataTemplate>
</TransitioningContentControl.ContentTemplate>
</TransitioningContentControl>
</Grid> </Grid>
</Window> </Window>