// 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);
}
}