Tom Edwards 1 day ago
committed by GitHub
parent
commit
6db94d2fc3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 30
      samples/ControlCatalog/Pages/ListBoxPage.xaml
  2. 18
      samples/ControlCatalog/Pages/ListBoxPage.xaml.cs
  3. 17
      samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs
  4. 2
      src/Avalonia.Controls/Flyouts/MenuFlyout.cs
  5. 19
      src/Avalonia.Controls/ItemCollection.cs
  6. 127
      src/Avalonia.Controls/ItemFilter.cs
  7. 226
      src/Avalonia.Controls/ItemSorter.cs
  8. 20
      src/Avalonia.Controls/ItemsControl.cs
  9. 807
      src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs
  10. 199
      src/Avalonia.Controls/ItemsSourceView.cs
  11. 148
      src/Avalonia.Controls/ItemsSourceViewLayer.cs
  12. 10
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  13. 50
      src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs
  14. 2
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlLanguageParseIntrinsics.cs
  15. 345
      tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs
  16. 2
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

30
samples/ControlCatalog/Pages/ListBoxPage.xaml

@ -17,7 +17,7 @@
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="ListBox ListBoxItem:nth-last-child(5n+4)">
<Setter Property="Background" Value="Blue" />
<Setter Property="Background" Value="Beige" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="VirtualizingStackPanel">
@ -34,17 +34,39 @@
<CheckBox IsChecked="{Binding AlwaysSelected}">AlwaysSelected</CheckBox>
<CheckBox IsChecked="{Binding AutoScrollToSelectedItem}">AutoScrollToSelectedItem</CheckBox>
<CheckBox IsChecked="{Binding WrapSelection}">WrapSelection</CheckBox>
<Separator/>
<TextBox Name="SearchBox" Watermark="Search for a number"/>
<CheckBox Name="InvertSort">Sort Descending</CheckBox>
<CheckBox Name="FavToTop">Sort Favorites to top</CheckBox>
</StackPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="4">
<Button Command="{Binding AddItemCommand}">Add</Button>
<Button Command="{Binding RemoveItemCommand}">Remove</Button>
<Button Command="{Binding SelectRandomItemCommand}">Select Random Item</Button>
</StackPanel>
<ListBox ItemsSource="{Binding Items}"
<ListBox Name="ListBox"
ItemsSource="{Binding Items}"
Selection="{Binding Selection}"
DisplayMemberBinding="{Binding (viewModels:ItemModel).ID, StringFormat='{}Item {0:N0}'}"
AutoScrollToSelectedItem="{Binding AutoScrollToSelectedItem}"
SelectionMode="{Binding SelectionMode^}"
WrapSelection="{Binding WrapSelection}"/>
WrapSelection="{Binding WrapSelection}">
<ListBox.Filters>
<FunctionItemFilter Filter="FilterItem" State="{Binding Text, ElementName=SearchBox}" InvalidationPropertyNames="IsFavorite"/>
</ListBox.Filters>
<ListBox.Sorters>
<ComparableSorter ComparableSelector="SelectItemIsFavorite" SortDirection="Descending" IsActive="{Binding IsChecked, ElementName=FavToTop}" InvalidationPropertyNames="IsFavorite" />
<ComparableSorter ComparableSelector="SelectItemId" SortDirection="{Binding IsChecked, ElementName=InvertSort, Converter={x:Static ItemSorter.BooleanToDescendingSortConverter}}" />
</ListBox.Sorters>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="viewModels:ItemModel">
<Grid ColumnDefinitions="*,Auto">
<TextBlock VerticalAlignment="Center">
Item <Run Text="{Binding ID, StringFormat=n0, Mode=OneWay}"/>
</TextBlock>
<ToggleButton Grid.Column="1" IsChecked="{Binding IsFavorite}" Content="Favorite"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</UserControl>

18
samples/ControlCatalog/Pages/ListBoxPage.xaml.cs

@ -7,8 +7,24 @@ namespace ControlCatalog.Pages
{
public ListBoxPage()
{
InitializeComponent();
DataContext = new ListBoxPageViewModel();
InitializeComponent();
}
private void FilterItem(object? sender, FunctionItemFilter.FilterEventArgs e)
{
if (e.FilterState is not string { Length: > 0 } searchText)
{
e.Accept = true;
}
else
{
var item = (ItemModel)e.Item!;
e.Accept = item.IsFavorite || item.ID.ToString().Contains(searchText);
}
}
private void SelectItemId(object? sender, ComparableSorter.ComparableSelectEventArgs e) => e.Comparable = ((ItemModel)e.Item!).ID;
private void SelectItemIsFavorite(object? sender, ComparableSorter.ComparableSelectEventArgs e) => e.Comparable = ((ItemModel)e.Item!).IsFavorite;
}
}

17
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
/// <summary>
/// An Item model for the <see cref="ListBoxPage"/>
/// </summary>
public class ItemModel
public class ItemModel : INotifyPropertyChanged
{
private bool _isFavorite;
/// <summary>
/// Creates a new ItemModel with the given ID
/// </summary>
@ -124,6 +127,18 @@ namespace ControlCatalog.ViewModels
/// </summary>
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}";

2
src/Avalonia.Controls/Flyouts/MenuFlyout.cs

@ -11,7 +11,7 @@ namespace Avalonia.Controls
{
public MenuFlyout()
{
Items = new ItemCollection();
Items = new ItemCollection(this);
}
/// <summary>

19
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)
{
}
@ -26,8 +26,6 @@ namespace Avalonia.Controls
public bool IsReadOnly => _mode == Mode.ItemsSource;
internal event EventHandler? SourceChanged;
/// <summary>
/// Adds an item to the <see cref="ItemsControl"/>.
/// </summary>
@ -111,19 +109,6 @@ namespace Avalonia.Controls
SetSource(value ?? CreateDefaultCollection());
}
private new void SetSource(IEnumerable source)
{
var oldSource = Source;
base.SetSource(source);
if (oldSource.Count > 0)
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Remove, oldSource, 0));
if (Source.Count > 0)
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Add, Source, 0));
SourceChanged?.Invoke(this, EventArgs.Empty);
}
private static AvaloniaList<object?> CreateDefaultCollection()
{
return new() { ResetBehavior = ResetBehavior.Remove };

127
src/Avalonia.Controls/ItemFilter.cs

@ -0,0 +1,127 @@
using System;
using Avalonia.Logging;
namespace Avalonia.Controls;
/// <summary>
/// An <see cref="ItemsSourceView"/> layer which can exclude items of the source collection from the transformed view.
/// </summary>
public abstract class ItemFilter : ItemsSourceViewLayer
{
/// <summary>
/// Determines whether an item passes this filter.
/// </summary>
/// <returns>True if the item passes the filter, otherwise false.</returns>
public abstract bool FilterItem(object? item);
}
/// <summary>
/// Excludes items from an <see cref="ItemsSourceView"/> as determined by the delegate currently assigned to its <see cref="Filter"/> property.
/// </summary>
public class FunctionItemFilter : ItemFilter
{
private EventHandler<FilterEventArgs>? _filter;
private FilterEventArgs? _batchArgs;
/// <summary>
/// Gets or sets a method which determines whether an item passes this filter.
/// </summary>
/// <remarks>
/// If a multicast delegate is assigned, all invocations must accept the item in order for it to pass the filter.
/// </remarks>
public EventHandler<FilterEventArgs>? Filter
{
get => _filter;
set => SetAndRaise(FilterProperty, ref _filter, value);
}
/// <seealso cref="Filter"/>
public static readonly DirectProperty<FunctionItemFilter, EventHandler<FilterEventArgs>?> FilterProperty =
AvaloniaProperty.RegisterDirect<FunctionItemFilter, EventHandler<FilterEventArgs>?>(nameof(Filter), o => o.Filter, (o, v) => o.Filter = v);
public FunctionItemFilter() { }
public FunctionItemFilter(Func<object?, bool> filterFunc)
{
Filter = (s, e) => e.Accept = filterFunc(e.Item);
}
public override bool FilterItem(object? item)
{
if (Filter == null)
{
return true;
}
var args = _batchArgs ?? new() { FilterState = State };
args.ResetFor(item);
var handlers = Filter.GetInvocationList();
for (var i = 0; i < handlers.Length; i++)
{
var method = (EventHandler<FilterEventArgs>)handlers[i];
method(this, args);
if (!args.Accept)
{
return false;
}
}
return true;
}
protected internal override void BeginBatchOperation()
{
if (_batchArgs != null)
{
throw new InvalidOperationException("Already refreshing.");
}
_batchArgs = new() { FilterState = State };
}
protected internal override void EndBatchOperation() => _batchArgs = null;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == FilterProperty)
{
OnInvalidated();
}
else if (change.Property == StateProperty)
{
if (_batchArgs != null)
Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log(this, "State changed during batch operation!");
}
}
public class FilterEventArgs : EventArgs
{
private object? _item;
/// <summary>
/// Gets the item being filtered.
/// </summary>
public object? Item { get => _item; init => _item = value; }
/// <summary>
/// Gets the object retrieved from <see cref="ItemsSourceViewLayer.State"/> when the event was raised, or null.
/// </summary>
public object? FilterState { get; init; }
/// <summary>
/// Gets or sets whether <see cref="Item"/> should pass the filter.
/// </summary>
public bool Accept { get; set; } = true;
protected internal void ResetFor(object? item)
{
_item = item;
Accept = true;
}
}
}

