diff --git a/demo/Ursa.Demo/Models/MenuKeys.cs b/demo/Ursa.Demo/Models/MenuKeys.cs index 60d6ef2..ea8f8f5 100644 --- a/demo/Ursa.Demo/Models/MenuKeys.cs +++ b/demo/Ursa.Demo/Models/MenuKeys.cs @@ -22,6 +22,7 @@ public static class MenuKeys public const string MenuKeyNumericUpDown = "NumericUpDown"; public const string MenuKeyPagination = "Pagination"; public const string MenuKeyRangeSlider = "RangeSlider"; + public const string MenuKeySelectionList = "SelectionList"; public const string MenuKeyTagInput = "TagInput"; public const string MenuKeyTimeline = "Timeline"; public const string MenuKeyTwoTonePathIcon = "TwoTonePathIcon"; diff --git a/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml b/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml new file mode 100644 index 0000000..18068cf --- /dev/null +++ b/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml.cs b/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml.cs new file mode 100644 index 0000000..60d5ccd --- /dev/null +++ b/demo/Ursa.Demo/Pages/SelectionBoxDemo.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Ursa.Demo.Pages; + +public partial class SelectionListDemo : UserControl +{ + public SelectionListDemo() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 6797172..0a20fe2 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -44,6 +44,7 @@ public class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyNumericUpDown => new NumericUpDownDemoViewModel(), MenuKeys.MenuKeyPagination => new PaginationDemoViewModel(), MenuKeys.MenuKeyRangeSlider => new RangeSliderDemoViewModel(), + MenuKeys.MenuKeySelectionList => new SelectionListDemoViewModel(), MenuKeys.MenuKeyTagInput => new TagInputDemoViewModel(), MenuKeys.MenuKeyTimeline => new TimelineDemoViewModel(), MenuKeys.MenuKeyTwoTonePathIcon => new TwoTonePathIconDemoViewModel(), diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index cf807af..1377af9 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -31,6 +31,7 @@ public class MenuViewModel: ViewModelBase new() { MenuHeader = "NumericUpDown", Key = MenuKeys.MenuKeyNumericUpDown, Status = "New" }, new() { MenuHeader = "Pagination", Key = MenuKeys.MenuKeyPagination }, new() { MenuHeader = "RangeSlider", Key = MenuKeys.MenuKeyRangeSlider, Status = "New"}, + new() { MenuHeader = "Selection List", Key = MenuKeys.MenuKeySelectionList, Status = "New" }, new() { MenuHeader = "TagInput", Key = MenuKeys.MenuKeyTagInput }, new() { MenuHeader = "Theme Toggler", Key = MenuKeys.MenuKeyThemeToggler }, new() { MenuHeader = "Timeline", Key = MenuKeys.MenuKeyTimeline, Status = "Updated" }, diff --git a/demo/Ursa.Demo/ViewModels/SelectionListDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/SelectionListDemoViewModel.cs new file mode 100644 index 0000000..3711edd --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/SelectionListDemoViewModel.cs @@ -0,0 +1,23 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Ursa.Demo.ViewModels; + +public partial class SelectionListDemoViewModel: ObservableObject +{ + public ObservableCollection Items { get; set; } + [ObservableProperty] private string? _selectedItem; + + public SelectionListDemoViewModel() + { + Items = new ObservableCollection() + { + "Ding", "Otter", "Husky", "Mr. 17", "Cass" + }; + } + + public void Clear() + { + SelectedItem = null; + } +} \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/SelectionList.axaml b/src/Ursa.Themes.Semi/Controls/SelectionList.axaml new file mode 100644 index 0000000..8306ddc --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/SelectionList.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index f770a28..4bd0c67 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -21,6 +21,7 @@ + diff --git a/src/Ursa/Controls/SelectionList/SelectionList.cs b/src/Ursa/Controls/SelectionList/SelectionList.cs new file mode 100644 index 0000000..ba102cc --- /dev/null +++ b/src/Ursa/Controls/SelectionList/SelectionList.cs @@ -0,0 +1,153 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Animations; +using Irihi.Avalonia.Shared.Helpers; + +namespace Ursa.Controls; + +[TemplatePart(PART_Indicator, typeof(ContentPresenter))] +public class SelectionList: SelectingItemsControl +{ + public const string PART_Indicator = "PART_Indicator"; + private static readonly FuncTemplate DefaultPanel = new(() => new StackPanel()); + + private ImplicitAnimationCollection? _implicitAnimations; + private ContentPresenter? _indicator; + + public static readonly StyledProperty IndicatorProperty = AvaloniaProperty.Register( + nameof(Indicator)); + + public Control? Indicator + { + get => GetValue(IndicatorProperty); + set => SetValue(IndicatorProperty, value); + } + + static SelectionList() + { + SelectionModeProperty.OverrideMetadata( + new StyledPropertyMetadata( + defaultValue: SelectionMode.Single, + coerce: (o, mode) => SelectionMode.Single) + ); + SelectedItemProperty.Changed.AddClassHandler((list, args) => + list.OnSelectedItemChanged(args)); + } + + private void OnSelectedItemChanged(AvaloniaPropertyChangedEventArgs args) + { + var newValue = args.NewValue.Value; + if (newValue is null) + { + OpacityProperty.SetValue(0d, _indicator); + return; + } + var container = ContainerFromItem(newValue); + if (container is null) + { + OpacityProperty.SetValue(0d, _indicator); + return; + } + OpacityProperty.SetValue(1d, _indicator); + InvalidateMeasure(); + InvalidateArrange(); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var size = base.ArrangeOverride(finalSize); + if(_indicator is not null && SelectedItem is not null) + { + var container = ContainerFromItem(SelectedItem); + if (container is null) return size; + _indicator.Arrange(container.Bounds); + } + else + { + // This is a hack. The indicator is not visible, so we arrange it to a 1x1 rectangle + _indicator?.Arrange(new Rect(new Point(), new Size(1, 1))); + } + return size; + } + + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new SelectionListItem(); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _indicator= e.NameScope.Find(PART_Indicator); + EnsureIndicatorAnimation(); + } + + private void EnsureIndicatorAnimation() + { + if (_indicator is not null) + { + _indicator.Opacity = 0; + SetUpAnimation(); + if (ElementComposition.GetElementVisual(_indicator) is { } v) + { + v.ImplicitAnimations = _implicitAnimations; + } + } + } + + internal void SelectByIndex(int index) + { + using var operation = Selection.BatchUpdate(); + Selection.Clear(); + Selection.Select(index); + } + + private void SetUpAnimation() + { + if (_implicitAnimations != null) return; + var compositorVisual = ElementComposition.GetElementVisual(this); + if (compositorVisual is null) return; + var compositor = ElementComposition.GetElementVisual(this)!.Compositor; + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = nameof(CompositionVisual.Offset); + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromSeconds(0.3); + var sizeAnimation = compositor.CreateVector2KeyFrameAnimation(); + sizeAnimation.Target = nameof(CompositionVisual.Size); + sizeAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + sizeAnimation.Duration = TimeSpan.FromSeconds(0.3); + var opacityAnimation = compositor.CreateScalarKeyFrameAnimation(); + opacityAnimation.Target = nameof(CompositionVisual.Opacity); + opacityAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + opacityAnimation.Duration = TimeSpan.FromSeconds(0.3); + + _implicitAnimations = compositor.CreateImplicitAnimationCollection(); + _implicitAnimations[nameof(CompositionVisual.Offset)] = offsetAnimation; + _implicitAnimations[nameof(CompositionVisual.Size)] = sizeAnimation; + _implicitAnimations[nameof(CompositionVisual.Opacity)] = opacityAnimation; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + var hotkeys = Application.Current!.PlatformSettings?.HotkeyConfiguration; + + if (e.Key.ToNavigationDirection() is { } direction && direction.IsDirectional()) + { + e.Handled |= MoveSelection(direction, WrapSelection); + } + base.OnKeyDown(e); + } +} diff --git a/src/Ursa/Controls/SelectionList/SelectionListItem.cs b/src/Ursa/Controls/SelectionList/SelectionListItem.cs new file mode 100644 index 0000000..1e89704 --- /dev/null +++ b/src/Ursa/Controls/SelectionList/SelectionListItem.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Mixins; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace Ursa.Controls; + +public class SelectionListItem: ContentControl, ISelectable +{ + static SelectionListItem() + { + SelectableMixin.Attach(IsSelectedProperty); + PressedMixin.Attach(); + FocusableProperty.OverrideDefaultValue(true); + } + + private static readonly Point s_invalidPoint = new Point(double.NaN, double.NaN); + private Point _pointerDownPoint = s_invalidPoint; + + public static readonly StyledProperty IsSelectedProperty = SelectingItemsControl.IsSelectedProperty.AddOwner(); + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (ItemsControl.ItemsControlFromItemContaner(this) is SelectionList list) + { + int index = list.IndexFromContainer(this); + list.SelectByIndex(index); + } + } +} \ No newline at end of file