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