226
src/Avalonia.Controls/ItemSorter.cs

@ -0,0 +1,226 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Logging;
namespace Avalonia.Controls;
/// <summary>
/// An <see cref="ItemsSourceView"/> layer which can re-order items of the source collection within the transformed view.
/// </summary>
public abstract class ItemSorter : ItemsSourceViewLayer, IComparer<object?>, IComparer
{
private ListSortDirection _sortDirection;
/// <summary>
/// Gets or sets a value that indicates whether to sort in ascending (1, 2, 3...) or descending (3, 2, 1...) order.
/// </summary>
public ListSortDirection SortDirection
{
get => _sortDirection;
set => SetAndRaise(SortDirectionProperty, ref _sortDirection, value);
}
/// <seealso cref="SortDirection"/>
public static readonly DirectProperty<ItemSorter, ListSortDirection> SortDirectionProperty =
AvaloniaProperty.RegisterDirect<ItemSorter, ListSortDirection>(nameof(SortDirection), o => o.SortDirection, (o, v) => o.SortDirection = v);
/// <summary>
/// Compares two objects to determine their sort order.
/// </summary>
/// <returns>
/// <list type="table">
/// <item><term>A negative value</term> <description>If <paramref name="x"/> should come before <paramref name="y"/></description></item>
/// <item><term>0</term> <description>If <paramref name="x"/> and <paramref name="y"/> have the same precedence</description></item>
/// <item><term>A positive value</term> <description>If <paramref name="x"/> should come after <paramref name="y"/></description></item>
/// </list>
/// </returns>
public abstract int Compare(object? x, object? y);
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SortDirectionProperty)
{
OnInvalidated();
}
}
/// <summary>
/// Gets an <see cref="IValueConverter"/> which converts <c>true</c> to <see cref="ListSortDirection.Descending"/> and <c>false</c> to <see cref="ListSortDirection.Ascending"/>.
/// </summary>
public static IValueConverter BooleanToDescendingSortConverter { get; } = new BooleanToDescendingSortConverter_();
private class BooleanToDescendingSortConverter_ : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
{
true => ListSortDirection.Descending,
false => ListSortDirection.Ascending,
_ => AvaloniaProperty.UnsetValue,
};
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
{
ListSortDirection.Descending => true,
ListSortDirection.Ascending => false,
_ => AvaloniaProperty.UnsetValue,
};
}
}
/// <summary>
/// Sorts items in an <see cref="ItemsSourceView"/> according to the order provided by passing an <see cref="IComparable"/>
/// object selected for each item to an <see cref="IComparer"/> object.
/// </summary>
/// <remarks>
/// If any item passed to <see cref="Compare(object?, object?)"/> is not an <see cref="IComparable"/>, and no
/// <see cref="ComparableSelector"/> value has been provided, an <see cref="InvalidCastException"/> will be thrown.
/// </remarks>
public class ComparableSorter : ItemSorter
{
private EventHandler<ComparableSelectEventArgs>? _comparableSelector;
private IComparer? _comparer;
private ComparableSelectEventArgs? _batchArgs;
private Dictionary<object, IComparable?>? _batchComparableCache;
private static readonly object s_batchNullItemKey = new();
/// <summary>
/// Gets or sets a delegate that will be executed twice each time a comparison is made, to select
/// an <see cref="IComparable"/> for the two items being compared.
/// </summary>
/// <remarks>
/// If no value is provided, the item itself will be used. In this case, an <see cref="InvalidCastException"/> will be thrown if the item does not implement <see cref="IComparable"/>.
/// </remarks>
public EventHandler<ComparableSelectEventArgs>? ComparableSelector
{
get => _comparableSelector;
set => SetAndRaise(ComparableSelectorProperty, ref _comparableSelector, value);
}
/// <seealso cref="ComparableSelector"/>
public static readonly DirectProperty<ComparableSorter, EventHandler<ComparableSelectEventArgs>?> ComparableSelectorProperty =
AvaloniaProperty.RegisterDirect<ComparableSorter, EventHandler<ComparableSelectEventArgs>?>(nameof(ComparableSelector), o => o.ComparableSelector, (o, v) => o.ComparableSelector = v);
/// <summary>
/// Gets or sets an <see cref="IComparable"/> to use to compare items.
/// </summary>
/// <remarks>
/// If this value is null, <see cref="Comparer.Default"/> will be used.
/// </remarks>
public IComparer? Comparer
{
get => _comparer;
set => SetAndRaise(ComparerProperty, ref _comparer, value);
}
/// <seealso cref="Comparer"/>
public static readonly DirectProperty<ComparableSorter, IComparer?> ComparerProperty =
AvaloniaProperty.RegisterDirect<ComparableSorter, IComparer?>(nameof(Comparer), o => o.Comparer, (o, v) => o.Comparer = v);
public ComparableSorter() { }
public ComparableSorter(Func<object?, IComparable> comparableSelector)
{
ComparableSelector = (s, e) => e.Comparable = comparableSelector(e.Item);
}
/// <inheritdoc cref="ItemSorter.Compare(object?, object?)"/>
/// <exception cref="InvalidCastException">Thrown if <paramref name="x"/> or <paramref name="y"/> cannot be converted to <see cref="IComparable"/>.</exception>
public override int Compare(object? x, object? y)
{
var stateCopy = State; // ensure that both events are raised with the same state object
var compareResult = (Comparer ?? System.Collections.Comparer.Default).Compare(GetComparable(x, stateCopy), GetComparable(y, stateCopy));
return SortDirection == ListSortDirection.Descending ? -compareResult : compareResult;
}
private IComparable? GetComparable(object? item, object? state)
{
if (_batchComparableCache?.TryGetValue(item ?? s_batchNullItemKey, out var result) == true)
{
return result;
}
if (ComparableSelector is { } selector)
{
var args = _batchArgs ?? new() { SorterState = state };
args.ResetFor(item);
selector(this, args);
result = args.Comparable;
}
else
{
result = (IComparable?)item;
}
_batchComparableCache?.Add(item ?? s_batchNullItemKey, result);
return result;
}
protected internal override void BeginBatchOperation()
{
if (_batchArgs != null)
{
throw new InvalidOperationException("Already sorting.");
}
_batchArgs = new() { SorterState = State };
_batchComparableCache = new();
}
protected internal override void EndBatchOperation()
{
_batchArgs = null;
_batchComparableCache = null;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ComparableSelectorProperty)
{
OnInvalidated();
}
else if (change.Property == ComparerProperty)
{
OnInvalidated();
}
else if (change.Property == StateProperty)
{
if (_batchArgs != null)
Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log(this, "State changed during batch operation!");
}
}
public class ComparableSelectEventArgs : EventArgs
{
private object? _item;
/// <summary>
/// The item for which to provide an <see cref="IComparable"/>.
/// </summary>
public object? Item { get => _item; init => _item = value; }
/// <summary>
/// Gets the object retrieved from <see cref="ItemsSourceViewLayer.State"/> when the event was raised, or null.
/// </summary>
public object? SorterState { get; init; }
/// <summary>
/// The <see cref="IComparable"/> object selected by the event handler, or null.
/// </summary>
public IComparable? Comparable { get; set; }
protected internal void ResetFor(object? item)
{
_item = item;
Comparable = null;
}
}
}

