Skip to content

Commit cb7e6fb

Browse files
committed
feat: Add ImageSelector styles and demo.
1 parent 7da3acc commit cb7e6fb

6 files changed

Lines changed: 338 additions & 2 deletions

File tree

src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
[ "Badge", "BadgeDemo", "Brush.CheckDot", "", "DataDisplay" ],
6565
[ "Shield", "ShieldDemo", "Brush.SplitKeyValuePair", "", "DataDisplay" ],
6666
[ "ImageBlock", "ImageBlockDemo", "Brush.ImageStack", "", "DataDisplay" ],
67-
[ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ]
67+
[ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ],
68+
[ "ImageSelector", "ImageSelectorDemo", "Brush.Image", "", "DataEntry" ]
6869
]
6970
}
7071
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<UserControl xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:hc="https://handyorg.github.io/handycontrol"
4+
x:Class="HandyControlDemo.UserControl.ImageSelectorDemo"
5+
Background="{DynamicResource RegionBrush}">
6+
<ScrollViewer>
7+
<hc:UniformSpacingPanel Margin="32"
8+
ChildWrapping="Wrap"
9+
VerticalAlignment="Center"
10+
Spacing="32"
11+
HorizontalAlignment="Center">
12+
<hc:ImageSelector Width="100" Height="100" />
13+
<hc:ImageSelector Width="100" Height="100"
14+
hc:BorderElement.CornerRadius="50" />
15+
<hc:ImageSelector Width="100" Height="100"
16+
hc:BorderElement.CornerRadius="50"
17+
StrokeDashArray="10,5" />
18+
<hc:ImageSelector Width="100" Height="100"
19+
hc:BorderElement.CornerRadius="50"
20+
BorderBrush="{DynamicResource SuccessBrush}" />
21+
<hc:ImageSelector Width="100" Height="100"
22+
hc:BorderElement.CornerRadius="50"
23+
StrokeDashArray="10,5,10"
24+
BorderBrush="{DynamicResource DangerBrush}" />
25+
<hc:ImageSelector Width="100" Height="100"
26+
hc:BorderElement.CornerRadius="10"
27+
StrokeThickness="2"
28+
BorderThickness="2"
29+
BorderBrush="{DynamicResource PrimaryBrush}" />
30+
</hc:UniformSpacingPanel>
31+
</ScrollViewer>
32+
</UserControl>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace HandyControlDemo.UserControl;
2+
3+
public partial class ImageSelectorDemo : Avalonia.Controls.UserControl
4+
{
5+
public ImageSelectorDemo()
6+
{
7+
InitializeComponent();
8+
}
9+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using Avalonia;
5+
using Avalonia.Controls;
6+
using Avalonia.Controls.Primitives;
7+
using Avalonia.Input;
8+
using Avalonia.Interactivity;
9+
using Avalonia.Media;
10+
using Avalonia.Media.Imaging;
11+
using Avalonia.Platform.Storage;
12+
13+
namespace HandyControl.Controls;
14+
15+
/// <summary>
16+
/// A control that allows selecting an image file via a file picker dialog.
17+
/// Displays the selected image preview inside a configurable frame.
18+
/// </summary>
19+
public class ImageSelector : TemplatedControl
20+
{
21+
public static readonly RoutedEvent<RoutedEventArgs> ImageSelectedEvent =
22+
RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageSelected), RoutingStrategies.Bubble);
23+
24+
public static readonly RoutedEvent<RoutedEventArgs> ImageUnselectedEvent =
25+
RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageUnselected), RoutingStrategies.Bubble);
26+
27+
public event EventHandler<RoutedEventArgs>? ImageSelected
28+
{
29+
add => AddHandler(ImageSelectedEvent, value);
30+
remove => RemoveHandler(ImageSelectedEvent, value);
31+
}
32+
33+
public event EventHandler<RoutedEventArgs>? ImageUnselected
34+
{
35+
add => AddHandler(ImageUnselectedEvent, value);
36+
remove => RemoveHandler(ImageUnselectedEvent, value);
37+
}
38+
39+
static ImageSelector()
40+
{
41+
FocusableProperty.OverrideDefaultValue<ImageSelector>(true);
42+
}
43+
44+
protected override void OnPointerPressed(PointerPressedEventArgs e)
45+
{
46+
base.OnPointerPressed(e);
47+
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
48+
{
49+
SwitchImage();
50+
}
51+
}
52+
53+
private async void SwitchImage()
54+
{
55+
if (!HasValue)
56+
{
57+
var topLevel = TopLevel.GetTopLevel(this);
58+
if (topLevel == null) return;
59+
60+
var patterns = ParseFilterPatterns(Filter);
61+
62+
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
63+
{
64+
AllowMultiple = false,
65+
FileTypeFilter =
66+
[
67+
new FilePickerFileType("Images") { Patterns = patterns },
68+
],
69+
});
70+
71+
if (files.Count > 0)
72+
{
73+
var file = files[0];
74+
var localPath = file.TryGetLocalPath();
75+
HasValue = true;
76+
77+
try
78+
{
79+
await using var stream = await file.OpenReadAsync();
80+
var bitmap = new Bitmap(stream);
81+
PreviewBrush = new ImageBrush(bitmap) { Stretch = Stretch };
82+
}
83+
catch
84+
{
85+
PreviewBrush = null;
86+
}
87+
88+
if (localPath != null)
89+
Uri = new Uri(localPath);
90+
ToolTip.SetTip(this, file.Name);
91+
RaiseEvent(new RoutedEventArgs(ImageSelectedEvent));
92+
}
93+
}
94+
else
95+
{
96+
HasValue = false;
97+
PreviewBrush = null;
98+
Uri = null;
99+
ToolTip.SetTip(this, null);
100+
RaiseEvent(new RoutedEventArgs(ImageUnselectedEvent));
101+
}
102+
}
103+
104+
// ── Properties ──
105+
106+
public static readonly StyledProperty<Stretch> StretchProperty =
107+
AvaloniaProperty.Register<ImageSelector, Stretch>(nameof(Stretch));
108+
109+
public Stretch Stretch
110+
{
111+
get => GetValue(StretchProperty);
112+
set => SetValue(StretchProperty, value);
113+
}
114+
115+
public static readonly DirectProperty<ImageSelector, Uri?> UriProperty =
116+
AvaloniaProperty.RegisterDirect<ImageSelector, Uri?>(nameof(Uri), o => o.Uri);
117+
118+
private Uri? _uri;
119+
120+
public Uri? Uri
121+
{
122+
get => _uri;
123+
private set => SetAndRaise(UriProperty, ref _uri, value);
124+
}
125+
126+
public static readonly DirectProperty<ImageSelector, IBrush?> PreviewBrushProperty =
127+
AvaloniaProperty.RegisterDirect<ImageSelector, IBrush?>(nameof(PreviewBrush), o => o.PreviewBrush);
128+
129+
private IBrush? _previewBrush;
130+
131+
public IBrush? PreviewBrush
132+
{
133+
get => _previewBrush;
134+
private set => SetAndRaise(PreviewBrushProperty, ref _previewBrush, value);
135+
}
136+
public static readonly StyledProperty<double> StrokeThicknessProperty =
137+
AvaloniaProperty.Register<ImageSelector, double>(nameof(StrokeThickness), 1.0);
138+
139+
public double StrokeThickness
140+
{
141+
get => GetValue(StrokeThicknessProperty);
142+
set => SetValue(StrokeThicknessProperty, value);
143+
}
144+
145+
public static readonly StyledProperty<Avalonia.Collections.AvaloniaList<double>?> StrokeDashArrayProperty =
146+
AvaloniaProperty.Register<ImageSelector, Avalonia.Collections.AvaloniaList<double>?>(nameof(StrokeDashArray));
147+
148+
public Avalonia.Collections.AvaloniaList<double>? StrokeDashArray
149+
{
150+
get => GetValue(StrokeDashArrayProperty);
151+
set => SetValue(StrokeDashArrayProperty, value);
152+
}
153+
154+
public static readonly StyledProperty<string> DefaultExtProperty =
155+
AvaloniaProperty.Register<ImageSelector, string>(nameof(DefaultExt), ".jpg");
156+
157+
public string DefaultExt
158+
{
159+
get => GetValue(DefaultExtProperty);
160+
set => SetValue(DefaultExtProperty, value);
161+
}
162+
163+
public static readonly StyledProperty<string> FilterProperty =
164+
AvaloniaProperty.Register<ImageSelector, string>(nameof(Filter), "(.jpg)|*.jpg|(.png)|*.png");
165+
166+
public string Filter
167+
{
168+
get => GetValue(FilterProperty);
169+
set => SetValue(FilterProperty, value);
170+
}
171+
172+
/// <summary>
173+
/// Parses a WPF-style filter string like "(.jpg)|*.jpg|(.png)|*.png"
174+
/// into individual glob patterns for the cross-platform file picker.
175+
/// </summary>
176+
private static List<string> ParseFilterPatterns(string? filter)
177+
{
178+
var patterns = new List<string>();
179+
180+
if (string.IsNullOrWhiteSpace(filter))
181+
{
182+
// Sensible cross-platform defaults with case variations
183+
patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif"]);
184+
patterns.AddRange(["*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
185+
return patterns;
186+
}
187+
188+
// WPF format: "Description|*.ext|Description|*.ext"
189+
var parts = filter.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
190+
191+
foreach (var part in parts)
192+
{
193+
// Skip display-name segments (e.g. "(.jpg)"); only keep pattern segments
194+
if (part.Contains('*') || part.Contains('?'))
195+
{
196+
// Split multi-pattern entries like "*.jpg;*.png"
197+
foreach (var p in part.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
198+
{
199+
var trimmed = p.Trim();
200+
if (!string.IsNullOrEmpty(trimmed) && !patterns.Contains(trimmed))
201+
{
202+
patterns.Add(trimmed);
203+
// Add uppercase variant for case-sensitive platforms (macOS)
204+
var upper = trimmed.ToUpperInvariant();
205+
if (upper != trimmed && !patterns.Contains(upper))
206+
patterns.Add(upper);
207+
}
208+
}
209+
}
210+
}
211+
212+
// Fallback if parsing produced nothing
213+
if (patterns.Count == 0)
214+
{
215+
patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif",
216+
"*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
217+
}
218+
219+
return patterns;
220+
}
221+
222+
public static readonly DirectProperty<ImageSelector, bool> HasValueProperty =
223+
AvaloniaProperty.RegisterDirect<ImageSelector, bool>(nameof(HasValue), o => o.HasValue);
224+
225+
private bool _hasValue;
226+
227+
public bool HasValue
228+
{
229+
get => _hasValue;
230+
private set => SetAndRaise(HasValueProperty, ref _hasValue, value);
231+
}
232+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<ResourceDictionary xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
x:ClassModifier="internal"
4+
xmlns:hc="clr-namespace:HandyControl.Controls">
5+
6+
<ControlTheme x:Key="{x:Type hc:ImageSelector}"
7+
TargetType="hc:ImageSelector">
8+
<Setter Property="Stretch" Value="UniformToFill" />
9+
<Setter Property="BorderThickness" Value="1" />
10+
<Setter Property="StrokeThickness" Value="1" />
11+
<Setter Property="Background" Value="{DynamicResource RegionBrush}" />
12+
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
13+
<Setter Property="StrokeDashArray" Value="2,2" />
14+
<Setter Property="hc:BorderElement.CornerRadius"
15+
Value="{StaticResource DefaultCornerRadius}" />
16+
<Setter Property="Template">
17+
<ControlTemplate>
18+
<Border Background="{TemplateBinding Background}"
19+
CornerRadius="{Binding $self.(hc:BorderElement.CornerRadius)}">
20+
<hc:DashedBorder BorderDashArray="{TemplateBinding StrokeDashArray}"
21+
BorderBrush="{TemplateBinding BorderBrush}"
22+
BorderThickness="{TemplateBinding BorderThickness}"
23+
BorderDashThickness="{TemplateBinding StrokeThickness}"
24+
Background="{TemplateBinding PreviewBrush}"
25+
CornerRadius="{Binding $self.(hc:BorderElement.CornerRadius)}"
26+
ClipToBounds="True">
27+
<!-- Add / Remove icon -->
28+
<Border Width="30"
29+
Height="30"
30+
HorizontalAlignment="Center"
31+
VerticalAlignment="Center"
32+
Background="{TemplateBinding Background}"
33+
CornerRadius="15"
34+
BorderThickness="0">
35+
<Panel>
36+
<Path IsVisible="{Binding HasValue, RelativeSource={RelativeSource TemplatedParent}, Converter={x:Static BoolConverters.Not}}"
37+
Data="{StaticResource AddGeometry}"
38+
Width="16"
39+
Height="16"
40+
Fill="{DynamicResource PrimaryBrush}" />
41+
<Path IsVisible="{Binding HasValue, RelativeSource={RelativeSource TemplatedParent}}"
42+
Data="{StaticResource RemoveGeometry}"
43+
Width="12"
44+
Height="12"
45+
Fill="{DynamicResource PrimaryBrush}" />
46+
</Panel>
47+
</Border>
48+
</hc:DashedBorder>
49+
</Border>
50+
</ControlTemplate>
51+
</Setter>
52+
53+
<Style Selector="^:pointerover">
54+
<Setter Property="Background"
55+
Value="{DynamicResource SecondaryRegionBrush}" />
56+
<Setter Property="BorderBrush"
57+
Value="{DynamicResource PrimaryBrush}" />
58+
</Style>
59+
</ControlTheme>
60+
61+
</ResourceDictionary>

src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@
7676
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Gravatar.axaml" />
7777
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Tag.axaml" />
7878
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Badge.axaml" />
79-
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/GotoTop.axaml" />
79+
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/GotoTop.axaml" />
8080
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Shield.axaml" />
81+
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/ImageSelector.axaml" />
8182
</ResourceDictionary.MergedDictionaries>
8283
</ResourceDictionary>
8384
</Styles.Resources>

0 commit comments

Comments
 (0)