Compare commits
7 Commits
54268fc0b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bf03a25c0e | |||
| d25e8238f2 | |||
| 162df64596 | |||
| 70f22af18c | |||
| 1ff80890d5 | |||
| 8735570f35 | |||
| d286282f71 |
@@ -0,0 +1,170 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
public static readonly AttachedProperty<bool> SuppressPointerHoverProperty =
|
||||||
|
AvaloniaProperty.RegisterAttached<Control, bool>(
|
||||||
|
"SuppressPointerHover",
|
||||||
|
typeof(CardHoverEffectBehavior),
|
||||||
|
false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
public static bool GetSuppressPointerHover(AvaloniaObject obj) => obj.GetValue(SuppressPointerHoverProperty);
|
||||||
|
|
||||||
|
public static void SetSuppressPointerHover(AvaloniaObject obj, bool value) => obj.SetValue(SuppressPointerHoverProperty, value);
|
||||||
|
|
||||||
|
public static void ApplyHover(Control element)
|
||||||
|
{
|
||||||
|
element.ZIndex = 10;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ResetHover(Control element)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || GetSuppressPointerHover(element)) return;
|
||||||
|
ApplyHover(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Element_PointerExited(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Control element || GetSuppressPointerHover(element)) return;
|
||||||
|
ResetHover(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Element_PointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Control element || GetSuppressPointerHover(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -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
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationIcon>Assets/SimplePhotos.ico</ApplicationIcon>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private ObservableCollection<Photo> _photos = [];
|
[ObservableProperty] private ObservableCollection<Photo> _photos = [];
|
||||||
|
|
||||||
[ObservableProperty] private Photo? _selectedPhoto;
|
[ObservableProperty] private Photo? _selectedPhoto;
|
||||||
|
[ObservableProperty] private bool _photosLoaded = false;
|
||||||
|
|
||||||
public string LibraryPath { get; private set; } = string.Empty;
|
public string LibraryPath { get; private set; } = string.Empty;
|
||||||
|
|
||||||
@@ -35,10 +36,18 @@ 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);
|
||||||
|
PhotosLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseDirectory()
|
||||||
|
{
|
||||||
|
Photos.Clear();
|
||||||
|
PhotosLoaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SanitizeFiles(List<string> files, IEnumerable<string> extensions)
|
private void SanitizeFiles(List<string> files, IEnumerable<string> extensions)
|
||||||
@@ -82,10 +91,28 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
await SelectedPhoto.LoadFullImage();
|
await SelectedPhoto.LoadFullImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectedPhotoClicked()
|
public void ResetSelectedPhoto()
|
||||||
{
|
{
|
||||||
if (SelectedPhoto is null) return;
|
if (SelectedPhoto is null) return;
|
||||||
SelectedPhoto.FullImage = null;
|
SelectedPhoto.FullImage = null;
|
||||||
SelectedPhoto = null;
|
SelectedPhoto = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task TryFocusImage(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= Photos.Count ) return;
|
||||||
|
SelectedPhoto = Photos[index];
|
||||||
|
await SelectedPhoto.LoadFullImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnfocusPhoto() => ResetSelectedPhoto();
|
||||||
|
|
||||||
|
public bool IsPhotoFocused() => SelectedPhoto != null;
|
||||||
|
|
||||||
|
public int GetImageIndex(Image i)
|
||||||
|
{
|
||||||
|
var p = Photos.FirstOrDefault(x => x.PreviewImage == i.Source);
|
||||||
|
if (p is null) return -1;
|
||||||
|
return Photos.IndexOf(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+208
-42
@@ -6,11 +6,14 @@
|
|||||||
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"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
xmlns:anim="using:Photos.Animations"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="1200"
|
||||||
|
d:DesignHeight="800"
|
||||||
x:Class="Photos.Views.MainWindow"
|
x:Class="Photos.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
Icon="/Assets/avalonia-logo.ico"
|
Icon="/Assets/SimplePhotos.ico"
|
||||||
Title="Photos"
|
Title="Photos"
|
||||||
DragDrop.AllowDrop="True"
|
DragDrop.AllowDrop="True"
|
||||||
DragDrop.Drop="DropHandler"
|
DragDrop.Drop="DropHandler"
|
||||||
@@ -18,55 +21,218 @@
|
|||||||
TransparencyLevelHint="AcrylicBlur"
|
TransparencyLevelHint="AcrylicBlur"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
>
|
Focusable="True"
|
||||||
|
KeyDown="OnKeyDownHandler">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
|
||||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
|
||||||
<vm:MainWindowViewModel/>
|
<vm:MainWindowViewModel/>
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="1*, 5*" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
<Grid Background="#0F0F10">
|
||||||
<Menu>
|
<Rectangle Fill="#CC0B0B0C"/>
|
||||||
<Button Content="Open Directory" Command="{Binding OpenDirectory}"/>
|
|
||||||
</Menu>
|
<Grid RowDefinitions="Auto,*">
|
||||||
<ScrollViewer Grid.Column="1">
|
|
||||||
<ItemsRepeater ItemsSource="{Binding Photos}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
<!-- Top Bar -->
|
||||||
<ItemsRepeater.Layout>
|
<Border Grid.Row="0"
|
||||||
<UniformGridLayout MinItemWidth="190"
|
Margin="18,18,18,0"
|
||||||
MinItemHeight="240"
|
Padding="14,10"
|
||||||
MinRowSpacing="12"
|
Background="#CC161618"
|
||||||
MinColumnSpacing="12" />
|
BorderBrush="#22FFFFFF"
|
||||||
</ItemsRepeater.Layout>
|
BorderThickness="1"
|
||||||
|
CornerRadius="14"
|
||||||
|
BoxShadow="0 10 30 0 #50000000"
|
||||||
|
ZIndex="20">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Spacing="10"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Border Width="10"
|
||||||
|
Height="10"
|
||||||
|
CornerRadius="999"
|
||||||
|
Background="#E6FFFFFF"
|
||||||
|
Opacity="0.9"/>
|
||||||
|
<TextBlock Text="Photos"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="16"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#F2FFFFFF"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Menu DockPanel.Dock="Right"
|
||||||
|
Background="Transparent"
|
||||||
|
Foreground="#EDEDED"
|
||||||
|
Margin="5,0,0,0"
|
||||||
|
IsTabStop="False"
|
||||||
|
Focusable="False"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
<MenuItem Header="_File">
|
||||||
|
<MenuItem Header="_Open" Command="{Binding OpenDirectory}"/>
|
||||||
|
<MenuItem Header="_Close" Command="{Binding CloseDirectory}"/>
|
||||||
|
</MenuItem>
|
||||||
|
-->
|
||||||
|
<MenuItem Header="_Open Library" Focusable="False" IsVisible="{Binding !PhotosLoaded}" Command="{Binding OpenDirectory}"/>
|
||||||
|
<MenuItem Header="_Close Library" Focusable="False" IsVisible="{Binding PhotosLoaded}" Command="{Binding CloseDirectory}"/>
|
||||||
|
</Menu>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
IsVisible="{Binding !PhotosLoaded}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
<Border HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="28"
|
||||||
|
Background="#B3141416"
|
||||||
|
BorderBrush="#22FFFFFF"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="18"
|
||||||
|
BoxShadow="0 16 40 0 #40000000">
|
||||||
|
<StackPanel Spacing="14"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="No directory opened"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
FontSize="22"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#F4FFFFFF"/>
|
||||||
|
<TextBlock Text="Choose a folder to display your images."
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="#B8FFFFFF"/>
|
||||||
|
<Button Content="Open Directory"
|
||||||
|
Command="{Binding OpenDirectory}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Padding="20,10"
|
||||||
|
CornerRadius="10"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Galerie -->
|
||||||
|
<ScrollViewer Grid.Row="0"
|
||||||
|
Grid.RowSpan="2"
|
||||||
|
IsVisible="{Binding PhotosLoaded}">
|
||||||
|
<Border Padding="72,88,72,48">
|
||||||
|
<ItemsRepeater x:Name="Repeater"
|
||||||
|
ItemsSource="{Binding Photos}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
ClipToBounds="False"
|
||||||
|
IsTabStop="True"
|
||||||
|
>
|
||||||
|
<ItemsRepeater.Layout>
|
||||||
|
<UniformGridLayout MinItemWidth="220"
|
||||||
|
MinItemHeight="290"
|
||||||
|
MinRowSpacing="18"
|
||||||
|
MinColumnSpacing="18"/>
|
||||||
|
</ItemsRepeater.Layout>
|
||||||
|
|
||||||
|
<ItemsRepeater.ItemTemplate>
|
||||||
|
<DataTemplate DataType="models:Photo">
|
||||||
|
<Border Background="#B8141416"
|
||||||
|
BorderBrush="#22FFFFFF"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="18"
|
||||||
|
Padding="14"
|
||||||
|
BoxShadow="0 10 24 0 #30000000"
|
||||||
|
anim:CardHoverEffectBehavior.IsEnabled="True"
|
||||||
|
anim:CardHoverEffectBehavior.MaxRotationAngle="2.5"
|
||||||
|
anim:CardHoverEffectBehavior.ScaleFactor="1.08"
|
||||||
|
>
|
||||||
|
<StackPanel Orientation="Vertical"
|
||||||
|
Spacing="10">
|
||||||
|
<i:Interaction.Behaviors>
|
||||||
|
<ic:EventTriggerBehavior EventName="PointerReleased">
|
||||||
|
<ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}"
|
||||||
|
PassEventArgsToCommand="True"/>
|
||||||
|
</ic:EventTriggerBehavior>
|
||||||
|
</i:Interaction.Behaviors>
|
||||||
|
|
||||||
|
<Border Width="180"
|
||||||
|
Height="180"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
CornerRadius="12"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#14000000">
|
||||||
|
<Image Source="{Binding PreviewImage}"
|
||||||
|
Stretch="UniformToFill"
|
||||||
|
PointerEntered="PointerEnteredPhotoHandler"
|
||||||
|
/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding DisplayName}"
|
||||||
|
ToolTip.Tip="{Binding DisplayName}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Width="180"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#F1FFFFFF"/>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding DisplayPath}"
|
||||||
|
ToolTip.Tip="{Binding DisplayPath}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Width="180"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
MaxLines="2"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="#99FFFFFF"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsRepeater.ItemTemplate>
|
||||||
|
</ItemsRepeater>
|
||||||
|
</Border>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Photo-Overlay -->
|
||||||
|
<TransitioningContentControl Grid.Row="0"
|
||||||
|
Grid.RowSpan="2"
|
||||||
|
Content="{Binding SelectedPhoto}"
|
||||||
|
ZIndex="50"
|
||||||
|
>
|
||||||
|
<TransitioningContentControl.PageTransition>
|
||||||
|
<CrossFade Duration="0:0:0.18"/>
|
||||||
|
</TransitioningContentControl.PageTransition>
|
||||||
|
|
||||||
<ItemsRepeater.ItemTemplate>
|
|
||||||
<DataTemplate DataType="models:Photo">
|
|
||||||
<StackPanel Orientation="Vertical" Margin="12">
|
|
||||||
<i:Interaction.Behaviors>
|
|
||||||
<ic:EventTriggerBehavior EventName="PointerReleased">
|
|
||||||
<ic:InvokeCommandAction Command="{Binding DataContext.PhotoClicked, ElementName=Root}" PassEventArgsToCommand="True"/>
|
|
||||||
</ic:EventTriggerBehavior>
|
|
||||||
</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>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsRepeater.ItemTemplate>
|
|
||||||
</ItemsRepeater>
|
|
||||||
</ScrollViewer>
|
|
||||||
<Grid Grid.Column="0" Grid.ColumnSpan="2" IsEnabled="{Binding SelectedPhoto}">
|
|
||||||
<Image Source="{Binding SelectedPhoto.FullImage}" Margin="50">
|
|
||||||
<i:Interaction.Behaviors>
|
<i:Interaction.Behaviors>
|
||||||
<ic:EventTriggerBehavior EventName="PointerReleased">
|
<ic:EventTriggerBehavior EventName="PointerReleased">
|
||||||
<ic:InvokeCommandAction Command="{Binding Path=SelectedPhotoClicked}"/>
|
<ic:InvokeCommandAction Command="{Binding Path=DataContext.ResetSelectedPhoto, ElementName=Root}"/>
|
||||||
</ic:EventTriggerBehavior>
|
</ic:EventTriggerBehavior>
|
||||||
</i:Interaction.Behaviors>
|
</i:Interaction.Behaviors>
|
||||||
</Image>
|
|
||||||
|
<TransitioningContentControl.ContentTemplate>
|
||||||
|
<DataTemplate DataType="models:Photo">
|
||||||
|
<Grid>
|
||||||
|
<Rectangle Fill="#E6101011"/>
|
||||||
|
|
||||||
|
<Border Margin="36"
|
||||||
|
Padding="18"
|
||||||
|
Background="#12000000"
|
||||||
|
BorderBrush="#18FFFFFF"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="24">
|
||||||
|
<Grid RowDefinitions="*, auto">
|
||||||
|
<Image Source="{Binding FullImage}"
|
||||||
|
Stretch="Uniform">
|
||||||
|
<i:Interaction.Behaviors>
|
||||||
|
<ic:EventTriggerBehavior EventName="PointerReleased">
|
||||||
|
<ic:InvokeCommandAction Command="{Binding Path=DataContext.ResetSelectedPhoto, ElementName=Root}"/>
|
||||||
|
</ic:EventTriggerBehavior>
|
||||||
|
</i:Interaction.Behaviors>
|
||||||
|
</Image>
|
||||||
|
<TextBlock Grid.Row="1" Text="{Binding DisplayName}" HorizontalAlignment="Center" VerticalAlignment="Bottom"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</TransitioningContentControl.ContentTemplate>
|
||||||
|
</TransitioningContentControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using Avalonia.Xaml.Interactions.DragAndDrop;
|
using Avalonia.Xaml.Interactions.DragAndDrop;
|
||||||
|
using Photos.Animations;
|
||||||
using Photos.Models;
|
using Photos.Models;
|
||||||
using Photos.ViewModels;
|
using Photos.ViewModels;
|
||||||
|
|
||||||
@@ -8,7 +13,17 @@ namespace Photos.Views;
|
|||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private enum NavigationInputMode
|
||||||
|
{
|
||||||
|
Mouse,
|
||||||
|
Keyboard
|
||||||
|
}
|
||||||
|
|
||||||
|
private NavigationInputMode _navigationInputMode = NavigationInputMode.Mouse;
|
||||||
|
|
||||||
private IFolderPickerService _folderPickerService;
|
private IFolderPickerService _folderPickerService;
|
||||||
|
private int _currentImageIndex = 0;
|
||||||
|
private Control? _currentHoveredCard;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -21,4 +36,170 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
(DataContext as MainWindowViewModel)?.WindowDropHandler(e);
|
(DataContext as MainWindowViewModel)?.WindowDropHandler(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnKeyDownHandler(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm) return;
|
||||||
|
|
||||||
|
// input state changer
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.Enter:
|
||||||
|
case Key.Escape:
|
||||||
|
case Key.Left:
|
||||||
|
case Key.Right:
|
||||||
|
case Key.Up:
|
||||||
|
case Key.Down:
|
||||||
|
_navigationInputMode = NavigationInputMode.Keyboard;
|
||||||
|
UpdatePointerHoverSuppression();
|
||||||
|
UpdateKeyboardHover();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task HandleEnter()
|
||||||
|
{
|
||||||
|
if (vm.IsPhotoFocused()) vm.UnfocusPhoto();
|
||||||
|
else await vm.TryFocusImage(_currentImageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleEscape()
|
||||||
|
{
|
||||||
|
vm.UnfocusPhoto();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task HandleArrowKey()
|
||||||
|
{
|
||||||
|
if(vm.IsPhotoFocused()) await vm.TryFocusImage(_currentImageIndex);
|
||||||
|
// TODO: move basic selection
|
||||||
|
}
|
||||||
|
|
||||||
|
int CalculateCurrentIndexHop()
|
||||||
|
{
|
||||||
|
// Values of UniformGridLayout
|
||||||
|
var minItemWidth = 220;
|
||||||
|
var minColumnSpacing = 18;
|
||||||
|
|
||||||
|
var repeaterWidth = Repeater.Bounds.Width;
|
||||||
|
|
||||||
|
var abstractedColumnCount = Math.Floor(repeaterWidth / (minItemWidth + minColumnSpacing));
|
||||||
|
return Math.Max(1, (int)abstractedColumnCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleArrowKeyDown()
|
||||||
|
{
|
||||||
|
var nextIdx = CalculateCurrentIndexHop();
|
||||||
|
if (_currentImageIndex + nextIdx >= vm.Photos.Count) _currentImageIndex = vm.Photos.Count - 1;
|
||||||
|
else _currentImageIndex += nextIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleArrowKeyUp()
|
||||||
|
{
|
||||||
|
var nextIdx = CalculateCurrentIndexHop();
|
||||||
|
if (_currentImageIndex - nextIdx < 0) _currentImageIndex = 0;
|
||||||
|
else _currentImageIndex -= nextIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
Control? FindCardControlForCurrentIndex()
|
||||||
|
{
|
||||||
|
if (_currentImageIndex < 0 || _currentImageIndex >= vm.Photos.Count)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var currentPhoto = vm.Photos[_currentImageIndex];
|
||||||
|
|
||||||
|
return Repeater.GetVisualDescendants()
|
||||||
|
.OfType<Control>()
|
||||||
|
.FirstOrDefault(c =>
|
||||||
|
ReferenceEquals(c.DataContext, currentPhoto) &&
|
||||||
|
CardHoverEffectBehavior.GetIsEnabled(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateKeyboardHover()
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_currentHoveredCard is not null)
|
||||||
|
{
|
||||||
|
CardHoverEffectBehavior.ResetHover(_currentHoveredCard);
|
||||||
|
_currentHoveredCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newCard = FindCardControlForCurrentIndex();
|
||||||
|
if (newCard is null) return;
|
||||||
|
|
||||||
|
CardHoverEffectBehavior.ApplyHover(newCard);
|
||||||
|
_currentHoveredCard = newCard;
|
||||||
|
_currentHoveredCard.BringIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.Enter: case Key.Space:
|
||||||
|
if (!vm.PhotosLoaded) await vm.OpenDirectory();
|
||||||
|
else await HandleEnter();
|
||||||
|
break;
|
||||||
|
case Key.Escape:
|
||||||
|
HandleEscape();
|
||||||
|
break;
|
||||||
|
case Key.Down:
|
||||||
|
HandleArrowKeyDown();
|
||||||
|
UpdateKeyboardHover();
|
||||||
|
await HandleArrowKey();
|
||||||
|
break;
|
||||||
|
case Key.Up:
|
||||||
|
HandleArrowKeyUp();
|
||||||
|
UpdateKeyboardHover();
|
||||||
|
await HandleArrowKey();
|
||||||
|
break;
|
||||||
|
case Key.Left:
|
||||||
|
if (_currentImageIndex > 0)
|
||||||
|
{
|
||||||
|
_currentImageIndex--;
|
||||||
|
UpdateKeyboardHover();
|
||||||
|
await HandleArrowKey();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Key.Right:
|
||||||
|
if (_currentImageIndex < vm.Photos.Count - 1)
|
||||||
|
{
|
||||||
|
_currentImageIndex++;
|
||||||
|
UpdateKeyboardHover();
|
||||||
|
await HandleArrowKey();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PointerEnteredPhotoHandler(object sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Image image || DataContext is not MainWindowViewModel vm) return;
|
||||||
|
|
||||||
|
var photo = image.DataContext as Photo;
|
||||||
|
if (photo is null) return;
|
||||||
|
|
||||||
|
var idx = vm.Photos.IndexOf(photo);
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
_navigationInputMode = NavigationInputMode.Mouse;
|
||||||
|
UpdatePointerHoverSuppression();
|
||||||
|
_currentImageIndex = idx;
|
||||||
|
|
||||||
|
if (_currentHoveredCard is null) return;
|
||||||
|
|
||||||
|
CardHoverEffectBehavior.ResetHover(_currentHoveredCard);
|
||||||
|
_currentHoveredCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePointerHoverSuppression()
|
||||||
|
{
|
||||||
|
foreach (var control in Repeater.GetVisualDescendants().OfType<Control>())
|
||||||
|
{
|
||||||
|
if (CardHoverEffectBehavior.GetIsEnabled(control))
|
||||||
|
{
|
||||||
|
CardHoverEffectBehavior.SetSuppressPointerHover(
|
||||||
|
control,
|
||||||
|
_navigationInputMode == NavigationInputMode.Keyboard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user