20
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,23 @@ namespace Avalonia.Controls
set => SetValue(DisplayMemberBindingProperty, value);
}
private readonly ItemCollection _items = new();
/// <summary>
/// Gets a list of the <see cref="ItemFilter"/> objects owned by the <see cref="ItemsSourceView"/> of this <see cref="ItemsControl"/>.
/// </summary>
/// <remarks>
/// This property is provided as a XAML shortcut to <c>ItemsView.Filters</c>.
/// </remarks>
public AvaloniaList<ItemFilter> Filters => ItemsView.Filters;
/// <summary>
/// Gets a list of the <see cref="ItemSorter"/> objects owned by the <see cref="ItemsSourceView"/> of this <see cref="ItemsControl"/>.
/// </summary>
/// <remarks>
/// This property is provided as a XAML shortcut to <c>ItemsView.Sorters</c>.
/// </remarks>
public AvaloniaList<ItemSorter> Sorters => ItemsView.Sorters;
private readonly ItemCollection _items;
private int _itemCount;
private ItemContainerGenerator? _itemContainerGenerator;
private EventHandler<ChildIndexChangedEventArgs>? _childIndexChanged;
@ -93,6 +110,7 @@ namespace Avalonia.Controls
/// </summary>
public ItemsControl()
{
_items = new(this);
UpdatePseudoClasses();
_items.CollectionChanged += OnItemsViewCollectionChanged;
}

807
src/Avalonia.Controls/ItemsSourceView.LayerProcessing.cs

