feat: Add ImageSelector styles and demo.
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user