feat: Add PinBox control, styles and demo.

This commit is contained in:
NaBian committed 2026-06-20 17:55:51 +08:00
1 parent c48a634220
commit aa32f72563
6 files changed
+551

No files matched your search

@@ -46,6 +46,7 @@
[ "ButtonGroup", "ButtonGroupDemo", "Brush.ButtonGroup", "", "Button" ],
[ "ProgressButton", "ProgressButtonDemo", "Brush.ProgressBarStatusBar8", "", "Button" ],
[ "Card", "CardDemo", "Brush.ContactCard", "", "DataDisplay" ],
[ "PinBox", "PinBoxDemo", "Brush.Ellipsis", "", "DataEntry" ],
[ "ElementGroup", "ElementGroupDemo", "Brush.ButtonGroup", "", "Layout" ],
[ "CirclePanel", "CirclePanelDemo", "Brush.ColorWheel", "", "Layout" ],
[ "Grid", "GridDemo", "Brush.GridPane", "", "Layout" ],
@@ -0,0 +1,26 @@
<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.PinBoxDemo"
Background="{DynamicResource RegionBrush}">
<TransitioningContentControl>
<hc:UniformSpacingPanel Spacing="16"
Orientation="Vertical"
Margin="32"
VerticalAlignment="Center">
<hc:PinBox Length="4"
Password="1234"
Completed="PinBox_OnCompleted" />
<hc:PinBox x:Name="PinBoxUnsafe"
Length="4"
Password="1234"
IsSafeEnabled="False"
Completed="PinBox_OnCompleted" />
<TextBox Text="{Binding #PinBoxUnsafe.UnsafePassword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<hc:PinBox Length="6"
Password="123456"
PasswordChar="❤"
Completed="PinBox_OnCompleted" />
</hc:UniformSpacingPanel>
</TransitioningContentControl>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia.Interactivity;
using HandyControl.Controls;
using System;
using System.Reflection;
namespace HandyControlDemo.UserControl;
public partial class PinBoxDemo : Avalonia.Controls.UserControl
{
public PinBoxDemo()
{
InitializeComponent();
}
private void PinBox_OnCompleted(object? sender, RoutedEventArgs e)
{
if (e.Source is PinBox pinBox)
{
TryShowGrowl(pinBox.Password);
}
}
private static void TryShowGrowl(string message)
{
var growlType = Type.GetType("HandyControl.Controls.Growl, HandyControl");
var infoMethod = growlType?.GetMethod("Info", BindingFlags.Public | BindingFlags.Static, [typeof(string), typeof(string)]);
infoMethod?.Invoke(null, [message, string.Empty]);
}
}
@@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
namespace HandyControl.Controls;
[TemplatePart(Name = ElementPanel, Type = typeof(Panel))]
public class PinBox : TemplatedControl
{
private const string ElementPanel = "PART_Panel";
private const int MinLength = 4;
public static readonly StyledProperty<char> PasswordCharProperty =
AvaloniaProperty.Register<PinBox, char>(nameof(PasswordChar), '●');
public static readonly StyledProperty<int> LengthProperty =
AvaloniaProperty.Register<PinBox, int>(nameof(Length), MinLength);
public static readonly StyledProperty<Thickness> ItemMarginProperty =
AvaloniaProperty.Register<PinBox, Thickness>(nameof(ItemMargin), new Thickness(4, 0));
public static readonly StyledProperty<double> ItemWidthProperty =
AvaloniaProperty.Register<PinBox, double>(nameof(ItemWidth), 32d);
public static readonly StyledProperty<double> ItemHeightProperty =
AvaloniaProperty.Register<PinBox, double>(nameof(ItemHeight), 32d);
public static readonly StyledProperty<IBrush?> SelectionBrushProperty =
AvaloniaProperty.Register<PinBox, IBrush?>(nameof(SelectionBrush));
public static readonly StyledProperty<IBrush?> CaretBrushProperty =
AvaloniaProperty.Register<PinBox, IBrush?>(nameof(CaretBrush));
public static readonly StyledProperty<bool> IsSafeEnabledProperty =
AvaloniaProperty.Register<PinBox, bool>(nameof(IsSafeEnabled), true);
public static readonly StyledProperty<string?> UnsafePasswordProperty =
AvaloniaProperty.Register<PinBox, string?>(nameof(UnsafePassword), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<string?> PasswordProperty =
AvaloniaProperty.Register<PinBox, string?>(nameof(Password));
public static readonly RoutedEvent<RoutedEventArgs> CompletedEvent =
RoutedEvent.Register<PinBox, RoutedEventArgs>(nameof(Completed), RoutingStrategies.Bubble);
private Panel? _panel;
private readonly List<TextBox> _textBoxes = [];
private bool _isInternalAction;
private int _inputIndex;
private string _cachedPassword = string.Empty;
static PinBox()
{
FocusableProperty.OverrideDefaultValue<PinBox>(false);
LengthProperty.Changed.AddClassHandler<PinBox>((pinBox, e) =>
{
var newLength = e.GetNewValue<int>();
if (newLength < MinLength)
{
pinBox.SetCurrentValue(LengthProperty, MinLength);
return;
}
pinBox.UpdateItems();
});
ItemMarginProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
ItemWidthProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
ItemHeightProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
PasswordCharProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
ForegroundProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
SelectionBrushProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
CaretBrushProperty.Changed.AddClassHandler<PinBox>((pinBox, _) => pinBox.ApplyItemVisualProperties());
PasswordProperty.Changed.AddClassHandler<PinBox>((pinBox, e) =>
{
if (pinBox._isInternalAction)
{
return;
}
pinBox.ApplyPasswordToItems(e.GetNewValue<string?>() ?? string.Empty);
pinBox.UpdateUnsafePassword();
});
UnsafePasswordProperty.Changed.AddClassHandler<PinBox>((pinBox, e) =>
{
if (pinBox._isInternalAction || pinBox.IsSafeEnabled)
{
return;
}
pinBox.SetCurrentValue(PasswordProperty, e.GetNewValue<string?>() ?? string.Empty);
});
IsSafeEnabledProperty.Changed.AddClassHandler<PinBox>((pinBox, e) => pinBox.OnIsSafeEnabledChanged(e.GetNewValue<bool>()));
}
public event EventHandler<RoutedEventArgs> Completed
{
add => AddHandler(CompletedEvent, value);
remove => RemoveHandler(CompletedEvent, value);
}
public char PasswordChar
{
get => GetValue(PasswordCharProperty);
set => SetValue(PasswordCharProperty, value);
}
public int Length
{
get => GetValue(LengthProperty);
set => SetValue(LengthProperty, value < MinLength ? MinLength : value);
}
public Thickness ItemMargin
{
get => GetValue(ItemMarginProperty);
set => SetValue(ItemMarginProperty, value);
}
public double ItemWidth
{
get => GetValue(ItemWidthProperty);
set => SetValue(ItemWidthProperty, value);
}
public double ItemHeight
{
get => GetValue(ItemHeightProperty);
set => SetValue(ItemHeightProperty, value);
}
public IBrush? SelectionBrush
{
get => GetValue(SelectionBrushProperty);
set => SetValue(SelectionBrushProperty, value);
}
public IBrush? CaretBrush
{
get => GetValue(CaretBrushProperty);
set => SetValue(CaretBrushProperty, value);
}
public bool IsSafeEnabled
{
get => GetValue(IsSafeEnabledProperty);
set => SetValue(IsSafeEnabledProperty, value);
}
public string UnsafePassword
{
get => GetValue(UnsafePasswordProperty) ?? string.Empty;
set => SetValue(UnsafePasswordProperty, value);
}
public string Password
{
get => GetValue(PasswordProperty) ?? string.Empty;
set => SetValue(PasswordProperty, value);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_panel = e.NameScope.Find<Panel>(ElementPanel);
UpdateItems();
var initialPassword = string.IsNullOrEmpty(Password) ? _cachedPassword : Password;
if (!string.IsNullOrEmpty(initialPassword))
{
ApplyPasswordToItems(initialPassword);
}
OnIsSafeEnabledChanged(IsSafeEnabled);
}
protected override void OnGotFocus(GotFocusEventArgs e)
{
base.OnGotFocus(e);
FocusPasswordBox();
}
private void FocusPasswordBox()
{
if (_textBoxes.Count == 0)
{
return;
}
if (_textBoxes.Any(box => box.IsFocused))
{
return;
}
var index = Math.Clamp(_inputIndex, 0, _textBoxes.Count - 1);
_textBoxes[index].Focus();
_textBoxes[index].SelectAll();
}
private void UpdateItems()
{
if (_panel == null)
{
return;
}
_panel.Children.Clear();
_textBoxes.Clear();
_inputIndex = 0;
var length = Math.Max(Length, MinLength);
for (var i = 0; i < length; i++)
{
var textBox = CreateTextBox();
_textBoxes.Add(textBox);
_panel.Children.Add(textBox);
}
ApplyItemVisualProperties();
}
private TextBox CreateTextBox()
{
var textBox = new TextBox
{
MaxLength = 1,
HorizontalContentAlignment = HorizontalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center,
Padding = default
};
textBox.TextChanged += TextBoxOnTextChanged;
textBox.GotFocus += TextBoxOnGotFocus;
textBox.AddHandler(InputElement.KeyDownEvent, TextBoxOnKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
return textBox;
}
private void ApplyItemVisualProperties()
{
foreach (var textBox in _textBoxes)
{
textBox.Margin = ItemMargin;
textBox.Width = ItemWidth;
textBox.Height = ItemHeight;
textBox.PasswordChar = PasswordChar;
textBox.Foreground = Foreground;
textBox.SelectionBrush = SelectionBrush;
textBox.CaretBrush = CaretBrush;
}
}
private void TextBoxOnGotFocus(object? sender, GotFocusEventArgs e)
{
if (sender is not TextBox textBox)
{
return;
}
_inputIndex = _textBoxes.IndexOf(textBox);
textBox.SelectAll();
}
private void TextBoxOnTextChanged(object? sender, TextChangedEventArgs e)
{
if (_isInternalAction || sender is not TextBox textBox)
{
return;
}
var index = _textBoxes.IndexOf(textBox);
if (index < 0)
{
return;
}
if (!string.IsNullOrEmpty(textBox.Text) && textBox.Text!.Length > 1)
{
_isInternalAction = true;
textBox.Text = textBox.Text[^1].ToString();
_isInternalAction = false;
}
_inputIndex = index;
if (!string.IsNullOrEmpty(textBox.Text))
{
MoveToNext(index);
}
SyncPasswordFromItems();
if (_textBoxes.Count > 0 && _textBoxes.All(item => !string.IsNullOrEmpty(item.Text)))
{
RaiseEvent(new RoutedEventArgs(CompletedEvent, this));
Dispatcher.UIThread.Post(() => TopLevel.GetTopLevel(this)?.FocusManager?.ClearFocus(), DispatcherPriority.Background);
}
}
private void TextBoxOnKeyDown(object? sender, KeyEventArgs e)
{
if (sender is not TextBox textBox)
{
return;
}
var index = _textBoxes.IndexOf(textBox);
if (index < 0)
{
return;
}
switch (e.Key)
{
case Key.Left:
MoveFocus(Math.Max(index - 1, 0));
e.Handled = true;
break;
case Key.Right:
MoveFocus(Math.Min(index + 1, _textBoxes.Count - 1));
e.Handled = true;
break;
case Key.Back:
HandleBackspace(index, textBox);
e.Handled = true;
break;
case Key.Delete:
HandleDelete(index, textBox);
e.Handled = true;
break;
}
}
private void HandleBackspace(int index, TextBox textBox)
{
var target = Math.Max(index - 1, 0);
_isInternalAction = true;
if (string.IsNullOrEmpty(textBox.Text) && index > 0)
{
_textBoxes[target].Clear();
}
else
{
textBox.Clear();
}
_isInternalAction = false;
MoveFocusDeferred(target);
SyncPasswordFromItems();
}
private void HandleDelete(int index, TextBox textBox)
{
var target = Math.Max(index - 1, 0);
_isInternalAction = true;
if (string.IsNullOrEmpty(textBox.Text) && index > 0)
{
_textBoxes[target].Clear();
}
else
{
textBox.Clear();
}
_isInternalAction = false;
MoveFocusDeferred(target);
SyncPasswordFromItems();
}
private void MoveToNext(int index)
{
if (index >= _textBoxes.Count - 1)
{
_inputIndex = _textBoxes.Count - 1;
return;
}
MoveFocus(index + 1);
}
private void MoveFocus(int index)
{
if (_textBoxes.Count == 0)
{
return;
}
_inputIndex = Math.Clamp(index, 0, _textBoxes.Count - 1);
var textBox = _textBoxes[_inputIndex];
textBox.Focus();
textBox.SelectAll();
}
private void MoveFocusDeferred(int index)
{
_inputIndex = Math.Clamp(index, 0, Math.Max(_textBoxes.Count - 1, 0));
Dispatcher.UIThread.Post(() => MoveFocus(_inputIndex), DispatcherPriority.Input);
}
private void SyncPasswordFromItems()
{
var password = string.Concat(_textBoxes.Select(box => box.Text ?? string.Empty));
_cachedPassword = password;
_isInternalAction = true;
SetCurrentValue(PasswordProperty, password);
_isInternalAction = false;
UpdateUnsafePassword();
}
private void ApplyPasswordToItems(string password)
{
_cachedPassword = password;
if (_textBoxes.Count == 0)
{
return;
}
_isInternalAction = true;
var limited = password.Length > _textBoxes.Count ? password[.._textBoxes.Count] : password;
for (var i = 0; i < _textBoxes.Count; i++)
{
_textBoxes[i].Text = i < limited.Length ? limited[i].ToString() : string.Empty;
}
_isInternalAction = false;
_inputIndex = Math.Min(limited.Length, _textBoxes.Count - 1);
}
private void OnIsSafeEnabledChanged(bool isSafeEnabled)
{
if (isSafeEnabled)
{
_isInternalAction = true;
SetCurrentValue(UnsafePasswordProperty, string.Empty);
_isInternalAction = false;
return;
}
UpdateUnsafePassword();
}
private void UpdateUnsafePassword()
{
_isInternalAction = true;
SetCurrentValue(UnsafePasswordProperty, IsSafeEnabled ? string.Empty : (_textBoxes.Count == 0 ? _cachedPassword : string.Concat(_textBoxes.Select(item => item.Text ?? string.Empty))));
_isInternalAction = false;
}
}
@@ -59,6 +59,7 @@
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Clock.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/TimePicker.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/TextBox.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/PinBox.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/AutoCompleteBox.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/ButtonSpinner.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/NumericUpDown.axaml" />
@@ -0,0 +1,25 @@
<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:PinBox}"
TargetType="hc:PinBox">
<Setter Property="Focusable"
Value="False" />
<Setter Property="ItemMargin"
Value="4,0" />
<Setter Property="ItemWidth"
Value="{DynamicResource DefaultControlHeight}" />
<Setter Property="ItemHeight"
Value="{DynamicResource DefaultControlHeight}" />
<Setter Property="Template">
<ControlTemplate>
<UniformGrid x:Name="PART_Panel"
Columns="{TemplateBinding Length}"
Rows="1" />
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>