@ -0,0 +1,807 @@
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.Controls.Utils;
using Avalonia.Reactive;
using Avalonia.Threading;
using Avalonia.Utilities;
namespace Avalonia.Controls;
public partial class ItemsSourceView : IWeakEventSubscriber<PropertyChangedEventArgs>
{
private static readonly Lazy<ConditionalWeakTable<NotifyCollectionChangedEventArgs, NotifyCollectionChangedEventArgs?>> s_rewrittenCollectionChangedEvents = new();
private static readonly ThreadLocal<CompoundSorter> s_compoundSorter = 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<object?> items, List<int> indexMap, HashSet<string> invalidationProperties)? _layersState;
private readonly Lazy<Dictionary<INotifyPropertyChanged, int>> _propertyChangedSubscriptions = new();
private Dictionary<INotifyPropertyChanged, int> PropertyChangedSubscriptions => _propertyChangedSubscriptions.Value;
private int _deferredRefreshDepth;
/// <summary>
/// Gets whether there are any undisposed <see cref="DeferredRefreshScope"/> objects for this <see cref="ItemsSourceView"/>.
/// </summary>
private bool DeferredRefreshActive => _deferredRefreshDepth != 0;
internal static int[]? GetDiagnosticItemMap(ItemsSourceView itemsSourceView) => itemsSourceView._layersState?.indexMap.ToArray();
/// <summary>
/// Gets a list of the <see cref="ItemFilter"/> objects owned by this <see cref="ItemsSourceView"/>.
/// </summary>
public AvaloniaList<ItemFilter> Filters { get; }
/// <summary>
/// Gets whether any <see cref="ItemFilter"/> in the <see cref="Filters"/> collection is currently active.
/// </summary>
protected bool HasActiveFilters
{
get
{
for (int i = 0; i < Filters.Count; i++)
{
if (Filters[i].IsActive)
return true;
}
return false;
}
}
/// <summary>
/// Gets a list of the <see cref="ItemSorter"/> objects owned by this <see cref="ItemsSourceView"/>.
/// </summary>
public AvaloniaList<ItemSorter> Sorters { get; }
/// <summary>
/// Gets whether any <see cref="ItemSorter"/> in the <see cref="Sorters"/> collection is currently active.
/// </summary>
protected bool HasActiveSorters
{
get
{
for (int i = 0; i < Sorters.Count; i++)
{
if (Sorters[i].IsActive)
return true;
}
return false;
}
}
protected bool HasActiveLayers => HasActiveFilters || HasActiveSorters;
private static void BlockUpdateFromBackgroundThreads()
{
if (!Dispatcher.UIThread.CheckAccess())
{
throw new InvalidThreadException();
}
}
private static void ValidateLayer(ItemsSourceViewLayer layer)
{
Dispatcher.UIThread.VerifyAccess();
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();
}
/// <summary>
/// While any <see cref="DeferredRefreshScope"/> returned from this method is active, the <see cref="ItemsSourceView"/> will not raise <see cref="INotifyCollectionChanged"/>
/// events, nor will it evaluate any <see cref="Filters"/> or <see cref="Sorters"/>. When all scopes for a given <see cref="ItemsSourceView"/> have exited, <see cref="Refresh()"/>
/// will automatically be called on the UI thread.
/// </summary>
/// <remarks>
/// Scopes may be entered or exited from any thread. The <see cref="Source"/> collection can be safely modified from a background thread while a
/// <see cref="DeferredRefreshScope"/> is active.
/// </remarks>
/// <returns>
/// A disposable object (both <see cref="IDisposable"/> and <see cref="IAsyncDisposable"/>) which represents a deferred refresh scope. When this object is disposed, the scope will exit.
/// </returns>
public DeferredRefreshScope EnterDeferredRefreshScope() => new(this);
/// <summary>
/// Re-evaluates the current view of the <see cref="Source"/> collection and if necessary raises a <see cref="NotifyCollectionChangedAction.Reset"/> event.
/// This method executes a full re-evaluation of each active <see cref="ItemsSourceViewLayer"/> object found in <see cref="Filters"/> and <see cref="Sorters"/>.
/// </summary>
/// <remarks>
/// <para>If a <see cref="DeferredRefreshScope"/> is active, calling this method has no effect.</para>
/// <para>Calling this method is often not necessary, if appropriate values have been provided to <see cref="ItemsSourceViewLayer.State"/>
/// and/or <see cref="ItemsSourceViewLayer.InvalidationPropertyNames"/> on each layer.</para>
/// </remarks>
/// <exception cref="InvalidThreadException">Thrown if the method is called from any thread except <see cref="Dispatcher.UIThread"/>.</exception>
public void Refresh()
{
if (DeferredRefreshActive)
{
return;
}
Refresh(applyingDeferredUpdates: false, raiseEvents: true);
}
private void Refresh(bool applyingDeferredUpdates, bool raiseEvents)
{
BlockUpdateFromBackgroundThreads();
if (_isOwnerUnloaded)
{
return;
}
if (!HasActiveLayers)
{
if (_layersState == null)
{
if (raiseEvents && (_source is not INotifyCollectionChanged || applyingDeferredUpdates))
RaiseCollectionChanged(CollectionUtils.ResetEventArgs);
return;
}
RemoveListenerIfNecessary();
_layersState = null;
}
else
{
AddListenerIfNecessary();
_layersState = EvaluateLayers();
}
if (raiseEvents)
RaiseCollectionChanged(CollectionUtils.ResetEventArgs);
}
private IDisposable? EnterLayersBatchScope(bool filters = true, bool sorters = true)
{
Action? exitAllScopes = null;
for (int i = 0; filters && i < Filters.Count; i++)
{
if (Filters[i].IsActive)
{
Filters[i].BeginBatchOperation();
exitAllScopes += Filters[i].EndBatchOperation;
}
}
for (int i = 0; sorters && i < Sorters.Count; i++)
{
if (Sorters[i].IsActive)
{
Sorters[i].BeginBatchOperation();
exitAllScopes += Sorters[i].EndBatchOperation;
}
}
return exitAllScopes == null ? null : Disposable.Create(exitAllScopes);
}
private (List<object?> items, List<int> indexMap, HashSet<string> invalidationProperties) EvaluateLayers()
{
var result = new List<object?>(_source.Count);
var map = new List<int>(_source.Count);
var viewIndexToSourceIndex = new List<int>(_source.Count);
var invalidationProperties = new HashSet<string>();
using var layerScopes = EnterLayersBatchScope();
for (int i = 0; i < Filters.Count; i++)
{
if (Filters[i].IsActive)
invalidationProperties.UnionWith(Filters[i].GetInvalidationPropertyNamesEnumerator());
}
for (int i = 0; i < Sorters.Count; i++)
{
if (Sorters[i].IsActive)
invalidationProperties.UnionWith(Sorters[i].GetInvalidationPropertyNamesEnumerator());
}
Dictionary<INotifyPropertyChanged, int>? newPropertyChangedSubscriptions = null;
if (invalidationProperties.Count > 0)
{
newPropertyChangedSubscriptions = new();
}
CompoundSorter? comparer = null;
if (HasActiveSorters)
{
comparer = s_compoundSorter.Value ??= new();
comparer.Sorters = Sorters;
}
try
{
int i = 0;
// use an enumerator so that the IList implementation can manage the iteration, e.g. by throwing
// an exception should the collection change during enumeration, or by enumerating over a local
// copy of the collection.
foreach (var item in _source)
{
if (newPropertyChangedSubscriptions != null && item is INotifyPropertyChanged inpc)
{
if (newPropertyChangedSubscriptions.ContainsKey(inpc))
{
newPropertyChangedSubscriptions[inpc] += 1;
}
else
{
newPropertyChangedSubscriptions[inpc] = 1;
}
}
if (ItemPassesFilters(Filters, item))
{
if (comparer != null)
{
var index = result.BinarySearch(item, comparer);
if (index < 0)
{
index = ~index;
}
viewIndexToSourceIndex.Insert(index, i);
result.Insert(index, item);
}
else
{
viewIndexToSourceIndex.Add(i);
result.Add(item);
}
}
i++;
}
map.InsertMany(0, -1, _source.Count);
for (i = 0; i < viewIndexToSourceIndex.Count; i++)
{
map[viewIndexToSourceIndex[i]] = i;
}
return (result, map, invalidationProperties);
}
finally
{
if (comparer != null)
{
comparer.Sorters = null;
}
if (newPropertyChangedSubscriptions != null)
{
foreach (var kvp in newPropertyChangedSubscriptions)
{
if (!PropertyChangedSubscriptions.ContainsKey(kvp.Key))
{
WeakEvents.ThreadSafePropertyChanged.Subscribe(kvp.Key, this);
}
PropertyChangedSubscriptions[kvp.Key] = kvp.Value;
}
List<INotifyPropertyChanged>? toRemove = null;
foreach (var inpc in PropertyChangedSubscriptions.Keys)
{
if (!newPropertyChangedSubscriptions.ContainsKey(inpc))
{
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this);
(toRemove ??= new()).Add(inpc);
}
}
if (toRemove != null)
{
for (int i = 0; i < toRemove.Count; i++)
{
PropertyChangedSubscriptions.Remove(toRemove[i]);
}
}
}
}
}
private static bool ItemPassesFilters(IList<ItemFilter> itemFilters, object? item)
{
for (int i = 0; i < itemFilters.Count; i++)
{
if (itemFilters[i].IsActive && !itemFilters[i].FilterItem(item))
{
return false;
}
}
return true;
}
void IWeakEventSubscriber<PropertyChangedEventArgs>.OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e)
{
if (DeferredRefreshActive)
{
return;
}
BlockUpdateFromBackgroundThreads();
if (sender is not INotifyPropertyChanged inpc
|| _layersState is not { } layersState
|| e.PropertyName is not { } propertyName
|| !layersState.invalidationProperties.Contains(propertyName))
{
return;
}
bool? passes = null;
IDisposable? layerScopes = null;
try
{
for (int sourceIndex = 0; sourceIndex < Source.Count; sourceIndex++)
{
if (Source[sourceIndex] != sender)
{
continue;
}
// If a collection doesn't raise CollectionChanged events, 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.
if (passes == null)
{
layerScopes = EnterLayersBatchScope();
passes = ItemPassesFilters(Filters, sender);
}
switch ((layersState.indexMap[sourceIndex], passes))
{
case (-1, true):
{
var viewIndex = ViewIndex(sourceIndex, sender);
layersState.indexMap[sourceIndex] = viewIndex;
ShiftIndexMapOnViewChanged(sourceIndex + 1, 1);
layersState.items.Insert(viewIndex, sender);
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Add, sender, viewIndex));
}
break;
case (int viewIndex, true) when HasActiveSorters:
// To perform a correct binary search, the rest of the list must already be sorted. Since the changed item may
// now have a stale index, this means that we have to remove it from the list before identifying the new index.
layersState.items.RemoveAt(viewIndex);
var newViewIndex = ViewIndex(sourceIndex, sender);
layersState.items.Insert(newViewIndex, sender);
if (newViewIndex != viewIndex)
{
var delta = newViewIndex > viewIndex ? -1 : 1;
var (start, end) = (Math.Min(newViewIndex, viewIndex), Math.Max(newViewIndex, viewIndex));
for (int i = 0; i < layersState.indexMap.Count; i++)
{
if (layersState.indexMap[i] >= start && layersState.indexMap[i] < end)
{
layersState.indexMap[i] += delta;
}
}
layersState.indexMap[sourceIndex] = newViewIndex;
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Move, sender, newViewIndex, viewIndex));
}
break;
case (int viewIndex, false):
layersState.indexMap[sourceIndex] = -1;
ShiftIndexMapOnViewChanged(sourceIndex + 1, -1);
layersState.items.RemoveAt(viewIndex);
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Remove, sender, viewIndex));
break;
}
}
}
finally
{
layerScopes?.Dispose();
}
if (passes == null) // item is no longer in the collection, we can unsubscribe
{
Debug.Assert(PropertyChangedSubscriptions.ContainsKey(inpc));
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this);
PropertyChangedSubscriptions.Remove(inpc);
}
}
private void UpdateLayersForCollectionChangedEvent(NotifyCollectionChangedEventArgs e)
{
BlockUpdateFromBackgroundThreads();
if (_layersState is not { } layersState)
{
throw new InvalidOperationException("Layers not initialised.");
}
using var layerScopes = e.Action switch
{
NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Replace => EnterLayersBatchScope(),
NotifyCollectionChangedAction.Move => EnterLayersBatchScope(filters: false),
_ => null
};
NotifyCollectionChangedEventArgs? rewrittenArgs;
List<object?>? viewItems = null;
int? viewStartIndex = null; // null means that item sorting has lead to multiple discontinuous changes, in which case we issue a single Reset event instead
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]))
{
var viewIndex = ViewIndex(sourceIndex, e.NewItems[i]);
if (viewItems == null)
{
viewItems = new(e.NewItems.Count);
viewStartIndex = viewIndex;
}
if (viewStartIndex.HasValue && viewIndex != viewStartIndex + i)
{
viewStartIndex = null;
}
// during add operations this has to be done incrementally, so that ViewIndex can find the right result
// for the next item.
ShiftIndexMapOnSourceChanged(sourceIndex, 1);
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)
{
if (PropertyChangedSubscriptions.ContainsKey(inpc))
{
PropertyChangedSubscriptions[inpc] += 1;
}
else
{
PropertyChangedSubscriptions[inpc] = 1;
WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this);
}
}
}
if (viewItems != null)
{
rewrittenArgs = viewStartIndex == null ? CollectionUtils.ResetEventArgs : new(NotifyCollectionChangedAction.Add, viewItems, viewStartIndex.Value);
}
else
{
rewrittenArgs = null;
}
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(e.OldItems != null);
for (var i = 0; i < e.OldItems.Count; i++)
{
var sourceIndex = e.OldStartingIndex + i;
var viewIndex = layersState.indexMap[sourceIndex];
if (viewIndex != -1)
{
if (viewItems == null)
{
viewItems = new(e.OldItems.Count);
viewStartIndex = viewIndex;
}
if (viewStartIndex.HasValue && viewIndex != viewStartIndex + i)
{
viewStartIndex = null;
}
layersState.items.RemoveAt(viewIndex);
ShiftIndexMapOnSourceChanged(viewIndex, -1);
viewItems.Add(e.OldItems[i]);
}
layersState.indexMap.RemoveAt(sourceIndex);
if (layersState.invalidationProperties.Count > 0 && e.OldItems[i] is INotifyPropertyChanged inpc)
{
if (PropertyChangedSubscriptions.TryGetValue(inpc, out var subscribeCount))
{
if (subscribeCount <= 1)
{
Debug.Assert(subscribeCount == 1);
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this);
PropertyChangedSubscriptions.Remove(inpc);
}
else
{
PropertyChangedSubscriptions[inpc] -= 1;
}
}
else
{
Debug.Fail("An INotifyPropertyChanged object was removed, but there is no record of a PropertyChanged subscription for it.");
}
}
}
if (viewItems != null)
{
rewrittenArgs = viewStartIndex == null ? CollectionUtils.ResetEventArgs : new(NotifyCollectionChangedAction.Remove, viewItems, viewStartIndex.Value);
}
else
{
rewrittenArgs = null;
}
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException();
case NotifyCollectionChangedAction.Reset:
Refresh();
rewrittenArgs = null;
break;
case NotifyCollectionChangedAction.Move:
throw new NotImplementedException();
default:
throw new NotImplementedException();
}
s_rewrittenCollectionChangedEvents.Value.Add(e, rewrittenArgs);
}
private void ShiftIndexMapOnViewChanged(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 void ShiftIndexMapOnSourceChanged(int inclusiveViewStartIndex, int delta)
{
var map = _layersState!.Value.indexMap;
for (var i = 0; i < map.Count; i++)
{
if (map[i] >= inclusiveViewStartIndex)
{
map[i] += delta;
}
}
}
private int ViewIndex(int sourceIndex, object? item)
{
if (_layersState is not { } layersState)
{
throw new InvalidOperationException("Layers not initialised.");
}
if (HasActiveSorters)
{
var sorter = s_compoundSorter.Value ??= new();
sorter.Sorters = Sorters;
try
{
var searchResult = layersState.items.BinarySearch(item, sorter);
return searchResult < 0 ? ~searchResult : searchResult;
}
finally
{
sorter.Sorters = null;
}
}
var candidateIndex = sourceIndex;
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);
}
private class CompoundSorter : IComparer<object?>
{
public IList<ItemSorter>? Sorters { get; set; }
public int Compare(object? x, object? y)
{
if (Sorters == null)
{
throw new InvalidOperationException("No sorters provided");
}
for (int i = 0; i < Sorters.Count; i++)
{
if (!Sorters[i].IsActive)
{
continue;
}
var comparison = Sorters[i].Compare(x, y);
if (comparison != 0)
{
return comparison;
}
}
return -1; // this default result will give us the source collection's order
}
}
/// <seealso cref="EnterDeferredRefreshScope"/>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Shared dispose method")]
public sealed class DeferredRefreshScope : IDisposable, IAsyncDisposable
{
private readonly object _disposeLock = new();
private ItemsSourceView? _owner;
private Task? _disposeTask;
public DeferredRefreshScope(ItemsSourceView owner)
{
_owner = owner;
Interlocked.Increment(ref _owner._deferredRefreshDepth);
}
public void Dispose() => DisposeInternal().GetAwaiter().GetResult();
public ValueTask DisposeAsync() => new(DisposeInternal());
private Task DisposeInternal()
{
GC.SuppressFinalize(this);
lock (_disposeLock)
{
if (_owner is { } owner)
{
_owner = null;
if (Interlocked.Decrement(ref owner._deferredRefreshDepth) == 0)
{
if (Dispatcher.UIThread.CheckAccess())
{
ApplyDeferredUpdates(owner);
_disposeTask = Task.CompletedTask;
}
else
{
_disposeTask = Dispatcher.UIThread.InvokeAsync(() => ApplyDeferredUpdates(owner)).GetTask();
}
}
else
{
_disposeTask = Task.CompletedTask;
}
}
}
Debug.Assert(_owner == null);
Debug.Assert(_disposeTask != null);
return _disposeTask;
}
~DeferredRefreshScope()
{
lock (_disposeLock)
{
if (_owner is { } owner)
{
_owner = null;
if (Interlocked.Decrement(ref owner._deferredRefreshDepth) == 0)
{
Dispatcher.UIThread.Post(() => ApplyDeferredUpdates(owner));
}
Logging.Logger.TryGet(Logging.LogEventLevel.Warning, nameof(ItemsSourceView))?.Log(owner, $"A {nameof(DeferredRefreshScope)} was finalized without having been disposed.");
}
}
}
private static void ApplyDeferredUpdates(ItemsSourceView owner) => owner.Refresh(applyingDeferredUpdates: true, raiseEvents: true);
}
/// <summary>
/// Thrown when an <see cref="ItemsSourceView"/> with an active <see cref="ItemsSourceViewLayer"/> is asked to process a change to its source data on a background thread.
/// </summary>
public class InvalidThreadException : InvalidOperationException
{
public InvalidThreadException() : base($"{nameof(ItemsSourceView)} does not support data changes on background threads " +
$"while any {nameof(ItemsSourceViewLayer)} is active. Either make these changes on the UI thread, or call " +
$"{nameof(ItemsSourceView)}.{nameof(EnterDeferredRefreshScope)} to defer processing of data changes until they can be " +
$"executed on the UI thread.")
{ }
}
}

