Merge pull request #25 from irihitech/taginput

Taginput
This commit is contained in:
Dong Bin
2023-06-29 11:25:18 +08:00
committed by GitHub
15 changed files with 561 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
<UserControl
x:Class="Ursa.Demo.Pages.TagInputDemo"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d">
<StackPanel>
<u:TagInput
Name="labels"
Margin="20"
Separator="-" />
<u:TagInput
Margin="20"
AllowDuplicates="False"
Separator="-" />
<ListBox ItemsSource="{Binding #labels.Tags}" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Ursa.Demo.Pages;
public partial class TagInputDemo : UserControl
{
public TagInputDemo()
{
InitializeComponent();
}
}

View File

@@ -47,6 +47,9 @@
<TabItem Header="Pagination">
<pages:PaginationDemo />
</TabItem>
<TabItem Header="TagInput">
<pages:TagInputDemo />
</TabItem>
<TabItem Header="Timeline">
<pages:TimelineDemo />
</TabItem>

View File

@@ -0,0 +1,135 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:u="https://irihi.tech/ursa">
<!-- Add Resources Here -->
<ControlTheme x:Key="{x:Type u:TagInput}" TargetType="u:TagInput">
<Setter Property="InputTheme" Value="{DynamicResource TagInputTextBoxTheme}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="MinHeight" Value="{DynamicResource TagInputDefaultHeight}" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="ItemTemplate">
<DataTemplate>
<u:ClosableTag Command="{Binding $parent[u:TagInput].Close}" Content="{Binding}" />
</DataTemplate>
</Setter>
<Setter Property="Template">
<ControlTemplate TargetType="u:TagInput">
<Border
Name="PART_RootBorder"
MinHeight="30"
Padding="8,4"
VerticalAlignment="Stretch"
Background="{DynamicResource TextBoxDefaultBackground}"
BorderBrush="{DynamicResource TextBoxDefaultBorderBrush}"
BorderThickness="1"
CornerRadius="3">
<Panel HorizontalAlignment="Stretch">
<ItemsControl
Name="PART_ItemsControl"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
ItemTemplate="{TemplateBinding ItemTemplate}"
ItemsSource="{TemplateBinding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<u:TagInputPanel VerticalAlignment="Top" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Panel>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_RootBorder">
<Setter Property="Border.Background" Value="{DynamicResource TextBoxPointeroverBackground}" />
</Style>
<Style Selector="^:focus-within /template/ Border#PART_RootBorder">
<Setter Property="Border.BorderBrush" Value="{DynamicResource TextBoxFocusBorderBrush}" />
</Style>
</ControlTheme>
<ControlTheme x:Key="TagInputTextBoxTheme" TargetType="TextBox">
<Setter Property="TextBox.Foreground" Value="{DynamicResource TextBoxForeground}" />
<Setter Property="TextBox.Background" Value="{DynamicResource TextBoxDefaultBackground}" />
<Setter Property="TextBox.BorderBrush" Value="{DynamicResource TextBoxDefaultBorderBrush}" />
<Setter Property="TextBox.SelectionBrush" Value="{DynamicResource TextBoxSelectionBackground}" />
<Setter Property="TextBox.SelectionForegroundBrush" Value="{DynamicResource TextBoxSelectionForeground}" />
<Setter Property="TextBox.Cursor" Value="Ibeam" />
<Setter Property="TextBox.CaretBrush" Value="{DynamicResource TextBoxTextCaretBrush}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="ScrollViewer.IsScrollChainingEnabled" Value="True" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Template">
<ControlTemplate TargetType="TextBox">
<Border Padding="0,4">
<ScrollViewer
Width="{Binding $parent[TextBox].Bounds.Width}"
AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}"
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
<TextPresenter
Name="PART_TextPresenter"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
CaretBrush="{TemplateBinding CaretBrush}"
CaretIndex="{TemplateBinding CaretIndex}"
LineHeight="{TemplateBinding LineHeight}"
PasswordChar="{TemplateBinding PasswordChar}"
RevealPassword="{TemplateBinding RevealPassword}"
SelectionBrush="{TemplateBinding SelectionBrush}"
SelectionEnd="{TemplateBinding SelectionEnd}"
SelectionForegroundBrush="{TemplateBinding SelectionForegroundBrush}"
SelectionStart="{TemplateBinding SelectionStart}"
Text="{TemplateBinding Text,
Mode=TwoWay}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="{x:Type u:ClosableTag}" TargetType="u:ClosableTag">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="u:ClosableTag.Foreground" Value="{DynamicResource LabelTagLightGreyForeground}" />
<Setter Property="u:ClosableTag.Background" Value="{DynamicResource ClosableTagBackground}" />
<Setter Property="u:ClosableTag.BorderBrush" Value="{DynamicResource SemiColorBorder}" />
<Setter Property="u:ClosableTag.BorderThickness" Value="1" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate TargetType="u:ClosableTag">
<Border
Margin="1"
Padding="4,2"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3">
<DockPanel LastChildFill="True">
<PathIcon
Name="{x:Static u:ClosableTag.PART_CloseButton}"
Width="8"
Height="8"
Margin="4,0"
Background="Transparent"
Data="{DynamicResource ClosableTagCloseIconGlyph}"
DockPanel.Dock="Right"
Foreground="{TemplateBinding Foreground}" />
<TextBlock
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
FontSize="12"
Foreground="{TemplateBinding Foreground}"
Text="{TemplateBinding Content}"
TextTrimming="CharacterEllipsis" />
</DockPanel>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

