From fc1632538e22f7ce71ec3efa2858729296adbbd1 Mon Sep 17 00:00:00 2001 From: Tom Edwards Date: Sun, 11 Feb 2024 16:05:53 +0100 Subject: [PATCH 01/10] Added ItemsSourceView.Filter Added filter processing for Add and Remove collection changes Added search filter to ControlCatalog's ListBoxPage --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 4 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 12 +- .../ItemsSourceView.Filters.cs | 257 ++++++++++++++++++ src/Avalonia.Controls/ItemsSourceView.cs | 91 +++++-- .../ItemsSourceViewTests.cs | 92 +++++++ 5 files changed, 424 insertions(+), 32 deletions(-) create mode 100644 src/Avalonia.Controls/ItemsSourceView.Filters.cs diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index e3a706bfed..dee76e2580 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -34,13 +34,15 @@ AlwaysSelected AutoScrollToSelectedItem WrapSelection + - + /// Gets the item being filtered. + /// + public object? Item { get; init; } + + /// + /// Gets or sets whether should pass the filter. + /// + public bool Accept { get; set; } = true; +} + +public partial class ItemsSourceView +{ + private static readonly Lazy> s_rewrittenCollectionChangedEvents = new(); + + private static NotifyCollectionChangedEventArgs? GetRewrittenEvent(NotifyCollectionChangedEventArgs e) + { + if (s_rewrittenCollectionChangedEvents.IsValueCreated && s_rewrittenCollectionChangedEvents.Value.TryGetValue(e, out var rewritten)) + { + return rewritten; + } + + return e; + } + + private (List items, List indexMap)? _filterState; + private EventHandler? _filter; + + public EventHandler? Filter + { + get => _filter; + set + { + _filter = value; + Refresh(); + } + } + + private bool ItemPassesFilter(object? item, EventHandler filter) + { + var args = new ItemSourceViewFilterEventArgs() { Item = item }; + + var handlers = filter.GetInvocationList(); + for (var i = 0; i < handlers.Length; i++) + { + var method = (EventHandler)handlers[i]; + + method(this, args); + if (!args.Accept) + { + return false; + } + } + + return true; + } + + public void Refresh() + { + if (_filterState == null && Filter == null) + { + return; + } + + if (Filter == null) + { + RemoveListenerIfNecessary(); + _filterState = null; + } + else + { + AddListenerIfNecessary(); + _filterState = ExecuteFilter(Filter, CancellationToken.None); + } + + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); + } + + /// + /// Executes asynchronously, and applies results in one operation after processing completes. + /// + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + if (_filterState == null && Filter == null) + { + return; + } + + if (Filter is not { } filter) + { + RemoveListenerIfNecessary(); + _filterState = null; + } + else + { + AddListenerIfNecessary(); + + _filterState = await Task.Run(() => ExecuteFilter(filter, cancellationToken), cancellationToken); + } + + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); + } + + private (List items, List indexMap) ExecuteFilter(EventHandler filter, CancellationToken cancellationToken) + { + var result = new List(); + var map = new List(); + + for (int i = 0; i < _source.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (ItemPassesFilter(_source[i], filter)) + { + map.Add(result.Count); + result.Add(_source[i]); + } + else + { + map.Add(-1); + } + } + return (result, map); + } + + private void FilterCollectionChangedEvent(NotifyCollectionChangedEventArgs e, EventHandler filter) + { + if (_filterState is not { } filterState) + { + throw new InvalidOperationException("Filter feature not initialised."); + } + + List? filteredNewItems = null; + int filteredNewItemsStartingIndex = -1; + + List? filteredOldItems = null; + int filteredOldItemsStartingIndex = -1; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + + for (var i = 0; i < e.NewItems.Count; i++) + { + var sourceIndex = e.NewStartingIndex + i; + if (ItemPassesFilter(e.NewItems[i], filter)) + { + if (filteredNewItems == null) + { + filteredNewItems = new(e.NewItems.Count); + filteredNewItemsStartingIndex = FilteredStartingIndex(e.NewStartingIndex); + } + + var filteredItemIndex = filteredNewItemsStartingIndex + filteredNewItems.Count; + filterState.items.Insert(filteredItemIndex, e.NewItems[i]); + + ShiftIndexMap(sourceIndex, 1); + filterState.indexMap.Insert(sourceIndex, filteredItemIndex); + + filteredNewItems.Add(e.NewItems[i]); + } + else + { + filterState.indexMap.Insert(sourceIndex, -1); + } + } + s_rewrittenCollectionChangedEvents.Value.Add(e, filteredNewItems == null ? null : new(NotifyCollectionChangedAction.Add, filteredNewItems, filteredNewItemsStartingIndex)); + break; + case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + for (var i = 0; i < e.OldItems.Count; i++) + { + var sourceIndex = e.OldStartingIndex + i; + if (filterState.indexMap[sourceIndex] != -1) + { + if (filteredOldItems == null) + { + filteredOldItems = new(e.OldItems.Count); + filteredOldItemsStartingIndex = FilteredStartingIndex(e.OldStartingIndex); + } + + filterState.items.RemoveAt(filterState.indexMap[sourceIndex]); + filteredOldItems.Add(e.OldItems[i]); + } + ShiftIndexMap(sourceIndex, -1); + filterState.indexMap.RemoveAt(sourceIndex); + } + s_rewrittenCollectionChangedEvents.Value.Add(e, filteredOldItems == null ? null : new(NotifyCollectionChangedAction.Remove, filteredOldItems, filteredOldItemsStartingIndex)); + break; + case NotifyCollectionChangedAction.Replace: + throw new NotImplementedException(); + case NotifyCollectionChangedAction.Reset: + Refresh(); + s_rewrittenCollectionChangedEvents.Value.Add(e, null); + break; + case NotifyCollectionChangedAction.Move: + throw new NotImplementedException(); + default: + throw new NotImplementedException(); + } + } + + private void ShiftIndexMap(int inclusiveStart, int delta) + { + var map = _filterState!.Value.indexMap; + for (var i = 0; i < map.Count; i++) + { + if (map[i] >= inclusiveStart) + { + map[i] += delta; + } + } + } + + private int FilteredStartingIndex(int sourceIndex) + { + if (_filterState is not { } filterState) + { + throw new InvalidOperationException("Filter feature not initialised."); + } + + var candidateIndex = sourceIndex; + var insertAtEnd = candidateIndex >= filterState.indexMap.Count; + + if (insertAtEnd) + { + candidateIndex = filterState.indexMap.Count - 1; + } + + int filteredIndex; + do + { + if (candidateIndex == -1) + { + return 0; + } + + filteredIndex = filterState.indexMap[candidateIndex--]; + } + while (filteredIndex < 0); + + return filteredIndex + (insertAtEnd ? 1 : 0); + } +} diff --git a/src/Avalonia.Controls/ItemsSourceView.cs b/src/Avalonia.Controls/ItemsSourceView.cs index 22cddddd05..d433cdc1d6 100644 --- a/src/Avalonia.Controls/ItemsSourceView.cs +++ b/src/Avalonia.Controls/ItemsSourceView.cs @@ -7,6 +7,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia.Controls.Utils; @@ -17,9 +18,10 @@ namespace Avalonia.Controls /// Represents a standardized view of the supported interactions between an items collection /// and an items control. /// - public class ItemsSourceView : IReadOnlyList, + public partial class ItemsSourceView : IReadOnlyList, IList, INotifyCollectionChanged, + INotifyPropertyChanged, ICollectionChangedListener { /// @@ -38,8 +40,11 @@ namespace Avalonia.Controls private NotifyCollectionChangedEventHandler? _collectionChanged; private NotifyCollectionChangedEventHandler? _preCollectionChanged; private NotifyCollectionChangedEventHandler? _postCollectionChanged; + private PropertyChangedEventHandler? _propertyChanged; private bool _listening; + private IList InternalSource => _filterState?.items ?? _source; + /// /// Initializes a new instance of the ItemsSourceView class for the specified data source. /// @@ -49,7 +54,7 @@ namespace Avalonia.Controls /// /// Gets the number of items in the collection. /// - public int Count => Source.Count; + public int Count => InternalSource.Count; /// /// Gets the source collection. @@ -138,14 +143,29 @@ namespace Avalonia.Controls } } + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + AddListenerIfNecessary(); + _propertyChanged += value; + } + + remove + { + _propertyChanged -= value; + RemoveListenerIfNecessary(); + } + } + /// /// Retrieves the item at the specified index. /// /// The index. /// The item. - public object? GetAt(int index) => Source[index]; - public bool Contains(object? item) => Source.Contains(item); - public int IndexOf(object? item) => Source.IndexOf(item); + public object? GetAt(int index) => InternalSource[index]; + public bool Contains(object? item) => InternalSource.Contains(item); + public int IndexOf(object? item) => InternalSource.IndexOf(item); /// /// Gets or creates an for the specified enumerable. @@ -184,7 +204,6 @@ namespace Avalonia.Controls return items switch { ItemsSourceView isvt => isvt, - ItemsSourceView isv => new ItemsSourceView(isv.Source), null => ItemsSourceView.Empty, _ => new ItemsSourceView(items) }; @@ -219,7 +238,7 @@ namespace Avalonia.Controls yield return o; } - var inner = Source; + var inner = InternalSource; return inner switch { @@ -228,21 +247,44 @@ namespace Avalonia.Controls }; } - IEnumerator IEnumerable.GetEnumerator() => Source.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => InternalSource.GetEnumerator(); void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) { - _preCollectionChanged?.Invoke(this, e); + if (Filter is { } filter) + { + FilterCollectionChangedEvent(e, filter); + } + + if (GetRewrittenEvent(e) is not { } rewritten) + { + return; + } + + _preCollectionChanged?.Invoke(this, rewritten); } void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) { - _collectionChanged?.Invoke(this, e); + if (GetRewrittenEvent(e) is not { } rewritten) + { + return; + } + + _collectionChanged?.Invoke(this, rewritten); + + if (rewritten.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Remove or NotifyCollectionChangedAction.Reset) + _propertyChanged?.Invoke(this, new(nameof(Count))); } void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) { - _postCollectionChanged?.Invoke(this, e); + if (GetRewrittenEvent(e) is not { } rewritten) + { + return; + } + + _postCollectionChanged?.Invoke(this, rewritten); } int IList.Add(object? value) => ThrowReadOnly(); @@ -250,7 +292,7 @@ namespace Avalonia.Controls void IList.Insert(int index, object? value) => ThrowReadOnly(); void IList.Remove(object? value) => ThrowReadOnly(); void IList.RemoveAt(int index) => ThrowReadOnly(); - void ICollection.CopyTo(Array array, int index) => Source.CopyTo(array, index); + void ICollection.CopyTo(Array array, int index) => InternalSource.CopyTo(array, index); /// /// Not implemented in Avalonia, preserved here for ItemsRepeater's usage. @@ -272,7 +314,6 @@ namespace Avalonia.Controls _source = source switch { - ItemsSourceView isv => isv.Source, IList list => list, INotifyCollectionChanged => throw new ArgumentException( "Collection implements INotifyCollectionChanged but not IList.", @@ -298,12 +339,12 @@ namespace Avalonia.Controls private void RemoveListenerIfNecessary() { - if (_listening && _collectionChanged is null && _postCollectionChanged is null) + if (_listening && _collectionChanged is null && _postCollectionChanged is null && Filter == null) { if (_source is INotifyCollectionChanged incc) CollectionChangedEventManager.Instance.RemoveListener(incc, this); _listening = false; - } + } } [DoesNotReturn] @@ -343,25 +384,15 @@ namespace Avalonia.Controls /// /// The index. /// The item. - public new T GetAt(int index) => (T)Source[index]!; + public new T GetAt(int index) => (T)base[index]!; public new IEnumerator GetEnumerator() { - static IEnumerator EnumerateItems(IList list) - { - foreach (var o in list) - yield return (T)o; - } - - var inner = Source; - - return inner switch - { - IEnumerable e => e.GetEnumerator(), - _ => EnumerateItems(inner), - }; + using var enumerator = base.GetEnumerator(); + while (enumerator.MoveNext()) + yield return (T)enumerator.Current!; } - IEnumerator IEnumerable.GetEnumerator() => Source.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } diff --git a/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs index ab5f4c5e16..99acd13a62 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs @@ -68,6 +68,98 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, debug.GetCollectionChangedSubscribers().Length); } + [Fact] + public void Filtered_View_Adds_New_Items() + { + var source = new AvaloniaList() { "foo", "bar" }; + var target = ItemsSourceView.GetOrCreate(source); + + var collectionChangeEvents = new List(); + + target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); + + target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); + collectionChangeEvents.Clear(); + + source.Add(bool.FalseString); + + Assert.Empty(collectionChangeEvents); + + source.InsertRange(1, new[] { bool.TrueString, bool.TrueString }); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Add, collectionChangeEvents[0].Action); + Assert.Equal(1, collectionChangeEvents[0].NewStartingIndex); + Assert.Equal(2, collectionChangeEvents[0].NewItems.Count); + Assert.Equal(bool.TrueString, collectionChangeEvents[0].NewItems[0]); + + Assert.Equal(4, target.Count); + Assert.Equal(bool.TrueString, target[1]); + Assert.Equal(bool.TrueString, target[2]); + + source.Add(bool.TrueString); + + Assert.Equal(5, target.Count); + Assert.Equal(bool.TrueString, target[^1]); + } + + [Fact] + public void Filtered_View_Removes_Old_Items() + { + var source = new AvaloniaList() { "foo", "bar", bool.TrueString, bool.FalseString, bool.TrueString, "end" }; + var target = ItemsSourceView.GetOrCreate(source); + + var collectionChangeEvents = new List(); + + target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); + + target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); + collectionChangeEvents.Clear(); + + source.RemoveAt(4); + + Assert.Equal(4, target.Count); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Remove, collectionChangeEvents[0].Action); + Assert.Equal(3, collectionChangeEvents[0].OldStartingIndex); + Assert.Equal(1, collectionChangeEvents[0].OldItems.Count); + Assert.Equal(bool.TrueString, collectionChangeEvents[0].OldItems[0]); + collectionChangeEvents.Clear(); + + source.RemoveAt(3); + Assert.Empty(collectionChangeEvents); + Assert.Equal(4, target.Count); + } + + [Fact] + public void Filtered_View_Resets_When_Source_Cleared() + { + var source = new AvaloniaList() { "foo", "bar" }; + var target = ItemsSourceView.GetOrCreate(source); + + var collectionChangeEvents = new List(); + + target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); + + target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); + collectionChangeEvents.Clear(); + + source.Clear(); + + Assert.Equal(1, collectionChangeEvents.Count); + Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); + } + private class InvalidCollection : INotifyCollectionChanged, IEnumerable { public event NotifyCollectionChangedEventHandler CollectionChanged { add { } remove { } } From 06c5ec6a09e9d85955a133c77f742a1ce130317a Mon Sep 17 00:00:00 2001 From: Tom Edwards Date: Sun, 11 Feb 2024 18:23:05 +0100 Subject: [PATCH 02/10] Fixed SelectingItemsControl assigning ItemsView.Source to its selection model --- src/Avalonia.Controls/Primitives/SelectingItemsControl.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 59289838bb..3c7a3f1257 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -1236,7 +1236,7 @@ namespace Avalonia.Controls.Primitives private void TryInitializeSelectionSource(ISelectionModel? selection, bool shouldSelectItemFromSelectedValue) { - if (selection is not null && ItemsView.TryGetInitializedSource() is { } source) + if (selection is not null) { // InternalSelectionModel keeps the SelectedIndex and SelectedItem values before the ItemsSource is set. // However, SelectedValue isn't part of that model, so we have to set the SelectedItem from @@ -1256,7 +1256,7 @@ namespace Avalonia.Controls.Primitives selection.SelectedItem = item; } - selection.Source = source; + selection.Source = ItemsView; } } From 7d6f135e0d1a5e7cecbfe584bb8f125ac852e372 Mon Sep 17 00:00:00 2001 From: Tom Edwards Date: Thu, 15 Feb 2024 22:28:12 +0100 Subject: [PATCH 03/10] Added ItemSourceViewLayer Supports filter invalidation via changes to a "State" property, or via PropertyChanged notifications on individual items Fixed AvaloniaListAttribute passing invalid options to string.Split Added ItemFilter Added ItemSorter (abstract class only) --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 19 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 17 +- .../ViewModels/ListBoxPageViewModel.cs | 17 +- src/Avalonia.Controls/Flyouts/MenuFlyout.cs | 2 +- src/Avalonia.Controls/ItemCollection.cs | 4 +- src/Avalonia.Controls/ItemFilter.cs | 80 ++++ src/Avalonia.Controls/ItemSorter.cs | 14 + src/Avalonia.Controls/ItemsControl.cs | 8 +- .../ItemsSourceView.Filters.cs | 257 ------------ .../ItemsSourceView.LayerProcessing.cs | 369 ++++++++++++++++++ src/Avalonia.Controls/ItemsSourceView.cs | 89 ++++- src/Avalonia.Controls/ItemsSourceViewLayer.cs | 107 +++++ .../AvaloniaXamlIlLanguageParseIntrinsics.cs | 2 +- .../ItemsSourceViewTests.cs | 25 +- 14 files changed, 711 insertions(+), 299 deletions(-) create mode 100644 src/Avalonia.Controls/ItemFilter.cs create mode 100644 src/Avalonia.Controls/ItemSorter.cs delete mode 100644 src/Avalonia.Controls/ItemsSourceView.Filters.cs create mode 100644 src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs create mode 100644 src/Avalonia.Controls/ItemsSourceViewLayer.cs diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index dee76e2580..edc2cd4c97 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -34,7 +34,7 @@ AlwaysSelected AutoScrollToSelectedItem WrapSelection - + @@ -44,9 +44,22 @@ + WrapSelection="{Binding WrapSelection}"> + + + + + + + + Item + + + + + + diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs b/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs index f23f8dbe7f..dc124548de 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml.cs @@ -11,14 +11,17 @@ namespace ControlCatalog.Pages InitializeComponent(); } - private void OnSearchTextChanged(object? sender, TextChangedEventArgs e) + private void FilterItem(object? sender, FunctionItemFilter.FilterEventArgs e) { - ListBox.Items.Filter = string.IsNullOrEmpty(SearchBox.Text) ? null : FilterItem; - } - - private void FilterItem(object? sender, ItemSourceViewFilterEventArgs e) - { - e.Accept = ((ItemModel)e.Item!).ToString().Contains(SearchBox.Text!); + if (string.IsNullOrEmpty(SearchBox.Text)) + { + e.Accept = true; + } + else + { + var item = (ItemModel)e.Item!; + e.Accept = item.IsFavorite || item.ID.ToString().Contains(SearchBox.Text!); + } } } } diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs index a1dd17af58..2896d0bc5f 100644 --- a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Selection; @@ -108,8 +109,10 @@ namespace ControlCatalog.ViewModels /// /// An Item model for the /// - public class ItemModel + public class ItemModel : INotifyPropertyChanged { + private bool _isFavorite; + /// /// Creates a new ItemModel with the given ID /// @@ -124,6 +127,18 @@ namespace ControlCatalog.ViewModels /// public int ID { get; } + public bool IsFavorite + { + get => _isFavorite; + set + { + _isFavorite = value; + PropertyChanged?.Invoke(this, new(nameof(IsFavorite))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + public override string ToString() { return $"Item {ID}"; diff --git a/src/Avalonia.Controls/Flyouts/MenuFlyout.cs b/src/Avalonia.Controls/Flyouts/MenuFlyout.cs index 1e5f43cf41..5fe929fad9 100644 --- a/src/Avalonia.Controls/Flyouts/MenuFlyout.cs +++ b/src/Avalonia.Controls/Flyouts/MenuFlyout.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls { public MenuFlyout() { - Items = new ItemCollection(); + Items = new ItemCollection(this); } /// diff --git a/src/Avalonia.Controls/ItemCollection.cs b/src/Avalonia.Controls/ItemCollection.cs index 03fed042be..96b43b6049 100644 --- a/src/Avalonia.Controls/ItemCollection.cs +++ b/src/Avalonia.Controls/ItemCollection.cs @@ -13,8 +13,8 @@ namespace Avalonia.Controls { private Mode _mode; - internal ItemCollection() - : base(UninitializedSource) + internal ItemCollection(AvaloniaObject? owner) + : base(owner, UninitializedSource) { } diff --git a/src/Avalonia.Controls/ItemFilter.cs b/src/Avalonia.Controls/ItemFilter.cs new file mode 100644 index 0000000000..d009d47aa3 --- /dev/null +++ b/src/Avalonia.Controls/ItemFilter.cs @@ -0,0 +1,80 @@ +using System; + +namespace Avalonia.Controls; + +public abstract class ItemFilter : ItemsSourceViewLayer +{ + /// + /// Determines whether an item passes this filter. + /// + /// True if the item passes the filter, otherwise false. + public abstract bool FilterItem(object? item); +} + +public class FunctionItemFilter : ItemFilter +{ + private EventHandler? _filter; + + /// + /// Gets or sets a method which determines whether an item passes this filter. + /// + /// + /// If a multicast delegate is assigned, all invocations must accept the item in order for it to pass the filter. + /// + public EventHandler? Filter + { + get => _filter; + set => SetAndRaise(FilterProperty, ref _filter, value); + } + + /// + public static readonly DirectProperty?> FilterProperty = + AvaloniaProperty.RegisterDirect?>(nameof(Filter), o => o.Filter, (o, v) => o.Filter = v); + + public override bool FilterItem(object? item) + { + if (Filter == null) + { + return true; + } + + var args = new FilterEventArgs() { Item = item }; + + var handlers = Filter.GetInvocationList(); + for (var i = 0; i < handlers.Length; i++) + { + var method = (EventHandler)handlers[i]; + + method(this, args); + if (!args.Accept) + { + return false; + } + } + + return true; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FilterProperty) + { + OnInvalidated(); + } + } + + public class FilterEventArgs : EventArgs + { + /// + /// Gets the item being filtered. + /// + public object? Item { get; init; } + + /// + /// Gets or sets whether should pass the filter. + /// + public bool Accept { get; set; } = true; + } +} diff --git a/src/Avalonia.Controls/ItemSorter.cs b/src/Avalonia.Controls/ItemSorter.cs new file mode 100644 index 0000000000..4bc519ef4f --- /dev/null +++ b/src/Avalonia.Controls/ItemSorter.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Data; + +namespace Avalonia.Controls; + +public abstract class ItemSorter : ItemsSourceViewLayer, IComparer +{ + public abstract int Compare(object? x, object? y); +} diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index acc6f4762d..586458975a 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using Avalonia.Automation.Peers; +using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Metadata; using Avalonia.Controls.Presenters; @@ -81,7 +82,11 @@ namespace Avalonia.Controls set => SetValue(DisplayMemberBindingProperty, value); } - private readonly ItemCollection _items = new(); + public AvaloniaList Filters => ItemsView.Filters; + + public AvaloniaList Sorters => ItemsView.Sorters; + + private readonly ItemCollection _items; private int _itemCount; private ItemContainerGenerator? _itemContainerGenerator; private EventHandler? _childIndexChanged; @@ -93,6 +98,7 @@ namespace Avalonia.Controls /// public ItemsControl() { + _items = new(this); UpdatePseudoClasses(); _items.CollectionChanged += OnItemsViewCollectionChanged; } diff --git a/src/Avalonia.Controls/ItemsSourceView.Filters.cs b/src/Avalonia.Controls/ItemsSourceView.Filters.cs deleted file mode 100644 index 6d2742d1b3..0000000000 --- a/src/Avalonia.Controls/ItemsSourceView.Filters.cs +++ /dev/null @@ -1,257 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace Avalonia.Controls; - -public class ItemSourceViewFilterEventArgs : EventArgs -{ - /// - /// Gets the item being filtered. - /// - public object? Item { get; init; } - - /// - /// Gets or sets whether should pass the filter. - /// - public bool Accept { get; set; } = true; -} - -public partial class ItemsSourceView -{ - private static readonly Lazy> s_rewrittenCollectionChangedEvents = new(); - - private static NotifyCollectionChangedEventArgs? GetRewrittenEvent(NotifyCollectionChangedEventArgs e) - { - if (s_rewrittenCollectionChangedEvents.IsValueCreated && s_rewrittenCollectionChangedEvents.Value.TryGetValue(e, out var rewritten)) - { - return rewritten; - } - - return e; - } - - private (List items, List indexMap)? _filterState; - private EventHandler? _filter; - - public EventHandler? Filter - { - get => _filter; - set - { - _filter = value; - Refresh(); - } - } - - private bool ItemPassesFilter(object? item, EventHandler filter) - { - var args = new ItemSourceViewFilterEventArgs() { Item = item }; - - var handlers = filter.GetInvocationList(); - for (var i = 0; i < handlers.Length; i++) - { - var method = (EventHandler)handlers[i]; - - method(this, args); - if (!args.Accept) - { - return false; - } - } - - return true; - } - - public void Refresh() - { - if (_filterState == null && Filter == null) - { - return; - } - - if (Filter == null) - { - RemoveListenerIfNecessary(); - _filterState = null; - } - else - { - AddListenerIfNecessary(); - _filterState = ExecuteFilter(Filter, CancellationToken.None); - } - - RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); - } - - /// - /// Executes asynchronously, and applies results in one operation after processing completes. - /// - public async Task RefreshAsync(CancellationToken cancellationToken = default) - { - if (_filterState == null && Filter == null) - { - return; - } - - if (Filter is not { } filter) - { - RemoveListenerIfNecessary(); - _filterState = null; - } - else - { - AddListenerIfNecessary(); - - _filterState = await Task.Run(() => ExecuteFilter(filter, cancellationToken), cancellationToken); - } - - RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); - } - - private (List items, List indexMap) ExecuteFilter(EventHandler filter, CancellationToken cancellationToken) - { - var result = new List(); - var map = new List(); - - for (int i = 0; i < _source.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (ItemPassesFilter(_source[i], filter)) - { - map.Add(result.Count); - result.Add(_source[i]); - } - else - { - map.Add(-1); - } - } - return (result, map); - } - - private void FilterCollectionChangedEvent(NotifyCollectionChangedEventArgs e, EventHandler filter) - { - if (_filterState is not { } filterState) - { - throw new InvalidOperationException("Filter feature not initialised."); - } - - List? filteredNewItems = null; - int filteredNewItemsStartingIndex = -1; - - List? filteredOldItems = null; - int filteredOldItemsStartingIndex = -1; - - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - Debug.Assert(e.NewItems != null); - - for (var i = 0; i < e.NewItems.Count; i++) - { - var sourceIndex = e.NewStartingIndex + i; - if (ItemPassesFilter(e.NewItems[i], filter)) - { - if (filteredNewItems == null) - { - filteredNewItems = new(e.NewItems.Count); - filteredNewItemsStartingIndex = FilteredStartingIndex(e.NewStartingIndex); - } - - var filteredItemIndex = filteredNewItemsStartingIndex + filteredNewItems.Count; - filterState.items.Insert(filteredItemIndex, e.NewItems[i]); - - ShiftIndexMap(sourceIndex, 1); - filterState.indexMap.Insert(sourceIndex, filteredItemIndex); - - filteredNewItems.Add(e.NewItems[i]); - } - else - { - filterState.indexMap.Insert(sourceIndex, -1); - } - } - s_rewrittenCollectionChangedEvents.Value.Add(e, filteredNewItems == null ? null : new(NotifyCollectionChangedAction.Add, filteredNewItems, filteredNewItemsStartingIndex)); - break; - case NotifyCollectionChangedAction.Remove: - Debug.Assert(e.OldItems != null); - for (var i = 0; i < e.OldItems.Count; i++) - { - var sourceIndex = e.OldStartingIndex + i; - if (filterState.indexMap[sourceIndex] != -1) - { - if (filteredOldItems == null) - { - filteredOldItems = new(e.OldItems.Count); - filteredOldItemsStartingIndex = FilteredStartingIndex(e.OldStartingIndex); - } - - filterState.items.RemoveAt(filterState.indexMap[sourceIndex]); - filteredOldItems.Add(e.OldItems[i]); - } - ShiftIndexMap(sourceIndex, -1); - filterState.indexMap.RemoveAt(sourceIndex); - } - s_rewrittenCollectionChangedEvents.Value.Add(e, filteredOldItems == null ? null : new(NotifyCollectionChangedAction.Remove, filteredOldItems, filteredOldItemsStartingIndex)); - break; - case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); - case NotifyCollectionChangedAction.Reset: - Refresh(); - s_rewrittenCollectionChangedEvents.Value.Add(e, null); - break; - case NotifyCollectionChangedAction.Move: - throw new NotImplementedException(); - default: - throw new NotImplementedException(); - } - } - - private void ShiftIndexMap(int inclusiveStart, int delta) - { - var map = _filterState!.Value.indexMap; - for (var i = 0; i < map.Count; i++) - { - if (map[i] >= inclusiveStart) - { - map[i] += delta; - } - } - } - - private int FilteredStartingIndex(int sourceIndex) - { - if (_filterState is not { } filterState) - { - throw new InvalidOperationException("Filter feature not initialised."); - } - - var candidateIndex = sourceIndex; - var insertAtEnd = candidateIndex >= filterState.indexMap.Count; - - if (insertAtEnd) - { - candidateIndex = filterState.indexMap.Count - 1; - } - - int filteredIndex; - do - { - if (candidateIndex == -1) - { - return 0; - } - - filteredIndex = filterState.indexMap[candidateIndex--]; - } - while (filteredIndex < 0); - - return filteredIndex + (insertAtEnd ? 1 : 0); - } -} diff --git a/src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs b/src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs new file mode 100644 index 0000000000..bd5755b5d0 --- /dev/null +++ b/src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; +using Avalonia.Utilities; + +namespace Avalonia.Controls; + +public partial class ItemsSourceView : IWeakEventSubscriber +{ + private static readonly Lazy> s_rewrittenCollectionChangedEvents = new(); + + private static NotifyCollectionChangedEventArgs? GetRewrittenEvent(NotifyCollectionChangedEventArgs e) + { + if (s_rewrittenCollectionChangedEvents.IsValueCreated && s_rewrittenCollectionChangedEvents.Value.TryGetValue(e, out var rewritten)) + { + return rewritten; + } + + return e; + } + + private (List items, List indexMap, HashSet invalidationProperties)? _layersState; + + internal static int[]? GetDiagnosticItemMap(ItemsSourceView itemsSourceView) => itemsSourceView._layersState?.indexMap.ToArray(); + + public AvaloniaList Filters { get; } + + public AvaloniaList Sorters { get; } + + protected bool HasLayers => Filters.Count + Sorters.Count > 0; + + private static void ValidateLayer(ItemsSourceViewLayer layer) + { + if (layer == null) + { + throw new InvalidOperationException($"Cannot add null to this collection."); + } + } + + private void OnLayersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + var owner = _owner.Target as AvaloniaObject; + + if (e.OldItems != null) + { + for (int i = 0; i < e.OldItems.Count; i++) + { + var layer = (ItemsSourceViewLayer)e.OldItems[i]!; + layer.Invalidated -= OnLayerInvalidated; + if (owner != null) + { + layer.Detach(owner); + } + } + } + + if (e.NewItems != null) + { + for (int i = 0; i < e.NewItems.Count; i++) + { + var layer = (ItemsSourceViewLayer)e.NewItems[i]!; + layer.Invalidated += OnLayerInvalidated; + if (owner != null) + { + layer.Attach(owner); + } + } + } + + Refresh(); + } + + private void OnLayerInvalidated(object? sender, EventArgs e) + { + Refresh(); + } + + public void Refresh() + { + if (_isOwnerUnloaded || (_layersState == null && !HasLayers)) + { + return; + } + + if (!HasLayers) + { + RemoveListenerIfNecessary(); + _layersState = null; + } + else + { + AddListenerIfNecessary(); + _layersState = EvaluateLayers(Filters, Sorters, CancellationToken.None); + } + + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); + } + + /// + /// Re-evaluates and asynchronously, and applies results in one operation after processing completes. + /// + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + if (_layersState == null && !HasLayers) + { + return; + } + + if (!HasLayers) + { + RemoveListenerIfNecessary(); + _layersState = null; + } + else + { + AddListenerIfNecessary(); + var filtersCopy = new ItemFilter[Filters.Count]; + Filters.CopyTo(filtersCopy, 0); + var sortersCopy = new ItemSorter[Sorters.Count]; + Sorters.CopyTo(sortersCopy, 0); + _layersState = await Task.Run(() => EvaluateLayers(filtersCopy, sortersCopy, cancellationToken), cancellationToken); + } + + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Reset)); + } + + private (List items, List indexMap, HashSet invalidationProperties) EvaluateLayers(IList filters, IList sorters, CancellationToken cancellationToken) + { + var result = new List(); + var map = new List(); + var invalidationProperties = new HashSet(); + + for (int i = 0; i < filters.Count; i++) + { + invalidationProperties.UnionWith(filters[i].GetInvalidationPropertyNamesEnumerator()); + } + for (int i = 0; i < sorters.Count; i++) + { + invalidationProperties.UnionWith(sorters[i].GetInvalidationPropertyNamesEnumerator()); + } + + for (int i = 0; i < _source.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (invalidationProperties.Count > 0 && _source[i] is INotifyPropertyChanged inpc) + { + WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this); + WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this); + } + + if (ItemPassesFilters(filters, _source[i])) + { + map.Add(result.Count); + result.Add(_source[i]); + } + else + { + map.Add(-1); + } + } + return (result, map, invalidationProperties); + } + + private bool ItemPassesFilters(IList itemFilters, object? item) + { + for (int i = 0; i < itemFilters.Count; i++) + { + if (!itemFilters[i].FilterItem(item)) + { + return false; + } + } + return true; + } + + void IWeakEventSubscriber.OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e) + { + if (sender is not INotifyPropertyChanged inpc + || _layersState is not { } layersState + || e.PropertyName is not { } propertyName + || !layersState.invalidationProperties.Contains(propertyName)) + { + return; + } + + bool? passes = null; + + for (int sourceIndex = 0; sourceIndex < Source.Count; sourceIndex++) + { + if (Source[sourceIndex] != sender) + { + continue; + } + + // If a collection is reset, we aren't able to unsubscribe from stale items. So we can sometimes receive + // this event from items which are no longer in the collection. Don't execute the filter until we are sure + // that the item is still present. + passes ??= ItemPassesFilters(Filters, sender); + + switch ((layersState.indexMap[sourceIndex], passes)) + { + case (-1, true): + { + var viewIndex = ViewStartingIndex(sourceIndex); + + layersState.indexMap[sourceIndex] = viewIndex; + ShiftIndexMap(sourceIndex + 1, 1); + + layersState.items.Insert(viewIndex, sender); + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Add, sender, viewIndex)); + } + break; + case (int viewIndex, false): + layersState.indexMap[sourceIndex] = -1; + ShiftIndexMap(sourceIndex + 1, -1); + layersState.items.RemoveAt(viewIndex); + RaiseCollectionChanged(new(NotifyCollectionChangedAction.Remove, sender, viewIndex)); + break; + } + } + + if (passes == null) // item is no longer in the collection, we can unsubscribe + { + WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this); + } + } + + private void UpdateLayersForCollectionChangedEvent(NotifyCollectionChangedEventArgs e) + { + if (_layersState is not { } layersState) + { + throw new InvalidOperationException("Layers not initialised."); + } + + List? viewItems = null; + int viewStartIndex = -1; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + + for (var i = 0; i < e.NewItems.Count; i++) + { + var sourceIndex = e.NewStartingIndex + i; + if (ItemPassesFilters(Filters, e.NewItems[i])) + { + if (viewItems == null) + { + viewItems = new(e.NewItems.Count); + viewStartIndex = ViewStartingIndex(e.NewStartingIndex); + } + + var viewIndex = viewStartIndex + viewItems.Count; + layersState.items.Insert(viewIndex, e.NewItems[i]); + + layersState.indexMap.Insert(sourceIndex, viewIndex); + + viewItems.Add(e.NewItems[i]); + } + else + { + layersState.indexMap.Insert(sourceIndex, -1); + } + + if (layersState.invalidationProperties.Count > 0 && e.NewItems[i] is INotifyPropertyChanged inpc) + WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this); + } + + if (viewItems != null) + { + ShiftIndexMap(e.NewStartingIndex + e.NewItems.Count, viewItems.Count); + } + + s_rewrittenCollectionChangedEvents.Value.Add(e, viewItems == null ? null : new(NotifyCollectionChangedAction.Add, viewItems, viewStartIndex)); + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + for (var i = 0; i < e.OldItems.Count; i++) + { + var sourceIndex = e.OldStartingIndex + i; + if (layersState.indexMap[sourceIndex] != -1) + { + if (viewItems == null) + { + viewItems = new(e.OldItems.Count); + viewStartIndex = ViewStartingIndex(sourceIndex); + } + + layersState.items.RemoveAt(layersState.indexMap[sourceIndex]); + viewItems.Add(e.OldItems[i]); + } + + layersState.indexMap.RemoveAt(sourceIndex); + + if (layersState.invalidationProperties.Count > 0 && e.OldItems[i] is INotifyPropertyChanged inpc) + WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this); + } + + if (viewItems != null) + { + ShiftIndexMap(e.OldStartingIndex - e.OldItems.Count, -viewItems.Count); + } + + s_rewrittenCollectionChangedEvents.Value.Add(e, viewItems == null ? null : new(NotifyCollectionChangedAction.Remove, viewItems, viewStartIndex)); + break; + case NotifyCollectionChangedAction.Replace: + throw new NotImplementedException(); + case NotifyCollectionChangedAction.Reset: + Refresh(); + s_rewrittenCollectionChangedEvents.Value.Add(e, null); + break; + case NotifyCollectionChangedAction.Move: + throw new NotImplementedException(); + default: + throw new NotImplementedException(); + } + } + + private void ShiftIndexMap(int inclusiveSourceStartIndex, int delta) + { + var map = _layersState!.Value.indexMap; + for (var i = inclusiveSourceStartIndex; i < map.Count; i++) + { + if (map[i] != -1) + { + map[i] += delta; + } + } + } + + private int ViewStartingIndex(int sourceStartingIndex) + { + if (_layersState is not { } layersState) + { + throw new InvalidOperationException("Layers not initialised."); + } + + var candidateIndex = sourceStartingIndex; + var insertAtEnd = candidateIndex >= layersState.indexMap.Count; + + if (insertAtEnd) + { + candidateIndex = layersState.indexMap.Count - 1; + } + + int filteredIndex; + do + { + if (candidateIndex == -1) + { + return 0; + } + + filteredIndex = layersState.indexMap[candidateIndex--]; + } + while (filteredIndex < 0); + + return filteredIndex + (insertAtEnd ? 1 : 0); + } +} diff --git a/src/Avalonia.Controls/ItemsSourceView.cs b/src/Avalonia.Controls/ItemsSourceView.cs index d433cdc1d6..8bf9f529e9 100644 --- a/src/Avalonia.Controls/ItemsSourceView.cs +++ b/src/Avalonia.Controls/ItemsSourceView.cs @@ -10,7 +10,10 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.InteropServices; using Avalonia.Controls.Utils; +using Avalonia.Interactivity; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -27,15 +30,26 @@ namespace Avalonia.Controls /// /// Gets an empty /// - public static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty()); + public static ItemsSourceView Empty { get; } = new ItemsSourceView(null, Array.Empty()); /// /// Gets an instance representing an uninitialized source. /// [SuppressMessage("Performance", "CA1825:Avoid zero-length array allocations", Justification = "This is a sentinel value and must be unique.")] [SuppressMessage("ReSharper", "UseCollectionExpression", Justification = "This is a sentinel value and must be unique.")] + [SuppressMessage("Style", "IDE0300:Simplify collection initialization", Justification = "This is a sentinel value and must be unique.")] internal static object?[] UninitializedSource { get; } = new object?[0]; + internal AvaloniaObject? Owner => (AvaloniaObject?)_owner.Target; + + private GCHandle _owner; + /// + /// If the owner is a control, we don't refresh until it has loaded. This avoids the critical scenario + /// of non-nullable references to named XAML objects being null because InitializeComponent is still executing. + /// + private bool _isOwnerUnloaded; + private TargetWeakEventSubscriber? _loadedWeakSubscriber; + private IList _source; private NotifyCollectionChangedEventHandler? _collectionChanged; private NotifyCollectionChangedEventHandler? _preCollectionChanged; @@ -43,13 +57,48 @@ namespace Avalonia.Controls private PropertyChangedEventHandler? _propertyChanged; private bool _listening; - private IList InternalSource => _filterState?.items ?? _source; + private IList InternalSource => _layersState?.items ?? _source; + + private static readonly WeakEvent s_loadedWeakEvent = + WeakEvent.Register((s, h) => s.Loaded += h, (s, h) => s.Loaded -= h); /// /// Initializes a new instance of the ItemsSourceView class for the specified data source. /// + /// The for which this is being created. /// The data source. - private protected ItemsSourceView(IEnumerable source) => SetSource(source); + private protected ItemsSourceView(AvaloniaObject? owner, IEnumerable source) + { + _owner = GCHandle.Alloc(owner, GCHandleType.Weak); + + Filters = new() { Validate = ValidateLayer }; + Filters.CollectionChanged += OnLayersChanged; + Sorters = new() { Validate = ValidateLayer }; + Sorters.CollectionChanged += OnLayersChanged; + + SetSource(source); + + if (owner is Control ownerControl) + { + _isOwnerUnloaded = true; + _loadedWeakSubscriber = new(this, static (view, sender, _, _) => view.OnOwnerLoaded((Control)sender!)); + s_loadedWeakEvent.Subscribe(ownerControl, _loadedWeakSubscriber); + } + } + + private void OnOwnerLoaded(Control owner) + { + _isOwnerUnloaded = false; + s_loadedWeakEvent.Unsubscribe(owner, _loadedWeakSubscriber!); + _loadedWeakSubscriber = null; + + Refresh(); + } + + ~ItemsSourceView() + { + _owner.Free(); + } /// /// Gets the number of items in the collection. @@ -184,7 +233,7 @@ namespace Avalonia.Controls { ItemsSourceView isv => isv, null => Empty, - _ => new ItemsSourceView(items) + _ => new ItemsSourceView(null, items) }; } @@ -205,7 +254,7 @@ namespace Avalonia.Controls { ItemsSourceView isvt => isvt, null => ItemsSourceView.Empty, - _ => new ItemsSourceView(items) + _ => new ItemsSourceView(null, items) }; } @@ -226,7 +275,7 @@ namespace Avalonia.Controls { ItemsSourceView isv => isv, null => ItemsSourceView.Empty, - _ => new ItemsSourceView(items) + _ => new ItemsSourceView(null, items) }; } @@ -251,16 +300,16 @@ namespace Avalonia.Controls void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e) { - if (Filter is { } filter) + if (HasLayers) { - FilterCollectionChangedEvent(e, filter); + UpdateLayersForCollectionChangedEvent(e); } if (GetRewrittenEvent(e) is not { } rewritten) { return; } - + _preCollectionChanged?.Invoke(this, rewritten); } @@ -323,6 +372,8 @@ namespace Avalonia.Controls _ => new List(source.Cast()) }; + Refresh(); + if (_listening && _source is INotifyCollectionChanged inccNew) CollectionChangedEventManager.Instance.AddListener(inccNew, this); } @@ -339,12 +390,12 @@ namespace Avalonia.Controls private void RemoveListenerIfNecessary() { - if (_listening && _collectionChanged is null && _postCollectionChanged is null && Filter == null) + if (_listening && _collectionChanged is null && _postCollectionChanged is null && Filters.Count == 0) { if (_source is INotifyCollectionChanged incc) CollectionChangedEventManager.Instance.RemoveListener(incc, this); _listening = false; - } + } } [DoesNotReturn] @@ -356,19 +407,17 @@ namespace Avalonia.Controls /// /// Gets an empty /// - public new static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty()); + public new static ItemsSourceView Empty { get; } = new ItemsSourceView(null, Array.Empty()); - /// - /// Initializes a new instance of the ItemsSourceView class for the specified data source. - /// - /// The data source. - internal ItemsSourceView(IEnumerable source) - : base(source) + /// + internal ItemsSourceView(AvaloniaObject? owner, IEnumerable source) + : base(owner, source) { } - internal ItemsSourceView(IEnumerable source) - : base(source) + /// + internal ItemsSourceView(AvaloniaObject? owner, IEnumerable source) + : base(owner, source) { } diff --git a/src/Avalonia.Controls/ItemsSourceViewLayer.cs b/src/Avalonia.Controls/ItemsSourceViewLayer.cs new file mode 100644 index 0000000000..31152db674 --- /dev/null +++ b/src/Avalonia.Controls/ItemsSourceViewLayer.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Metadata; + +namespace Avalonia.Controls; + +public abstract class ItemsSourceViewLayer : AvaloniaObject, INamed +{ + private InvalidationPropertiesCollection? _invalidationPropertyNames; + private object? _state; + + public string? Name { get; init; } + + /// + /// Gets the owner of this layer. This is the owner of the that to which this layer + /// has been added. + /// + public AvaloniaObject? Owner => InheritanceParent; + + /// + /// Gets or sets a collection of strings which will trigger re-evaluation of an item, if: + /// + /// The item implements ; and + /// The item raises ; and + /// The value of the property is found in this collection. + /// + /// + /// + /// Performance warning: if any strings are added to this collection, the event will be subscribed to on ALL + /// items in . This can lead to a large number of allocations. + /// + public InvalidationPropertiesCollection InvalidationPropertyNames + { + get => _invalidationPropertyNames ??= new(); + init + { + _invalidationPropertyNames = value; + OnInvalidated(); + } + } + + /// + /// Raised when this layer should be re-evaluated for all items in the view. + /// + public event EventHandler? Invalidated; + + internal protected IEnumerable GetInvalidationPropertyNamesEnumerator() => _invalidationPropertyNames ?? Enumerable.Empty(); + + /// + /// Gets or sets an abitrary object. When the value of this property changes, is raised. + /// + public object? State + { + get => _state; + set => SetAndRaise(StateProperty, ref _state, value); + } + + /// + public static readonly DirectProperty StateProperty = + AvaloniaProperty.RegisterDirect(nameof(State), o => o.State, (o, v) => o.State = v); + + /// + /// Raises the event. + /// + protected virtual void OnInvalidated() + { + Invalidated?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == StateProperty) + { + OnInvalidated(); + } + } + + internal void Attach(AvaloniaObject anchor) + { + if (InheritanceParent != null && InheritanceParent != anchor) + { + throw new InvalidOperationException("This view layer is already attached to another object."); + } + + InheritanceParent = anchor; + } + + internal void Detach(AvaloniaObject anchor) + { + if (InheritanceParent != anchor) + { + throw new ArgumentException("Not attached to this object", nameof(anchor)); + } + InheritanceParent = null; + } +} + +[AvaloniaList(Separators = new[] { " ", "," }, SplitOptions = StringSplitOptions.RemoveEmptyEntries)] +public class InvalidationPropertiesCollection : AvaloniaList +{ + // Don't validate items: the PropertyChanged event can be raised with any "PropertyName" string. +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlLanguageParseIntrinsics.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlLanguageParseIntrinsics.cs index 69cdec68c8..aedda456c5 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlLanguageParseIntrinsics.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlLanguageParseIntrinsics.cs @@ -390,7 +390,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions } } - items = text.Split(separators, splitOptions ^ trimOption); + items = text.Split(separators, splitOptions & ~trimOption); // Compiler targets netstandard, so we need to emulate StringSplitOptions.TrimEntries, if it was requested. if (splitOptions.HasFlag(trimOption)) { diff --git a/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs index 99acd13a62..001f140d9d 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs @@ -70,7 +70,7 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Filtered_View_Adds_New_Items() - { + { var source = new AvaloniaList() { "foo", "bar" }; var target = ItemsSourceView.GetOrCreate(source); @@ -78,7 +78,7 @@ namespace Avalonia.Controls.UnitTests target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); - target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); + target.Filters.Add(new FunctionItemFilter { Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item) }); Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); @@ -88,8 +88,12 @@ namespace Avalonia.Controls.UnitTests Assert.Empty(collectionChangeEvents); + Assert.Equal(new int[] { 0, 1, -1 }, ItemsSourceView.GetDiagnosticItemMap(target)); + source.InsertRange(1, new[] { bool.TrueString, bool.TrueString }); + Assert.Equal(new int[] { 0, 1, 2, 3, -1 }, ItemsSourceView.GetDiagnosticItemMap(target)); + Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Add, collectionChangeEvents[0].Action); Assert.Equal(1, collectionChangeEvents[0].NewStartingIndex); @@ -102,6 +106,8 @@ namespace Avalonia.Controls.UnitTests source.Add(bool.TrueString); + Assert.Equal(new int[] { 0, 1, 2, 3, -1, 4 }, ItemsSourceView.GetDiagnosticItemMap(target)); + Assert.Equal(5, target.Count); Assert.Equal(bool.TrueString, target[^1]); } @@ -116,15 +122,18 @@ namespace Avalonia.Controls.UnitTests target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); - target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); - + target.Filters.Add(new FunctionItemFilter { Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item) }); + Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); collectionChangeEvents.Clear(); + Assert.Equal(new int[] { 0, 1, 2, -1, 3, 4 }, ItemsSourceView.GetDiagnosticItemMap(target)); + source.RemoveAt(4); Assert.Equal(4, target.Count); + Assert.Equal(new int[] { 0, 1, 2, -1, 3 }, ItemsSourceView.GetDiagnosticItemMap(target)); Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Remove, collectionChangeEvents[0].Action); @@ -136,6 +145,8 @@ namespace Avalonia.Controls.UnitTests source.RemoveAt(3); Assert.Empty(collectionChangeEvents); Assert.Equal(4, target.Count); + + Assert.Equal(new int[] { 0, 1, 2, 3 }, ItemsSourceView.GetDiagnosticItemMap(target)); } [Fact] @@ -148,7 +159,7 @@ namespace Avalonia.Controls.UnitTests target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e); - target.Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item); + target.Filters.Add(new FunctionItemFilter { Filter = (s, e) => e.Accept = !Equals(bool.FalseString, e.Item) }); Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); @@ -156,6 +167,8 @@ namespace Avalonia.Controls.UnitTests source.Clear(); + Assert.Equal(0, target.Count); + Assert.Equal(Array.Empty(), ItemsSourceView.GetDiagnosticItemMap(target)); Assert.Equal(1, collectionChangeEvents.Count); Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action); } @@ -178,7 +191,7 @@ namespace Avalonia.Controls.UnitTests private class ReassignableItemsSourceView : ItemsSourceView { public ReassignableItemsSourceView(IEnumerable source) - : base(source) + : base(null, source) { } From c87a4af60b633090e338d1a71c7a49ec68f255cf Mon Sep 17 00:00:00 2001 From: Tom Edwards Date: Sun, 18 Feb 2024 19:21:43 +0100 Subject: [PATCH 04/10] Added sorting and ComparableSorter Added ItemSourceViewLayer.IsActive --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 15 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 5 +- src/Avalonia.Controls/ItemSorter.cs | 142 +++++++++- .../ItemsSourceView.LayerProcessing.cs | 253 +++++++++++++++--- src/Avalonia.Controls/ItemsSourceView.cs | 2 +- src/Avalonia.Controls/ItemsSourceViewLayer.cs | 18 ++ 6 files changed, 383 insertions(+), 52 deletions(-) diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index edc2cd4c97..44c625bf40 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -17,7 +17,7 @@