199
src/Avalonia.Controls/ItemsSourceView.cs

@ -7,9 +7,13 @@ using System;
using System.Collections;
using System.Collections.Generic;
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
{
@ -17,39 +21,89 @@ namespace Avalonia.Controls
/// Represents a standardized view of the supported interactions between an items collection
/// and an items control.
/// </summary>
public class ItemsSourceView : IReadOnlyList<object?>,
public partial class ItemsSourceView : IReadOnlyList<object?>,
IList,
INotifyCollectionChanged,
INotifyPropertyChanged,
ICollectionChangedListener
{
/// <summary>
/// Gets an empty <see cref="ItemsSourceView"/>
/// </summary>
public static ItemsSourceView Empty { get; } = new ItemsSourceView(Array.Empty<object?>());
public static ItemsSourceView Empty { get; } = new ItemsSourceView(null, Array.Empty<object?>());
/// <summary>
/// Gets an instance representing an uninitialized source.
/// </summary>
[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;
/// <summary>
/// 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.
/// </summary>
private bool _isOwnerUnloaded;
private TargetWeakEventSubscriber<ItemsSourceView, RoutedEventArgs>? _loadedWeakSubscriber;
private IList _source;
private NotifyCollectionChangedEventHandler? _collectionChanged;
private NotifyCollectionChangedEventHandler? _preCollectionChanged;
private NotifyCollectionChangedEventHandler? _postCollectionChanged;
private PropertyChangedEventHandler? _propertyChanged;
private bool _listening;
private IList InternalSource => _layersState?.items ?? _source;
private static readonly WeakEvent<Control, RoutedEventArgs> s_loadedWeakEvent =
WeakEvent.Register<Control, RoutedEventArgs>((s, h) => s.Loaded += h, (s, h) => s.Loaded -= h);
/// <summary>
/// Initializes a new instance of the ItemsSourceView class for the specified data source.
/// </summary>
/// <param name="owner">The <see cref="ItemsControl"/> for which this <see cref="ItemsSourceView"/> is being created.</param>
/// <param name="source">The data source.</param>
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();
}
/// <summary>
/// Gets the number of items in the collection.
/// </summary>
public int Count => Source.Count;
public int Count => InternalSource.Count;
/// <summary>
/// Gets the source collection.
@ -138,14 +192,29 @@ namespace Avalonia.Controls
}
}
public event PropertyChangedEventHandler? PropertyChanged
{
add
{
AddListenerIfNecessary();
_propertyChanged += value;
}
remove
{
_propertyChanged -= value;
RemoveListenerIfNecessary();
}
}
/// <summary>
/// Retrieves the item at the specified index.
/// </summary>
/// <param name="index">The index.</param>
/// <returns>The item.</returns>
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);
/// <summary>
/// Gets or creates an <see cref="ItemsSourceView"/> for the specified enumerable.
@ -164,7 +233,7 @@ namespace Avalonia.Controls
{
ItemsSourceView isv => isv,
null => Empty,
_ => new ItemsSourceView(items)
_ => new ItemsSourceView(null, items)
};
}
@ -184,9 +253,8 @@ namespace Avalonia.Controls
return items switch
{
ItemsSourceView<T> isvt => isvt,
ItemsSourceView isv => new ItemsSourceView<T>(isv.Source),
null => ItemsSourceView<T>.Empty,
_ => new ItemsSourceView<T>(items)
_ => new ItemsSourceView<T>(null, items)
};
}
@ -207,7 +275,7 @@ namespace Avalonia.Controls
{
ItemsSourceView<T> isv => isv,
null => ItemsSourceView<T>.Empty,
_ => new ItemsSourceView<T>(items)
_ => new ItemsSourceView<T>(null, items)
};
}
@ -219,30 +287,77 @@ namespace Avalonia.Controls
yield return o;
}
var inner = Source;
var inner = InternalSource;
return inner switch
{
IEnumerable<object> e => e.GetEnumerator(),
null => Enumerable.Empty<object?>().GetEnumerator(),
_ => EnumerateItems(inner),
};
}
IEnumerator IEnumerable.GetEnumerator() => Source.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => InternalSource.GetEnumerator();
void ICollectionChangedListener.PreChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
{
_preCollectionChanged?.Invoke(this, e);
if (DeferredRefreshActive)
{
return;
}
if (HasActiveLayers)
{
UpdateLayersForCollectionChangedEvent(e);
}
if (GetRewrittenEvent(e) is not { } rewritten)
{
return;
}
_preCollectionChanged?.Invoke(this, rewritten);
}
void ICollectionChangedListener.Changed(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
{
_collectionChanged?.Invoke(this, e);
if (DeferredRefreshActive)
{
return;
}
if (GetRewrittenEvent(e) is not { } rewritten)
{
return;
}
_collectionChanged?.Invoke(this, rewritten);
if (rewritten.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Remove or NotifyCollectionChangedAction.Reset)
OnPropertyChanged(nameof(Count));
}
void ICollectionChangedListener.PostChanged(INotifyCollectionChanged sender, NotifyCollectionChangedEventArgs e)
{
_postCollectionChanged?.Invoke(this, e);
if (DeferredRefreshActive)
{
return;
}
if (GetRewrittenEvent(e) is not { } rewritten)
{
return;
}
_postCollectionChanged?.Invoke(this, rewritten);
}
/// <summary>
/// Raises <see cref="PropertyChanged"/>.
/// </summary>
protected virtual void OnPropertyChanged(string propertyName)
{
_propertyChanged?.Invoke(this, new(propertyName));
}
int IList.Add(object? value) => ThrowReadOnly();
@ -250,7 +365,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);
/// <summary>
/// Not implemented in Avalonia, preserved here for ItemsRepeater's usage.
@ -270,9 +385,10 @@ namespace Avalonia.Controls
if (_listening && _source is INotifyCollectionChanged inccOld)
CollectionChangedEventManager.Instance.RemoveListener(inccOld, this);
var oldItems = this.ToArray();
_source = source switch
{
ItemsSourceView isv => isv.Source,
IList list => list,
INotifyCollectionChanged => throw new ArgumentException(
"Collection implements INotifyCollectionChanged but not IList.",
@ -282,6 +398,15 @@ namespace Avalonia.Controls
_ => new List<object>(source.Cast<object>())
};
Refresh(applyingDeferredUpdates: false, raiseEvents: false);
OnPropertyChanged(nameof(Source));
if (oldItems.Length > 0)
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Remove, oldItems, 0));
if (Count > 0)
RaiseCollectionChanged(new(NotifyCollectionChangedAction.Add, this.ToArray(), 0));
if (_listening && _source is INotifyCollectionChanged inccNew)
CollectionChangedEventManager.Instance.AddListener(inccNew, this);
}
@ -298,7 +423,7 @@ namespace Avalonia.Controls
private void RemoveListenerIfNecessary()
{
if (_listening && _collectionChanged is null && _postCollectionChanged is null)
if (_listening && _collectionChanged is null && _postCollectionChanged is null && Filters.Count == 0)
{
if (_source is INotifyCollectionChanged incc)
CollectionChangedEventManager.Instance.RemoveListener(incc, this);
@ -315,19 +440,17 @@ namespace Avalonia.Controls
/// <summary>
/// Gets an empty <see cref="ItemsSourceView"/>
/// </summary>
public new static ItemsSourceView<T> Empty { get; } = new ItemsSourceView<T>(Array.Empty<T>());
public new static ItemsSourceView<T> Empty { get; } = new ItemsSourceView<T>(null, Array.Empty<T>());
/// <summary>
/// Initializes a new instance of the ItemsSourceView class for the specified data source.
/// </summary>
/// <param name="source">The data source.</param>
internal ItemsSourceView(IEnumerable<T> source)
: base(source)
/// <inheritdoc cref="ItemsSourceView(AvaloniaObject?, IEnumerable)"/>
internal ItemsSourceView(AvaloniaObject? owner, IEnumerable<T> source)
: base(owner, source)
{
}
internal ItemsSourceView(IEnumerable source)
: base(source)
/// <inheritdoc cref="ItemsSourceView(AvaloniaObject?, IEnumerable)"/>
internal ItemsSourceView(AvaloniaObject? owner, IEnumerable source)
: base(owner, source)
{
}
@ -343,25 +466,15 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="index">The index.</param>
/// <returns>The item.</returns>
public new T GetAt(int index) => (T)Source[index]!;
public new T GetAt(int index) => (T)base[index]!;
public new IEnumerator<T> GetEnumerator()
{
static IEnumerator<T> EnumerateItems(IList list)
{
foreach (var o in list)
yield return (T)o;
}
var inner = Source;
return inner switch
{
IEnumerable<T> 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();
}
}

148
src/Avalonia.Controls/ItemsSourceViewLayer.cs

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Metadata;
namespace Avalonia.Controls;
/// <summary>
/// Provides base functionality for an object which can be used by <see cref="ItemsSourceView"/> to generate a transformed view of its source collection.
/// </summary>
/// <remarks>
/// This type inherits from <see cref="AvaloniaObject"/>. Bindings applied to it will inherit values from the <see cref="Owner"/> object. The layer can
/// be activated and deactivated at any time, and can be configured to automatically refresh its owner when a dependent value changes.
/// </remarks>
public abstract class ItemsSourceViewLayer : AvaloniaObject, INamed
{
private InvalidationPropertiesCollection? _invalidationPropertyNames;
private object? _state;
private bool _isActive = true;
public string? Name { get; init; }
/// <summary>
/// Gets the owner of this layer. This is the owner of the <see cref="ItemsSourceView"/> that to which this layer
/// has been added.
/// </summary>
public AvaloniaObject? Owner => InheritanceParent;
/// <summary>
/// Gets or sets whether this layer is currently contributing to the final view of the items.
/// </summary>
public bool IsActive
{
get => _isActive;
set => SetAndRaise(IsActiveProperty, ref _isActive, value);
}
/// <seealso cref="IsActive"/>
public static readonly DirectProperty<ItemsSourceViewLayer, bool> IsActiveProperty =
AvaloniaProperty.RegisterDirect<ItemsSourceViewLayer, bool>(nameof(IsActive), o => o.IsActive, (o, v) => o.IsActive = v, unsetValue: true);
/// <summary>
/// Gets or sets a collection of strings which will trigger re-evaluation of an item, if:
/// <list type="number">
/// <item>The item implements <see cref="INotifyPropertyChanged"/>; and</item>
/// <item>The item raises <see cref="INotifyPropertyChanged.PropertyChanged"/>; and</item>
/// <item>The value of the <see cref="PropertyChangedEventArgs.PropertyName"/> property is found in this collection.</item>
/// </list>
/// </summary>
/// <remarks>
/// Performance warning: if any strings are added to this collection, the <see cref="INotifyPropertyChanged.PropertyChanged"/> event will be subscribed to on ALL
/// <see cref="INotifyPropertyChanged"/> items in <see cref="ItemsSourceView.Source"/>. This can lead to a large number of allocations.
/// </remarks>
public InvalidationPropertiesCollection InvalidationPropertyNames
{
get => _invalidationPropertyNames ??= new();
init
{
_invalidationPropertyNames = value;
OnInvalidated();
}
}
/// <summary>
/// Raised when this layer should be re-evaluated for all items in the view.
/// </summary>
public event EventHandler<EventArgs>? Invalidated;
internal protected IEnumerable<string> GetInvalidationPropertyNamesEnumerator() => _invalidationPropertyNames ?? Enumerable.Empty<string>();
/// <summary>
/// Gets or sets an abitrary object. When the value of this property changes, <see cref="Invalidated"/> is raised.
/// </summary>
public object? State
{
get => _state;
set => SetAndRaise(StateProperty, ref _state, value);
}
/// <seealso cref="State"/>
public static readonly DirectProperty<ItemsSourceViewLayer, object?> StateProperty =
AvaloniaProperty.RegisterDirect<ItemsSourceViewLayer, object?>(nameof(State), o => o.State, (o, v) => o.State = v);
/// <summary>
/// Raises the <see cref="Invalidated"/> event.
/// </summary>
protected virtual void OnInvalidated()
{
Invalidated?.Invoke(this, EventArgs.Empty);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == StateProperty)
{
OnInvalidated();
}
else if (change.Property == IsActiveProperty)
{
OnInvalidated();
}
}
/// <summary>
/// Called shortly before the layer is evaluated for one or more items.
/// </summary>
internal protected virtual void BeginBatchOperation() { }
/// <summary>
/// Called shortly after the layer has been evaluated for one or more items.
/// </summary>
internal protected virtual void EndBatchOperation() { }
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<string>
{
// Don't validate items: the PropertyChanged event can be raised with any "PropertyName" string.
public InvalidationPropertiesCollection() { }
public InvalidationPropertiesCollection(IEnumerable<string> names) : base(names) { }
public InvalidationPropertiesCollection(int capacity) : base(capacity) { }
}

10
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -153,7 +153,7 @@ namespace Avalonia.Controls.Primitives
public SelectingItemsControl()
{
((ItemCollection)ItemsView).SourceChanged += OnItemsViewSourceChanged;
ItemsView.PropertyChanged += OnItemsViewPropertyChanged;
}
/// <summary>
@ -922,9 +922,9 @@ namespace Avalonia.Controls.Primitives
return _selection;
}
private void OnItemsViewSourceChanged(object? sender, EventArgs e)
private void OnItemsViewPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_updateState is null)
if (e.PropertyName == nameof(ItemsView.Source) && _updateState is null)
TryInitializeSelectionSource(_selection, true);
}
@ -1267,7 +1267,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
@ -1287,7 +1287,7 @@ namespace Avalonia.Controls.Primitives
selection.SelectedItem = item;
}
selection.Source = source;
selection.Source = ItemsView;
}
}

