Skip to content

Instantly share code, notes, and snippets.

@weitzhandler
Last active October 9, 2024 16:26
Show Gist options
  • Save weitzhandler/65ac9113e31d12e697cb58cd92601091 to your computer and use it in GitHub Desktop.
Save weitzhandler/65ac9113e31d12e697cb58cd92601091 to your computer and use it in GitHub Desktop.
namespace System.Collections.ObjectModel
{
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
/// <summary>
/// Implementation of a dynamic data collection based on generic Collection&lt;T&gt;,
/// implementing INotifyCollectionChanged to notify listeners
/// when items get added, removed or the whole list is refreshed.
/// </summary>
public class RangeObservableCollection<T> : ObservableCollection<T>
{
//------------------------------------------------------
//
// Private Fields
//
//------------------------------------------------------
#region Private Fields
[NonSerialized]
private DeferredEventsCollection? _deferredEvents;
#endregion Private Fields
//------------------------------------------------------
//
// Constructors
//
//------------------------------------------------------
#region Constructors
/// <summary>
/// Initializes a new instance of ObservableCollection that is empty and has default initial capacity.
/// </summary>
public RangeObservableCollection() { }
/// <summary>
/// Initializes a new instance of the ObservableCollection class that contains
/// elements copied from the specified collection and has sufficient capacity
/// to accommodate the number of elements copied.
/// </summary>
/// <param name="collection">The collection whose elements are copied to the new list.</param>
/// <remarks>
/// The elements are copied onto the ObservableCollection in the
/// same order they are read by the enumerator of the collection.
/// </remarks>
/// <exception cref="ArgumentNullException"> collection is a null reference </exception>
public RangeObservableCollection(IEnumerable<T> collection) : base(collection) { }
/// <summary>
/// Initializes a new instance of the ObservableCollection class
/// that contains elements copied from the specified list
/// </summary>
/// <param name="list">The list whose elements are copied to the new list.</param>
/// <remarks>
/// The elements are copied onto the ObservableCollection in the
/// same order they are read by the enumerator of the list.
/// </remarks>
/// <exception cref="ArgumentNullException"> list is a null reference </exception>
public RangeObservableCollection(List<T> list) : base(list) { }
#endregion Constructors
//------------------------------------------------------
//
// Public Properties
//
//------------------------------------------------------
#region Public Properties
EqualityComparer<T>? _Comparer;
public EqualityComparer<T> Comparer
{
get => _Comparer ??= EqualityComparer<T>.Default;
private set => _Comparer = value;
}
/// <summary>
/// Gets or sets a value indicating whether this collection acts as a <see cref="HashSet{T}"/>,
/// disallowing duplicate items, based on <see cref="Comparer"/>.
/// This might indeed consume background performance, but in the other hand,
/// it will pay off in UI performance as less required UI updates are required.
/// </summary>
public bool AllowDuplicates { get; set; } = true;
#endregion Public Properties
//------------------------------------------------------
//
// Public Methods
//
//------------------------------------------------------
#region Public Methods
/// <summary>
/// Adds the elements of the specified collection to the end of the <see cref="ObservableCollection{T}"/>.
/// </summary>
/// <param name="collection">
/// The collection whose elements should be added to the end of the <see cref="ObservableCollection{T}"/>.
/// The collection itself cannot be null, but it can contain elements that are null, if type T is a reference type.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
public void AddRange(IEnumerable<T> collection)
{
InsertRange(Count, collection);
}
/// <summary>
/// Inserts the elements of a collection into the <see cref="ObservableCollection{T}"/> at the specified index.
/// </summary>
/// <param name="index">The zero-based index at which the new elements should be inserted.</param>
/// <param name="collection">The collection whose elements should be inserted into the List<T>.
/// The collection itself cannot be null, but it can contain elements that are null, if type T is a reference type.</param>
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is not in the collection range.</exception>
public void InsertRange(int index, IEnumerable<T> collection)
{
if (collection == null)
throw new ArgumentNullException(nameof(collection));
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (index > Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (!AllowDuplicates)
collection =
collection
.Distinct(Comparer)
.Where(item => !Items.Contains(item, Comparer))
.ToList();
if (collection is ICollection<T> countable)
{
if (countable.Count == 0)
return;
}
else if (!collection.Any())
return;
CheckReentrancy();
//expand the following couple of lines when adding more constructors.
var target = (List<T>)Items;
target.InsertRange(index, collection);
OnEssentialPropertiesChanged();
if (!(collection is IList list))
list = new List<T>(collection);
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, list, index));
}
/// <summary>
/// Removes the first occurence of each item in the specified collection from the <see cref="ObservableCollection{T}"/>.
/// </summary>
/// <param name="collection">The items to remove.</param>
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
public void RemoveRange(IEnumerable<T> collection)
{
if (collection == null)
throw new ArgumentNullException(nameof(collection));
if (Count == 0)
return;
else if (collection is ICollection<T> countable)
{
if (countable.Count == 0)
return;
else if (countable.Count == 1)
using (IEnumerator<T> enumerator = countable.GetEnumerator())
{
enumerator.MoveNext();
Remove(enumerator.Current);
return;
}
}
else if (!collection.Any())
return;
CheckReentrancy();
var clusters = new Dictionary<int, List<T>>();
var lastIndex = -1;
List<T>? lastCluster = null;
foreach (T item in collection)
{
var index = IndexOf(item);
if (index < 0)
continue;
Items.RemoveAt(index);
if (lastIndex == index && lastCluster != null)
lastCluster.Add(item);
else
clusters[lastIndex = index] = lastCluster = new List<T> { item };
}
OnEssentialPropertiesChanged();
if (Count == 0)
OnCollectionReset();
else
foreach (KeyValuePair<int, List<T>> cluster in clusters)
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, cluster.Value, cluster.Key));
}
/// <summary>
/// Iterates over the collection and removes all items that satisfy the specified match.
/// </summary>
/// <remarks>The complexity is O(n).</remarks>
/// <param name="match"></param>
/// <returns>Returns the number of elements that where </returns>
/// <exception cref="ArgumentNullException"><paramref name="match"/> is null.</exception>
public int RemoveAll(Predicate<T> match)
{
return RemoveAll(0, Count, match);
}
/// <summary>
/// Iterates over the specified range within the collection and removes all items that satisfy the specified match.
/// </summary>
/// <remarks>The complexity is O(n).</remarks>
/// <param name="index">The index of where to start performing the search.</param>
/// <param name="count">The number of items to iterate on.</param>
/// <param name="match"></param>
/// <returns>Returns the number of elements that where </returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is out of range.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="count"/> is out of range.</exception>
/// <exception cref="ArgumentNullException"><paramref name="match"/> is null.</exception>
public int RemoveAll(int index, int count, Predicate<T> match)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count));
if (index + count > Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (match == null)
throw new ArgumentNullException(nameof(match));
if (Count == 0)
return 0;
List<T>? cluster = null;
var clusterIndex = -1;
var removedCount = 0;
using (BlockReentrancy())
using (DeferEvents())
{
for (var i = 0; i < count; i++, index++)
{
T item = Items[index];
if (match(item))
{
Items.RemoveAt(index);
removedCount++;
if (clusterIndex == index)
{
Debug.Assert(cluster != null);
cluster!.Add(item);
}
else
{
cluster = new List<T> { item };
clusterIndex = index;
}
index--;
}
else if (clusterIndex > -1)
{
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, cluster, clusterIndex));
clusterIndex = -1;
cluster = null;
}
}
if (clusterIndex > -1)
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, cluster, clusterIndex));
}
if (removedCount > 0)
OnEssentialPropertiesChanged();
return removedCount;
}
/// <summary>
/// Removes a range of elements from the <see cref="ObservableCollection{T}"/>>.
/// </summary>
/// <param name="index">The zero-based starting index of the range of elements to remove.</param>
/// <param name="count">The number of elements to remove.</param>
/// <exception cref="ArgumentOutOfRangeException">The specified range is exceeding the collection.</exception>
public void RemoveRange(int index, int count)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count));
if (index + count > Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (count == 0)
return;
if (count == 1)
{
RemoveItem(index);
return;
}
//Items will always be List<T>, see constructors
var items = (List<T>)Items;
List<T> removedItems = items.GetRange(index, count);
CheckReentrancy();
items.RemoveRange(index, count);
OnEssentialPropertiesChanged();
if (Count == 0)
OnCollectionReset();
else
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItems, index));
}
/// <summary>
/// Clears the current collection and replaces it with the specified collection,
/// using <see cref="Comparer"/>.
/// </summary>
/// <param name="collection">The items to fill the collection with, after clearing it.</param>
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
public void ReplaceRange(IEnumerable<T> collection)
{
ReplaceRange(0, Count, collection);
}
/// <summary>
/// Removes the specified range and inserts the specified collection in its position, leaving equal items in equal positions intact.
/// </summary>
/// <param name="index">The index of where to start the replacement.</param>
/// <param name="count">The number of items to be replaced.</param>
/// <param name="collection">The collection to insert in that location.</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is out of range.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="count"/> is out of range.</exception>
/// <exception cref="ArgumentNullException"><paramref name="collection"/> is null.</exception>
/// <exception cref="ArgumentNullException"><paramref name="comparer"/> is null.</exception>
public void ReplaceRange(int index, int count, IEnumerable<T> collection)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count));
if (index + count > Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (collection == null)
throw new ArgumentNullException(nameof(collection));
if (!AllowDuplicates)
collection =
collection
.Distinct(Comparer)
.ToList();
if (collection is ICollection<T> countable)
{
if (countable.Count == 0)
{
RemoveRange(index, count);
return;
}
}
else if (!collection.Any())
{
RemoveRange(index, count);
return;
}
if (index + count == 0)
{
InsertRange(0, collection);
return;
}
if (!(collection is IList<T> list))
list = new List<T>(collection);
using (BlockReentrancy())
using (DeferEvents())
{
var rangeCount = index + count;
var addedCount = list.Count;
var changesMade = false;
List<T>?
newCluster = null,
oldCluster = null;
int i = index;
for (; i < rangeCount && i - index < addedCount; i++)
{
//parallel position
T old = this[i], @new = list[i - index];
if (Comparer.Equals(old, @new))
{
OnRangeReplaced(i, newCluster!, oldCluster!);
continue;
}
else
{
Items[i] = @new;
if (newCluster == null)
{
Debug.Assert(oldCluster == null);
newCluster = new List<T> { @new };
oldCluster = new List<T> { old };
}
else
{
newCluster.Add(@new);
oldCluster!.Add(old);
}
changesMade = true;
}
}
OnRangeReplaced(i, newCluster!, oldCluster!);
//exceeding position
if (count != addedCount)
{
var items = (List<T>)Items;
if (count > addedCount)
{
var removedCount = rangeCount - addedCount;
T[] removed = new T[removedCount];
items.CopyTo(i, removed, 0, removed.Length);
items.RemoveRange(i, removedCount);
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removed, i));
}
else
{
var k = i - index;
T[] added = new T[addedCount - k];
for (int j = k; j < addedCount; j++)
{
T @new = list[j];
added[j - k] = @new;
}
items.InsertRange(i, added);
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, added, i));
}
OnEssentialPropertiesChanged();
}
else if (changesMade)
{
OnIndexerPropertyChanged();
}
}
}
#endregion Public Methods
//------------------------------------------------------
//
// Protected Methods
//
//------------------------------------------------------
#region Protected Methods
/// <summary>
/// Called by base class Collection&lt;T&gt; when the list is being cleared;
/// raises a CollectionChanged event to any listeners.
/// </summary>
protected override void ClearItems()
{
if (Count == 0)
return;
CheckReentrancy();
base.ClearItems();
OnEssentialPropertiesChanged();
OnCollectionReset();
}
/// <inheritdoc/>
protected override void InsertItem(int index, T item)
{
if (!AllowDuplicates && Items.Contains(item))
return;
base.InsertItem(index, item);
}
/// <inheritdoc/>
protected override void SetItem(int index, T item)
{
if (AllowDuplicates)
{
if (Comparer.Equals(this[index], item))
return;
}
else
if (Items.Contains(item, Comparer))
return;
CheckReentrancy();
T oldItem = this[index];
base.SetItem(index, item);
OnIndexerPropertyChanged();
OnCollectionChanged(NotifyCollectionChangedAction.Replace, oldItem!, item!, index);
}
/// <summary>
/// Raise CollectionChanged event to any listeners.
/// Properties/methods modifying this ObservableCollection will raise
/// a collection changed event through this virtual method.
/// </summary>
/// <remarks>
/// When overriding this method, either call its base implementation
/// or call <see cref="BlockReentrancy"/> to guard against reentrant collection changes.
/// </remarks>
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
if (_deferredEvents != null)
{
_deferredEvents.Add(e);
return;
}
base.OnCollectionChanged(e);
}
protected virtual IDisposable DeferEvents() => new DeferredEventsCollection(this);
#endregion Protected Methods
//------------------------------------------------------
//
// Private Methods
//
//------------------------------------------------------
#region Private Methods
/// <summary>
/// Helper to raise Count property and the Indexer property.
/// </summary>
void OnEssentialPropertiesChanged()
{
OnPropertyChanged(EventArgsCache.CountPropertyChanged);
OnIndexerPropertyChanged();
}
/// <summary>
/// /// Helper to raise a PropertyChanged event for the Indexer property
/// /// </summary>
void OnIndexerPropertyChanged() =>
OnPropertyChanged(EventArgsCache.IndexerPropertyChanged);
/// <summary>
/// Helper to raise CollectionChanged event to any listeners
/// </summary>
void OnCollectionChanged(NotifyCollectionChangedAction action, object oldItem, object newItem, int index) =>
OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, newItem, oldItem, index));
/// <summary>
/// Helper to raise CollectionChanged event with action == Reset to any listeners
/// </summary>
void OnCollectionReset() =>
OnCollectionChanged(EventArgsCache.ResetCollectionChanged);
/// <summary>
/// Helper to raise event for clustered action and clear cluster.
/// </summary>
/// <param name="followingItemIndex">The index of the item following the replacement block.</param>
/// <param name="newCluster"></param>
/// <param name="oldCluster"></param>
//TODO should have really been a local method inside ReplaceRange(int index, int count, IEnumerable<T> collection, IEqualityComparer<T> comparer),
//move when supported language version updated.
void OnRangeReplaced(int followingItemIndex, ICollection<T> newCluster, ICollection<T> oldCluster)
{
if (oldCluster == null || oldCluster.Count == 0)
{
Debug.Assert(newCluster == null || newCluster.Count == 0);
return;
}
OnCollectionChanged(
new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Replace,
new List<T>(newCluster),
new List<T>(oldCluster),
followingItemIndex - oldCluster.Count));
oldCluster.Clear();
newCluster.Clear();
}
#endregion Private Methods
//------------------------------------------------------
//
// Private Types
//
//------------------------------------------------------
#region Private Types
sealed class DeferredEventsCollection : List<NotifyCollectionChangedEventArgs>, IDisposable
{
readonly RangeObservableCollection<T> _collection;
public DeferredEventsCollection(RangeObservableCollection<T> collection)
{
Debug.Assert(collection != null);
Debug.Assert(collection._deferredEvents == null);
_collection = collection;
_collection._deferredEvents = this;
}
public void Dispose()
{
_collection._deferredEvents = null;
foreach (var args in this)
_collection.OnCollectionChanged(args);
}
}
#endregion Private Types
}
/// <remarks>
/// To be kept outside <see cref="ObservableCollection{T}"/>, since otherwise, a new instance will be created for each generic type used.
/// </remarks>
internal static class EventArgsCache
{
internal static readonly PropertyChangedEventArgs CountPropertyChanged = new PropertyChangedEventArgs("Count");
internal static readonly PropertyChangedEventArgs IndexerPropertyChanged = new PropertyChangedEventArgs("Item[]");
internal static readonly NotifyCollectionChangedEventArgs ResetCollectionChanged = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset);
}
}
namespace System.Collections.ObjectModel
{
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
public class SortableObservableCollection<T> : RangeObservableCollection<T>
{
public SortableObservableCollection()
{
}
public SortableObservableCollection(IComparer<T> comparer, bool descending = false) : base()
{
Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
IsDescending = descending;
}
public SortableObservableCollection(IEnumerable<T> collection, IComparer<T> comparer, bool descending = false) : base(collection)
{
Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
IsDescending = descending;
Sort();
}
IComparer<T>? _Comparer;
public IComparer<T> Comparer
{
get => _Comparer ??= Comparer<T>.Default;
set
{
_Comparer = value;
Sort();
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Comparer)));
}
}
bool _IsDescending;
/// <summary>
/// Gets or sets a value indicating whether the sorting should be descending.
/// Default value is false.
/// </summary>
public bool IsDescending
{
get => _IsDescending;
set
{
if (_IsDescending != value)
{
_IsDescending = value;
Sort();
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsDescending)));
}
}
}
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
base.OnCollectionChanged(e);
if (_Reordering) return;
switch (e.Action)
{
case NotifyCollectionChangedAction.Remove:
case NotifyCollectionChangedAction.Reset:
return;
}
Sort();
}
bool _Reordering;
public void Sort() // TODO, concern change index so no need to walk the whole list
{
var query = this
.Select((item, index) => (Item: item, Index: index));
query = IsDescending
? query.OrderByDescending(tuple => tuple.Item, Comparer)
: query.OrderBy(tuple => tuple.Item, Comparer);
var map = query.Select((tuple, index) => (OldIndex: tuple.Index, NewIndex: index))
.Where(o => o.OldIndex != o.NewIndex);
using (var enumerator = map.GetEnumerator())
if (enumerator.MoveNext())
{
_Reordering = true;
Move(enumerator.Current.OldIndex, enumerator.Current.NewIndex);
_Reordering = false;
}
}
}
}
namespace System.Windows.Data
{
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
public class WpfObservableRangeCollection<T> : ObservableRangeCollection<T>
{
DeferredEventsCollection _deferredEvents;
public WpfObservableRangeCollection()
{
}
public WpfObservableRangeCollection(IEnumerable<T> collection) : base(collection)
{
}
public WpfObservableRangeCollection(List<T> list) : base(list)
{
}
/// <summary>
/// Raise CollectionChanged event to any listeners.
/// Properties/methods modifying this ObservableCollection will raise
/// a collection changed event through this virtual method.
/// </summary>
/// <remarks>
/// When overriding this method, either call its base implementation
/// or call <see cref="BlockReentrancy"/> to guard against reentrant collection changes.
/// </remarks>
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
var _deferredEvents = (ICollection<NotifyCollectionChangedEventArgs>)typeof(ObservableRangeCollection<T>).GetField("_deferredEvents", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(this);
if (_deferredEvents != null)
{
_deferredEvents.Add(e);
return;
}
foreach (var handler in GetHandlers())
if (IsRange(e) && handler.Target is CollectionView cv)
cv.Refresh();
else
handler(this, e);
}
protected override IDisposable DeferEvents() => new DeferredEventsCollection(this);
bool IsRange(NotifyCollectionChangedEventArgs e) => e.NewItems?.Count > 1 || e.OldItems?.Count > 1;
IEnumerable<NotifyCollectionChangedEventHandler> GetHandlers()
{
var info = typeof(ObservableCollection<T>).GetField(nameof(CollectionChanged), BindingFlags.Instance | BindingFlags.NonPublic);
var @event = (MulticastDelegate)info.GetValue(this);
return @event?.GetInvocationList()
.Cast<NotifyCollectionChangedEventHandler>()
.Distinct()
?? Enumerable.Empty<NotifyCollectionChangedEventHandler>();
}
class DeferredEventsCollection : List<NotifyCollectionChangedEventArgs>, IDisposable
{
private readonly WpfObservableRangeCollection<T> _collection;
public DeferredEventsCollection(WpfObservableRangeCollection<T> collection)
{
Debug.Assert(collection != null);
Debug.Assert(collection._deferredEvents == null);
_collection = collection;
_collection._deferredEvents = this;
}
public void Dispose()
{
_collection._deferredEvents = null;
var handlers = _collection
.GetHandlers()
.ToLookup(h => h.Target is CollectionView);
foreach (var handler in handlers[false])
foreach (var e in this)
handler(_collection, e);
foreach (var cv in handlers[true]
.Select(h => h.Target)
.Cast<CollectionView>()
.Distinct())
cv.Refresh();
}
}
}
}
@alansingfield
Copy link

