feat: Add ImageSelector styles and demo.

This commit is contained in:
NaBian committed 2026-06-20 22:30:38 +08:00
1 parent 7da3acceb1
commit cb7e6fbb98
6 files changed
+338 -2

No files matched your search

@@ -64,7 +64,8 @@
[ "Badge", "BadgeDemo", "Brush.CheckDot", "", "DataDisplay" ],
[ "Shield", "ShieldDemo", "Brush.SplitKeyValuePair", "", "DataDisplay" ],
[ "ImageBlock", "ImageBlockDemo", "Brush.ImageStack", "", "DataDisplay" ],
[ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ]
[ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ],
[ "ImageSelector", "ImageSelectorDemo", "Brush.Image", "", "DataEntry" ]
]
}
]
@@ -0,0 +1,32 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:hc="https://handyorg.github.io/handycontrol"
x:Class="HandyControlDemo.UserControl.ImageSelectorDemo"
Background="{DynamicResource RegionBrush}">
<ScrollViewer>
<hc:UniformSpacingPanel Margin="32"
ChildWrapping="Wrap"
VerticalAlignment="Center"
Spacing="32"
HorizontalAlignment="Center">
<hc:ImageSelector Width="100" Height="100" />
<hc:ImageSelector Width="100" Height="100"
hc:BorderElement.CornerRadius="50" />
<hc:ImageSelector Width="100" Height="100"
hc:BorderElement.CornerRadius="50"
StrokeDashArray="10,5" />
<hc:ImageSelector Width="100" Height="100"
hc:BorderElement.CornerRadius="50"
BorderBrush="{DynamicResource SuccessBrush}" />
<hc:ImageSelector Width="100" Height="100"
hc:BorderElement.CornerRadius="50"
StrokeDashArray="10,5,10"
BorderBrush="{DynamicResource DangerBrush}" />
<hc:ImageSelector Width="100" Height="100"
hc:BorderElement.CornerRadius="10"
StrokeThickness="2"
BorderThickness="2"
BorderBrush="{DynamicResource PrimaryBrush}" />
</hc:UniformSpacingPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
namespace HandyControlDemo.UserControl;
public partial class ImageSelectorDemo : Avalonia.Controls.UserControl
{
public ImageSelectorDemo()
{
InitializeComponent();
}
}
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
namespace HandyControl.Controls;
/// <summary>
/// A control that allows selecting an image file via a file picker dialog.
/// Displays the selected image preview inside a configurable frame.
/// </summary>
public class ImageSelector : TemplatedControl
{
public static readonly RoutedEvent<RoutedEventArgs> ImageSelectedEvent =
RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageSelected), RoutingStrategies.Bubble);
public static readonly RoutedEvent<RoutedEventArgs> ImageUnselectedEvent =
RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageUnselected), RoutingStrategies.Bubble);
public event EventHandler<RoutedEventArgs>? ImageSelected
{
add => AddHandler(ImageSelectedEvent, value);
remove => RemoveHandler(ImageSelectedEvent, value);
}
public event EventHandler<RoutedEventArgs>? ImageUnselected
{
add => AddHandler(ImageUnselectedEvent, value);
remove => RemoveHandler(ImageUnselectedEvent, value);
}
static ImageSelector()
{
FocusableProperty.OverrideDefaultValue<ImageSelector>(true);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
SwitchImage();
}
}
private async void SwitchImage()
{
if (!HasValue)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel == null) return;
var patterns = ParseFilterPatterns(Filter);
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Images") { Patterns = patterns },
],
});
if (files.Count > 0)
{
var file = files[0];
var localPath = file.TryGetLocalPath();
HasValue = true;
try
{
await using var stream = await file.OpenReadAsync();
var bitmap = new Bitmap(stream);
PreviewBrush = new ImageBrush(bitmap) { Stretch = Stretch };
}
catch
{
PreviewBrush = null;
}
if (localPath != null)
Uri = new Uri(localPath);
ToolTip.SetTip(this, file.Name);
RaiseEvent(new RoutedEventArgs(ImageSelectedEvent));
}
}
else
{
HasValue = false;
PreviewBrush = null;
Uri = null;
ToolTip.SetTip(this, null);
RaiseEvent(new RoutedEventArgs(ImageUnselectedEvent));
}
}
// ── Properties ──
public static readonly StyledProperty<Stretch> StretchProperty =
AvaloniaProperty.Register<ImageSelector, Stretch>(nameof(Stretch));
public Stretch Stretch
{
get => GetValue(StretchProperty);
set => SetValue(StretchProperty, value);
}
public static readonly DirectProperty<ImageSelector, Uri?> UriProperty =
AvaloniaProperty.RegisterDirect<ImageSelector, Uri?>(nameof(Uri), o => o.Uri);
private Uri? _uri;
public Uri? Uri
{
get => _uri;
private set => SetAndRaise(UriProperty, ref _uri, value);
}
public static readonly DirectProperty<ImageSelector, IBrush?> PreviewBrushProperty =
AvaloniaProperty.RegisterDirect<ImageSelector, IBrush?>(nameof(PreviewBrush), o => o.PreviewBrush);
private IBrush? _previewBrush;
public IBrush? PreviewBrush
{
get => _previewBrush;
private set => SetAndRaise(PreviewBrushProperty, ref _previewBrush, value);
}
public static readonly StyledProperty<double> StrokeThicknessProperty =
AvaloniaProperty.Register<ImageSelector, double>(nameof(StrokeThickness), 1.0);
public double StrokeThickness
{
get => GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public static readonly StyledProperty<Avalonia.Collections.AvaloniaList<double>?> StrokeDashArrayProperty =
AvaloniaProperty.Register<ImageSelector, Avalonia.Collections.AvaloniaList<double>?>(nameof(StrokeDashArray));
public Avalonia.Collections.AvaloniaList<double>? StrokeDashArray
{
get => GetValue(StrokeDashArrayProperty);
set => SetValue(StrokeDashArrayProperty, value);
}
public static readonly StyledProperty<string> DefaultExtProperty =
AvaloniaProperty.Register<ImageSelector, string>(nameof(DefaultExt), ".jpg");
public string DefaultExt
{
get => GetValue(DefaultExtProperty);
set => SetValue(DefaultExtProperty, value);
}
public static readonly StyledProperty<string> FilterProperty =
AvaloniaProperty.Register<ImageSelector, string>(nameof(Filter), "(.jpg)|*.jpg|(.png)|*.png");
public string Filter
{
get => GetValue(FilterProperty);
set => SetValue(FilterProperty, value);
}
/// <summary>
/// Parses a WPF-style filter string like "(.jpg)|*.jpg|(.png)|*.png"
/// into individual glob patterns for the cross-platform file picker.
/// </summary>
private static List<string> ParseFilterPatterns(string? filter)
{
var patterns = new List<string>();
if (string.IsNullOrWhiteSpace(filter))
{
// Sensible cross-platform defaults with case variations
patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif"]);
patterns.AddRange(["*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
return patterns;
}
// WPF format: "Description|*.ext|Description|*.ext"
var parts = filter.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
// Skip display-name segments (e.g. "(.jpg)"); only keep pattern segments
if (part.Contains('*') || part.Contains('?'))
{
// Split multi-pattern entries like "*.jpg;*.png"
foreach (var p in part.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var trimmed = p.Trim();
if (!string.IsNullOrEmpty(trimmed) && !patterns.Contains(trimmed))
{
patterns.Add(trimmed);
// Add uppercase variant for case-sensitive platforms (macOS)
var upper = trimmed.ToUpperInvariant();
if (upper != trimmed && !patterns.Contains(upper))
patterns.Add(upper);
}
}
}
}
// Fallback if parsing produced nothing
if (patterns.Count == 0)
{
patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif",
"*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
}
return patterns;
}
public static readonly DirectProperty<ImageSelector, bool> HasValueProperty =
AvaloniaProperty.RegisterDirect<ImageSelector, bool>(nameof(HasValue), o => o.HasValue);
private bool _hasValue;
public bool HasValue
{
get => _hasValue;
private set => SetAndRaise(HasValueProperty, ref _hasValue, value);
}
}
@@ -0,0 +1,61 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal"
xmlns:hc="clr-namespace:HandyControl.Controls">
<ControlTheme x:Key="{x:Type hc:ImageSelector}"
TargetType="hc:ImageSelector">
<Setter Property="Stretch" Value="UniformToFill" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="StrokeThickness" Value="1" />
<Setter Property="Background" Value="{DynamicResource RegionBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
<Setter Property="StrokeDashArray" Value="2,2" />
<Setter Property="hc:BorderElement.CornerRadius"
Value="{StaticResource DefaultCornerRadius}" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
CornerRadius="{Binding $self.(hc:BorderElement.CornerRadius)}">
<hc:DashedBorder BorderDashArray="{TemplateBinding StrokeDashArray}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderDashThickness="{TemplateBinding StrokeThickness}"
Background="{TemplateBinding PreviewBrush}"
CornerRadius="{Binding $self.(hc:BorderElement.CornerRadius)}"
ClipToBounds="True">
<!-- Add / Remove icon -->
<Border Width="30"
Height="30"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{TemplateBinding Background}"
CornerRadius="15"
BorderThickness="0">
<Panel>
<Path IsVisible="{Binding HasValue, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static BoolConverters.Not}}"
Data="{StaticResource AddGeometry}"
Width="16"
Height="16"
Fill="{DynamicResource PrimaryBrush}" />
<Path IsVisible="{Binding HasValue, RelativeSource={RelativeSource TemplatedParent}}"
Data="{StaticResource RemoveGeometry}"
Width="12"
Height="12"
Fill="{DynamicResource PrimaryBrush}" />
</Panel>
</Border>
</hc:DashedBorder>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="Background"
Value="{DynamicResource SecondaryRegionBrush}" />
<Setter Property="BorderBrush"
Value="{DynamicResource PrimaryBrush}" />
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -76,8 +76,9 @@
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Gravatar.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Tag.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Badge.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/GotoTop.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/GotoTop.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Shield.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/ImageSelector.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Styles.Resources>