50
src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs

@ -102,50 +102,30 @@ namespace Avalonia.Controls.Utils
void IWeakEventSubscriber<NotifyCollectionChangedEventArgs>.
OnEvent(object? notifyCollectionChanged, WeakEvent ev, NotifyCollectionChangedEventArgs e)
{
static void Notify(
INotifyCollectionChanged incc,
NotifyCollectionChangedEventArgs args,
WeakReference<ICollectionChangedListener>[] listeners)
{
foreach (var l in listeners)
{
if (l.TryGetTarget(out var target))
{
target.PreChanged(incc, args);
}
}
var listeners = Listeners.ToArray();
foreach (var l in listeners)
foreach (var l in listeners)
{
if (l.TryGetTarget(out var target))
{
if (l.TryGetTarget(out var target))
{
target.Changed(incc, args);
}
target.PreChanged(_collection, e);
}
}
foreach (var l in listeners)
foreach (var l in listeners)
{
if (l.TryGetTarget(out var target))
{
if (l.TryGetTarget(out var target))
{
target.PostChanged(incc, args);
}
target.Changed(_collection, e);
}
}
if (Dispatcher.UIThread.CheckAccess())
{
var l = Listeners.ToArray();
Notify(_collection, e, l);
}
else
foreach (var l in listeners)
{
var eCapture = e;
Dispatcher.UIThread.Post(() =>
{
var l = Listeners.ToArray();
Notify(_collection, eCapture, l);
},
DispatcherPriority.Send);
if (l.TryGetTarget(out var target))
{
target.PostChanged(_collection, e);
}
}
}
}