View File

@@ -9,6 +9,7 @@
<ResourceInclude Source="Loading.axaml" />
<ResourceInclude Source="Navigation.axaml" />
<ResourceInclude Source="Pagination.axaml" />
<ResourceInclude Source="TagInput.axaml" />
<ResourceInclude Source="Timeline.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,4 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Add Resources Here -->
<SolidColorBrush x:Key="ClosableTagBackground" Opacity="0.15" Color="#888D92" />
</ResourceDictionary>

View File

@@ -9,6 +9,7 @@
<MergeResourceInclude Source="Loading.axaml" />
<MergeResourceInclude Source="NavigationMenu.axaml" />
<MergeResourceInclude Source="Pagination.axaml" />
<MergeResourceInclude Source="TagInput.axaml" />
<MergeResourceInclude Source="Timeline.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,4 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Add Resources Here -->
<SolidColorBrush x:Key="ClosableTagBackground" Color="White" />
</ResourceDictionary>

View File

@@ -9,6 +9,7 @@
<MergeResourceInclude Source="Loading.axaml" />
<MergeResourceInclude Source="NavigationMenu.axaml" />
<MergeResourceInclude Source="Pagination.axaml" />
<MergeResourceInclude Source="TagInput.axaml" />
<MergeResourceInclude Source="Timeline.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Add Resources Here -->
<PathGeometry x:Key="ClosableTagCloseIconGlyph">M17.6568 19.7782C18.2426 20.3639 19.1924 20.3639 19.7782 19.7782C20.3639 19.1924 20.3639 18.2426 19.7782 17.6568L14.1213 12L19.7782 6.34313C20.3639 5.75734 20.3639 4.8076 19.7782 4.22181C19.1924 3.63602 18.2426 3.63602 17.6568 4.22181L12 9.87866L6.34313 4.22181C5.75734 3.63602 4.8076 3.63602 4.22181 4.22181C3.63602 4.8076 3.63602 5.75734 4.22181 6.34313L9.87866 12L4.22181 17.6568C3.63602 18.2426 3.63602 19.1924 4.22181 19.7782C4.8076 20.3639 5.75734 20.3639 6.34313 19.7782L12 14.1213L17.6568 19.7782Z</PathGeometry>
<x:Double x:Key="TagInputDefaultHeight">32</x:Double>
</ResourceDictionary>

View File

@@ -8,5 +8,6 @@
<MergeResourceInclude Source="IPv4Box.axaml" />
<MergeResourceInclude Source="NavigationMenu.axaml" />
<MergeResourceInclude Source="Pagination.axaml" />
<MergeResourceInclude Source="TagInput.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -1,5 +1,5 @@
using Avalonia.Metadata;
[assembly:XmlnsPrefix("https://irihi.tech/ursa", "u")]
[assembly:XmlnsDefinition("https://irihi.tech/ursa", "Ursa")]
[assembly:XmlnsDefinition("https://irihi.tech/ursa", "Ursa.Controls")]
[assembly:XmlnsPrefix("https://irihi.tech/ursa", "u")]

View File

@@ -0,0 +1,46 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
namespace Ursa.Controls;
[TemplatePart(PART_CloseButton, typeof(PathIcon))]
public class ClosableTag: ContentControl
{
public const string PART_CloseButton = "PART_CloseButton";
private PathIcon? _icon;
public static readonly StyledProperty<ICommand?> CommandProperty = AvaloniaProperty.Register<ClosableTag, ICommand?>(
nameof(Command));
public ICommand? Command
{
get => GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (_icon != null)
{
_icon.PointerPressed -= OnPointerPressed;
}
_icon = e.NameScope.Find<PathIcon>(PART_CloseButton);
if (_icon != null)
{
_icon.PointerPressed += OnPointerPressed;
}
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs args)
{
if (Command != null && Command.CanExecute(null))
{
Command.Execute(this);
}
}
}

