// 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; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; namespace CommunityToolkit.Mvvm.Collections; /// /// A read-only list of groups. /// /// The type of the group keys. /// The type of elements in the collection. public sealed class ReadOnlyObservableGroupedCollection : ReadOnlyObservableCollection>, ILookup where TKey : notnull { /// /// Initializes a new instance of the class. /// /// The source collection to wrap. /// Thrown if is . public ReadOnlyObservableGroupedCollection(ObservableCollection> collection) : base(new ObservableCollection>(collection?.Select(static g => new ReadOnlyObservableGroup(g))!)) { collection!.CollectionChanged += OnSourceCollectionChanged; } /// /// Initializes a new instance of the class. /// /// The source collection to wrap. /// Thrown if is . public ReadOnlyObservableGroupedCollection(ObservableCollection> collection) : base(collection) { } /// IEnumerable ILookup.this[TKey key] { get { IEnumerable? result = null; if (key is not null) { result = FirstGroupByKeyOrDefault(key); } return result ?? Enumerable.Empty(); } } /// bool ILookup.Contains(TKey key) { return key is not null && FirstGroupByKeyOrDefault(key) is not null; } /// IEnumerator> IEnumerable>.GetEnumerator() { return GetEnumerator(); } /// /// Forwards the event whenever it is raised by the wrapped collection. /// /// The wrapped collection (an of instance). /// The arguments. /// Thrown if a range operation is requested. private void OnSourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Even if NotifyCollectionChangedEventArgs allows multiple items, the actual implementation // is only reporting the changes one by one. We consider only this case for now. If this is // added in a new version of .NET, this type will need to be updated accordingly in a new version. [DoesNotReturn] static void ThrowNotSupportedExceptionForRangeOperation() { throw new NotSupportedException( "ReadOnlyObservableGroupedCollection doesn't support operations on multiple items at once.\n" + "If this exception was thrown, it likely means support for batched item updates has been added to the " + "underlying ObservableCollection type, and this implementation doesn't support that feature yet.\n" + "Please consider opening an issue in https://aka.ms/toolkit/dotnet to report this."); } // The inner Items list is ObservableCollection>, so doing a direct cast here will always succeed ObservableCollection> items = (ObservableCollection>)Items; switch (e.Action) { // Insert a single item for an "Add" operation, fail if multiple items are added case NotifyCollectionChangedAction.Add: if (e.NewItems!.Count == 1) { ObservableGroup newItem = (ObservableGroup)e.NewItems![0]!; items.Insert(e.NewStartingIndex, new ReadOnlyObservableGroup(newItem)); } else if (e.NewItems!.Count > 1) { ThrowNotSupportedExceptionForRangeOperation(); } break; // Remove a single item at offset for a "Remove" operation, fail if multiple items are removed case NotifyCollectionChangedAction.Remove: if (e.OldItems!.Count == 1) { items.RemoveAt(e.OldStartingIndex); } else if (e.OldItems!.Count > 1) { ThrowNotSupportedExceptionForRangeOperation(); } break; // Replace a single item at offset for a "Replace" operation, fail if multiple items are replaced case NotifyCollectionChangedAction.Replace: if (e.OldItems!.Count == 1 && e.NewItems!.Count == 1) { ObservableGroup replacedItem = (ObservableGroup)e.NewItems![0]!; items[e.OldStartingIndex] = new ReadOnlyObservableGroup(replacedItem); } else if (e.OldItems!.Count > 1 || e.NewItems!.Count > 1) { ThrowNotSupportedExceptionForRangeOperation(); } break; // Move a single item between offsets for a "Move" operation, fail if multiple items are moved case NotifyCollectionChangedAction.Move: if (e.OldItems!.Count == 1 && e.NewItems!.Count == 1) { items.Move(e.OldStartingIndex, e.NewStartingIndex); } else if (e.OldItems!.Count > 1 || e.NewItems!.Count > 1) { ThrowNotSupportedExceptionForRangeOperation(); } break; // A "Reset" operation is just forwarded normally case NotifyCollectionChangedAction.Reset: items.Clear(); break; default: break; } } /// /// Returns the first group with key or if not found. /// /// The key of the group to query (assumed not to be ). /// The first group matching . private IEnumerable? FirstGroupByKeyOrDefault(TKey key) { if (Items is List> list) { foreach (ReadOnlyObservableGroup group in list) { if (EqualityComparer.Default.Equals(group.Key, key)) { return group; } } return null; } [MethodImpl(MethodImplOptions.NoInlining)] static IEnumerable? FirstGroupByKeyOrDefaultFallback(ReadOnlyObservableGroupedCollection source, TKey key) { return Enumerable.FirstOrDefault>(source, group => EqualityComparer.Default.Equals(group.Key, key)); } return FirstGroupByKeyOrDefaultFallback(this, key); } }