2
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))
{

345
tests/Avalonia.Controls.UnitTests/ItemsSourceViewTests.cs

@ -2,9 +2,12 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.ComponentModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Platform;
using Avalonia.Diagnostics;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Xunit;
@ -73,6 +76,312 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(1, subscribers.Length);
}
[Fact]
public void Filtered_View_Adds_New_Items()
{
var source = new AvaloniaList<string>() { "foo", "bar" };
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
target.Filters.Add(new FunctionItemFilter(ob => !Equals(bool.FalseString, ob)));
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
source.Add(bool.FalseString);
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);
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(new int[] { 0, 1, 2, 3, -1, 4 }, ItemsSourceView.GetDiagnosticItemMap(target));
Assert.Equal(5, target.Count);
Assert.Equal(bool.TrueString, target[^1]);
}
[Fact]
public void Filtered_View_Removes_Old_Items()
{
var source = new AvaloniaList<string>() { "foo", "bar", bool.TrueString, bool.FalseString, bool.TrueString, "end" };
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
target.Filters.Add(new FunctionItemFilter(ob => !Equals(bool.FalseString, ob)));
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);
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);
Assert.Equal(new int[] { 0, 1, 2, 3 }, ItemsSourceView.GetDiagnosticItemMap(target));
}
[Fact]
public void Filtered_View_Resets_When_Source_Cleared()
{
var source = new AvaloniaList<string>() { "foo", "bar" };
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
target.Filters.Add(new FunctionItemFilter(ob => !Equals(bool.FalseString, ob)));
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
source.Clear();
Assert.Equal(0, target.Count);
Assert.Equal(Array.Empty<int>(), ItemsSourceView.GetDiagnosticItemMap(target));
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
}
[Fact]
public void ComparableSorter_Sorts_Integers()
{
var random = new Random();
var source = new AvaloniaList<int>(Enumerable.Repeat(0, 100).Select(i => random.Next(int.MinValue + 1, int.MaxValue)));
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
target.Sorters.Add(new ComparableSorter());
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
Assert.Equal(target.Cast<int>().OrderBy(i => i), target);
source.Add(int.MinValue);
Assert.Equal(target[0], int.MinValue);
source.Insert(0, int.MaxValue);
Assert.Equal(target[target.Count - 1], int.MaxValue);
}
[Fact]
public void Disabled_Layers_Update_View_When_Activated()
{
var random = new Random();
var source = new AvaloniaList<int>(Enumerable.Repeat(0, 100));
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
var filter = new FunctionItemFilter(ob => (int)ob % 2 == 0) { IsActive = false };
target.Filters.Add(filter);
var sorter = new ComparableSorter() { IsActive = false, SortDirection = ListSortDirection.Descending };
target.Sorters.Add(sorter);
Assert.Equal(0, collectionChangeEvents.Count);
Assert.Equal(source, target);
sorter.IsActive = true;
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
Assert.Equal(source.Reverse(), target);
filter.IsActive = true;
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
Assert.Equal(source.Reverse().Where((i, _) => i % 2 == 0), target);
sorter.IsActive = false;
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
collectionChangeEvents.Clear();
Assert.Equal(source.Where((i, _) => i % 2 == 0), target);
}
[Fact]
public void Layers_Refreshed_When_InvalidationProperty_Changes()
{
var source = new AvaloniaList<ViewModel>(Enumerable.Repeat(0, 5).Select(i => new ViewModel()));
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
target.Filters.Add(new FunctionItemFilter(ob => ((ViewModel)ob).PassesFilter)
{
InvalidationPropertyNames = new() { nameof(ViewModel.PassesFilter) },
});
target.Sorters.Add(new ComparableSorter(ob => ((ViewModel)ob).LastModified)
{
InvalidationPropertyNames = new() { nameof(ViewModel.LastModified) },
});
foreach (var vm in source)
Assert.Equal(1, vm.PropertyChangedSubscriberCount); // One event subscription should be shared between all layers
Assert.Equal(2, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[1].Action);
collectionChangeEvents.Clear();
source[3].PassesFilter = true;
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Add, collectionChangeEvents[0].Action);
Assert.Equal(0, collectionChangeEvents[0].NewStartingIndex);
Assert.Equal(new[] { source[3] }, collectionChangeEvents[0].NewItems);
Assert.Equal(source[3], target[0]);
collectionChangeEvents.Clear();
source[0].PassesFilter = true;
Assert.Equal(new[] { source[3], source[0] }, target); // source[0] comes last because it was modified more recently
}
[Fact]
public void Off_Thread_Collection_Changes_Throw_Exception_When_Layers_Active_And_No_DeferredRefreshScope()
{
var dispatcherImpl = new ConfigurableIsLoopThreadDispatcherImpl();
using var app = UnitTestApplication.Start(new TestServices(dispatcherImpl: dispatcherImpl));
var source = new AvaloniaList<int>(Enumerable.Repeat(0, 5));
var target = ItemsSourceView.GetOrCreate(source);
target.CollectionChanged += delegate { }; // ensure that source.CollectionChanged is subscribed to
dispatcherImpl.CurrentThreadIsLoopThread = false;
// Should not throw. If no layers are involved, ItemsSourceView can process the event on any thread.
// What happens when others try to handle the resulting CollectionChanged event is not its problem!
source.Add(-1);
dispatcherImpl.CurrentThreadIsLoopThread = true;
target.Sorters.Add(new ComparableSorter());
dispatcherImpl.CurrentThreadIsLoopThread = false;
Assert.Throws<ItemsSourceView.InvalidThreadException>(() => source.Add(-1));
using (target.EnterDeferredRefreshScope())
{
source.Add(-2);
}
Assert.Equal(8, target.Count);
Assert.Equal(-2, target[0]);
}
[Fact]
public void DeferredUpdateScope_Batches_Changes_Together()
{
var dispatcher = new ManagedDispatcherImpl(null);
using var app = UnitTestApplication.Start(new TestServices(dispatcherImpl: dispatcher));
var source = new AvaloniaList<int>();
var target = ItemsSourceView.GetOrCreate(source);
var collectionChangeEvents = new List<NotifyCollectionChangedEventArgs>();
target.CollectionChanged += (s, e) => collectionChangeEvents.Add(e);
using (target.EnterDeferredRefreshScope())
{
source.Add(0);
source.Add(1);
source.Add(2);
source.Remove(1);
target.Filters.Add(new FunctionItemFilter(ob => (int)ob % 2 == 0));
source.Add(4);
}
Assert.Equal(1, collectionChangeEvents.Count);
Assert.Equal(NotifyCollectionChangedAction.Reset, collectionChangeEvents[0].Action);
Assert.Equal(new[] { 0, 2, 4 }, target);
}
private class ViewModel : INotifyPropertyChanged
{
private bool _passesFilter;
public bool PassesFilter
{
get => _passesFilter;
set
{
_passesFilter = value;
PropertyChanged?.Invoke(this, new(nameof(PassesFilter)));
LastModified = DateTimeOffset.Now;
PropertyChanged?.Invoke(this, new(nameof(LastModified)));
}
}
public DateTimeOffset? LastModified { get; private set; }
public event PropertyChangedEventHandler PropertyChanged;
public int PropertyChangedSubscriberCount => PropertyChanged?.GetInvocationList().Length ?? 0;
}
private class InvalidCollection : INotifyCollectionChanged, IEnumerable<string>
{
public event NotifyCollectionChangedEventHandler? CollectionChanged { add { } remove { } }
@ -91,11 +400,43 @@ namespace Avalonia.Controls.UnitTests
private class ReassignableItemsSourceView : ItemsSourceView
{
public ReassignableItemsSourceView(IEnumerable source)
: base(source)
: base(null, source)
{
}
public new void SetSource(IEnumerable source) => base.SetSource(source);
}
private class ConfigurableIsLoopThreadDispatcherImpl : IDispatcherImpl
{
private bool _signalling;
bool IDispatcherImpl.CurrentThreadIsLoopThread => _signalling || CurrentThreadIsLoopThread;
public bool CurrentThreadIsLoopThread { get; set; } = true;
public long Now => DateTime.Now.Ticks;
public event Action Signaled;
public event Action Timer;
public void Signal()
{
_signalling = true;
try
{
Signaled?.Invoke();
}
finally
{
_signalling = false;
}
}
public void UpdateTimer(long? dueTimeInMs)
{
Timer?.Invoke();
}
}
}
}

2
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@ -769,7 +769,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.Selection = selection;
Assert.Same(target.ItemsSource, selection.Source);
Assert.Equal(target.ItemsSource, selection.Source);
}
[Fact]

Loading…
Cancel
Save