View File

@@ -0,0 +1,213 @@
using System.Collections;
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Styling;
namespace Ursa.Controls;
[TemplatePart(PART_ItemsControl, typeof(ItemsControl))]
public class TagInput : TemplatedControl
{
public const string PART_ItemsControl = "PART_ItemsControl";
private readonly TextBox _textBox;
private ItemsControl? _itemsControl;
public static readonly StyledProperty<IList<string>> TagsProperty =
AvaloniaProperty.Register<TagInput, IList<string>>(
nameof(Tags));
public IList<string> Tags
{
get => GetValue(TagsProperty);
set => SetValue(TagsProperty, value);
}
public static readonly DirectProperty<TagInput, IList> ItemsProperty =
AvaloniaProperty.RegisterDirect<TagInput, IList>(
nameof(Items), o => o.Items);
private IList _items;
public IList Items
{
get => _items;
private set => SetAndRaise(ItemsProperty, ref _items, value);
}
public TagInput()
{
_textBox = new TextBox();
_textBox.AddHandler(InputElement.KeyDownEvent, OnTextBoxKeyDown, RoutingStrategies.Tunnel);
Items = new AvaloniaList<object>();
Tags = new ObservableCollection<string>();
}
public static readonly StyledProperty<ControlTheme> InputThemeProperty =
AvaloniaProperty.Register<TagInput, ControlTheme>(
nameof(InputTheme));
public ControlTheme InputTheme
{
get => GetValue(InputThemeProperty);
set => SetValue(InputThemeProperty, value);
}
public static readonly StyledProperty<IDataTemplate?> ItemTemplateProperty =
AvaloniaProperty.Register<TagInput, IDataTemplate?>(
nameof(ItemTemplate));
public IDataTemplate? ItemTemplate
{
get => GetValue(ItemTemplateProperty);
set => SetValue(ItemTemplateProperty, value);
}
public static readonly StyledProperty<string> SeparatorProperty = AvaloniaProperty.Register<TagInput, string>(
nameof(Separator));
public string Separator
{
get => GetValue(SeparatorProperty);
set => SetValue(SeparatorProperty, value);
}
public static readonly StyledProperty<bool> AllowDuplicatesProperty = AvaloniaProperty.Register<TagInput, bool>(
nameof(AllowDuplicates), defaultValue: true);
public bool AllowDuplicates
{
get => GetValue(AllowDuplicatesProperty);
set => SetValue(AllowDuplicatesProperty, value);
}
public static readonly StyledProperty<object?> InnerLeftContentProperty =
AvaloniaProperty.Register<TagInput, object?>(
nameof(InnerLeftContent));
public object? InnerLeftContent
{
get => GetValue(InnerLeftContentProperty);
set => SetValue(InnerLeftContentProperty, value);
}
public static readonly StyledProperty<object?> InnerRightContentProperty =
AvaloniaProperty.Register<TagInput, object?>(
nameof(InnerRightContent));
public object? InnerRightContent
{
get => GetValue(InnerRightContentProperty);
set => SetValue(InnerRightContentProperty, value);
}
static TagInput()
{
InputThemeProperty.Changed.AddClassHandler<TagInput>((o, e) => o.OnInputThemePropertyChanged(e));
TagsProperty.Changed.AddClassHandler<TagInput>((o, e) => o.OnTagsPropertyChanged(e));
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_itemsControl = e.NameScope.Find<ItemsControl>(PART_ItemsControl);
Items.Add(_textBox);
}
private void OnInputThemePropertyChanged(AvaloniaPropertyChangedEventArgs args)
{
var newTheme = args.GetNewValue<ControlTheme>();
if (newTheme.TargetType == typeof(TextBox))
{
_textBox.Theme = newTheme;
}
}
private void OnTagsPropertyChanged(AvaloniaPropertyChangedEventArgs args)
{
var newTags = args.GetNewValue<IList<string>>();
for (int i = 0; i < Items.Count - 1; i++)
{
Items.RemoveAt(Items.Count - 1);
}
for (int i = 0; i < newTags.Count; i++)
{
Items.Insert(Items.Count - 1, newTags[i]);
}
}
private void OnTextBoxKeyDown(object? sender, KeyEventArgs args)
{
if (args.Key == Key.Enter)
{
if (_textBox.Text?.Length > 0)
{
string[] values;
if (!string.IsNullOrEmpty(Separator))
{
values = _textBox.Text.Split(new string[] { Separator },
StringSplitOptions.RemoveEmptyEntries);
}
else
{
values = new[] { _textBox.Text };
}
if (!AllowDuplicates)
{
values = values.Distinct().Except(Tags).ToArray();
}
for (int i = 0; i < values.Length; i++)
{
int index = Items.Count - 1;
Items.Insert(index, values[i]);
Tags.Insert(index, values[i]);
}
_textBox.Text = "";
}
}
else if (args.Key == Key.Delete || args.Key == Key.Back)
{
if (_textBox.Text?.Length == 0)
{
if (Tags.Count == 0)
{
return;
}
int index = Items.Count - 2;
Items.RemoveAt(index);
Tags.RemoveAt(index);
}
}
}
public void Close(object o)
{
if (o is Control t)
{
if (t.Parent is ContentPresenter presenter)
{
int? index = _itemsControl?.IndexFromContainer(presenter);
if (index is >= 0 && index < Items.Count - 1)
{
Items.RemoveAt(index.Value);
Tags.RemoveAt(index.Value);
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Utilities;
namespace Ursa.Controls;
/// <summary>
/// TagInputPanel is a horizontal wrap with last item filling last row.
/// </summary>
public class TagInputPanel: Panel
{
protected override Size MeasureOverride(Size availableSize)
{
// return base.MeasureOverride(availableSize);
double currentLineX = 0;
double currentLineHeight = 0;
double totalHeight = 0;
var children = Children;
for (int i = 0; i < children.Count-1; i++)
{
var child = children[i];
child.Measure(availableSize);
double deltaX = availableSize.Width - currentLineX;
// Width is enough to place next child
if (MathUtilities.GreaterThan(deltaX, child.DesiredSize.Width))
{
currentLineX+=child.DesiredSize.Width;
currentLineHeight = Math.Max(currentLineHeight, child.DesiredSize.Height);
}
// Width is not enough to place next child
// reset currentLineX and currentLineHeight
// accumulate last line height to total height.
// Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration.
else
{
currentLineX = child.DesiredSize.Width;
totalHeight += currentLineHeight;
currentLineHeight = child.DesiredSize.Height;
}
}
var last = children[children.Count - 1];
last.Measure(availableSize);
double lastDeltaX = availableSize.Width - currentLineX;
// If width is not enough, add a new line, and recalculate total height
if (lastDeltaX < 30)
{
totalHeight+=currentLineHeight;
totalHeight += last.DesiredSize.Height;
}
else
{
currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height);
totalHeight += currentLineHeight;
}
return new Size(availableSize.Width, totalHeight);
}
protected override Size ArrangeOverride(Size finalSize)
{
double currentLineX = 0;
double currentLineHeight = 0;
double totalHeight = 0;
var children = Children;
for (int i = 0; i < children.Count - 1; i++)
{
var child = children[i];
double deltaX = finalSize.Width - currentLineX;
// Width is enough to place next child
if (MathUtilities.GreaterThan(deltaX, child.DesiredSize.Width))
{
child.Arrange(new Rect(currentLineX, totalHeight, child.DesiredSize.Width, Math.Max(child.DesiredSize.Height, currentLineHeight)));
currentLineX += child.DesiredSize.Width;
currentLineHeight = Math.Max(currentLineHeight, child.DesiredSize.Height);
}
// Width is not enough to place next child
// reset currentLineX and currentLineHeight
// accumulate last line height to total height.
// Notice: last line height accumulation only happens when restarting a new line, so it needs to finally add one more time outside iteration.
else
{
totalHeight += currentLineHeight;
child.Arrange(new Rect(0, totalHeight, Math.Min(child.DesiredSize.Width, finalSize.Width), child.DesiredSize.Height));
currentLineX = child.DesiredSize.Width;
currentLineHeight = child.DesiredSize.Height;
}
}
var last = children[children.Count - 1];
double lastDeltaX = finalSize.Width - currentLineX;
// If width is not enough, add a new line, and recalculate total height
if (lastDeltaX < 10)
{
totalHeight += currentLineHeight;
last.Arrange(new Rect(0, totalHeight, finalSize.Width, last.DesiredSize.Height));
totalHeight += last.DesiredSize.Height;
}
else
{
currentLineHeight = children.Count == 1 ? finalSize.Height : currentLineHeight;
last.Arrange(new Rect(currentLineX, totalHeight, lastDeltaX,
Math.Max(currentLineHeight, last.DesiredSize.Height)));
currentLineHeight = Math.Max(currentLineHeight, last.DesiredSize.Height);
totalHeight += currentLineHeight;
}
return new Size(finalSize.Width, totalHeight);
}
}