I encountered a small problem with the side-effects of enumeration, ReplaceRange and InsertRange will consume the first item, reset the enumeration then consume all items passed in again.

This is because of the the ContainsAny() call.

        [TestMethod]
        public void ObservableRangeCollectionReplaceRangeEnumerationCount()
        {
            var coll = new ObservableRangeCollection<int>();

            int counter = 0;
            coll.ReplaceRange(new[] { 1, 2 }.Select(x =>
            {
                counter++;
                return x;
            }));

            counter.ShouldBe(2);  -- FAILS - counter is 3
        }

The solution is to do the conversion to List before counting rather than using ContainsAny() :

// ReplaceRange( ...
if(!(collection is IList<T> list))
{
    list = new List<T>(collection);
}
if(list.Count == 0)
{
    Clear();
    return;
}
  // ... replace all further uses of collection with list

I can't see this having any great performance impact seeing as the only different operation is populating a list with zero items.

@MillionsterNutzer
Copy link

Hi Weitzhandler,

I wanted to use your WpfObservableRangeCollection in order to avoid having a CollectionChanged triggered each time a single item is added or removed.
I noticed that your WpfObservableRangeCollection fixes this situation when adding a range of items. However, when removing a range this is not the case. Looking into the implementation of the RemoveRange method I noticed the following lines at the very end of the method:

foreach (KeyValuePair<int, List<T>> cluster in clusters) OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, cluster.Value, cluster.Key));

Why do you call the OnCollectionChanged in a foreach loop? I can fix this for my situation an call this only for the last item that was removed, however I'd like to understand why you are doing this.
Can you explain this to me?

@jwheeler31
Copy link

This is very useful, thank you.

An observation: while writing unit tests, it seems that the Comparer (RangeObservableCollection#79) should be exposed for standalone usage of RangeObservableCollection.

@rimex-bkimmett
Copy link

rimex-bkimmett commented Jul 14, 2023

Hey, thank you for making this! I'd like to note, there's a bug in your sorting algorithm in SortableObservableCollection where sorting will not produce the correct results.

I had to change Sort() to something like:

public void Sort() // TODO, concern change index so no need to walk the whole list
        {
            var query = this
              .Select((item, index) => (Item: item, Index: index));
            query = IsDescending
              ? query.OrderByDescending(tuple => tuple.Item, Comparer)
              : query.OrderBy(tuple => tuple.Item, Comparer);

            var anything_to_move = query.Select((tuple, index) => (OldIndex: tuple.Index, NewIndex: index))
            .Any(o => o.OldIndex != o.NewIndex);

            if (!anything_to_move) { return; }

            _Reordering = true;
            ReplaceRange(query.Select(x => x.Item));
            _Reordering = false;
     }

Here's why the Move() approach doesn't work.

Let's say we have this list: [2, 3, 4, 1] and we want to sort it to [1, 2, 3, 4]. Your var map would become something like:

2: [0] => [1]
3: [1] => [2]
4: [2] => [3]
1: [3] => [0]

Now, let's say that we scan through map in order and move 2 first. We go from [2, 3, 4, 1] to [3, 2, 4, 1].

However, now when we go to move 3, it is no longer at index 1. Instead, we move the 2 again: [3, 2, 4, 1] becomes [3, 4, 2, 1].
Then if we go to move 4, it has left its position as well and we move 2 again - [3, 4, 2, 1] becomes [3, 4, 1, 2].
Now we go to move 1, but it has been displaced and we move 2 again - [3, 4, 1, 2] becomes [2, 3, 4, 1].

We end up where we started, and the list isn't properly sorted.

More broadly, any system that involves moves where moving one item shifts others that still need to be moved on their own can't work off a static table that doesn't update the positions of the other displaced items. My best guess is that if you tried to make this work, you'd end up with something like insertion sort ( O(n^2) ).

The replacement I wrote checks to see if reordering is needed, then replaces the content of the array directly, which was sorted with the system sort algorithm - there's probably efficiency improvements when being used with, say, an observable collection attached to a front end UI element with many items, but it's been performant enough for the application I'm working on.

@CodingOctocat
Copy link

CodingOctocat commented Jul 20, 2023

WpfObservableRangeCollection throw InvalidOperationException.

WpfObservableRangeCollection: [1, 2, 3] binding to ListBox, then selected 3 and 2, and Remove them:

wpfobservablerangecollection-cs-L49 throw System.InvalidOperationException:"The "2" index in the collection change event is not valid for collections of size "1".

I had to use: https://github.com/jamesmontemagno/mvvm-helpers/blob/master/MvvmHelpers/ObservableRangeCollection.cs , it worked fine. but it raise System.NotSupportedException: Range actions are not supported.

Final Solution: Use https://github.com/xamarin/XamarinCommunityToolkit/blob/main/src/CommunityToolkit/Xamarin.CommunityToolkit/ObjectModel/ObservableRangeCollection.shared.cs (merged from above MvvmHelpers) + WpfObservableRangeCollection,No problems found for now.

I found the final solution still has a strange bug, suppose I have a Master-Detail view program, the Master view is a ListBox, the Detail view is used for editing SelectedItem, suppose the Model has a _inTxn field, and when the Item is selected, set _ inTxn to True when the Item is selected. If and only if only 1 item exists in the database, that item cannot be removed from the Collection after loading the program(AddRange(collection from db)) because the _inTxn(false) field of that data in the Collection does not match SelectedItem._inTxn(true).

Bug fixed: When only one item has been added, We use the ObservableCollection.Add method. The above problem is solved.

I've rebuilt a project that I hope will help more people:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment