Skip to main content

Collections

Observable collections with change notifications, thread safety, filtering and synchronization.

Contents


Overview

Every collection in Aspid.Collections.Observable implements:

public interface IObservableCollection<T> : IReadOnlyCollection<T>
{
event NotifyCollectionChangedEventHandler<T>? CollectionChanged;
object SyncRoot { get; }
}

Thread safety: every mutation is guarded by lock(SyncRoot).

NotifyCollectionChangedEventArgs<T>

A readonly struct describing the change:

ActionDescription
AddItem(s) added
RemoveItem(s) removed
ReplaceItem replaced
MoveItem moved
ResetCollection cleared

Properties: NewItem/OldItem (single), NewItems/OldItems (range), NewStartingIndex/OldStartingIndex.


ObservableList<T>

A thread-safe IList<T> with notifications:

var list = new ObservableList<string>();

list.CollectionChanged += (sender, args) =>
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
Debug.Log($"Added: {args.NewItem} at {args.NewStartingIndex}");
break;
case NotifyCollectionChangedAction.Remove:
Debug.Log($"Removed: {args.OldItem}");
break;
}
};

list.Add("Item 1");
list.AddRange(new[] { "Item 2", "Item 3" });
list.Insert(0, "First");
list.Move(0, 2); // Move an item
list.Swap(1, 3); // Swap two items
list.RemoveAt(0);
list.Clear();

Methods

MethodDescription
Add(T)Add an item
AddRange(IEnumerable<T>)Add a range
Insert(int, T)Insert at an index
InsertRange(int, IEnumerable<T>)Insert a range
Remove(T)Remove by value
RemoveAt(int)Remove by index
Move(int, int)Move an item
Swap(int, int)Swap two items
Clear()Clear

Virtual hooks

For subclasses:

protected virtual void OnAdded(T item, int index) { }
protected virtual void OnRemoved(T item, int index) { }
protected virtual void OnMoved(T item, int oldIndex, int newIndex) { }
protected virtual void OnReplaced(T oldItem, T newItem, int index) { }
protected virtual void OnClearing() { }

ObservableDictionary<TKey, TValue>

A thread-safe dictionary:

var dict = new ObservableDictionary<string, int>();

dict.CollectionChanged += (sender, args) => { /* ... */ };

dict["health"] = 100; // Add
dict["health"] = 80; // Replace
dict.Remove("health"); // Remove

The indexer works as "replace or add": an existing key gets its value replaced.


ObservableHashSet<T>

A thread-safe HashSet:

var set = new ObservableHashSet<string>();

set.Add("tag1"); // true
set.Add("tag1"); // false (already present)
set.Remove("tag1"); // true
set.Clear();

Supports set operations: IsSubsetOf, IsSupersetOf, Overlaps and others.


FilteredList<T>

Filtering and sorting without touching the source collection:

var source = new ObservableList<int> { 5, 3, 8, 1, 9, 2 };

var filtered = new FilteredList<int>(source)
{
Filter = x => x > 3, // Only > 3
Comparer = Comparer<int>.Default // Ascending
};

// filtered: [5, 8, 9]

source.Add(7); // filtered updates itself: [5, 7, 8, 9]
source.Add(1); // fails the filter, filtered is unchanged

API

public sealed class FilteredList<T> : IReadOnlyFilteredList<T>, IDisposable
{
// Filter; setting it calls Update()
Predicate<T>? Filter { get; set; }

// Sort order; setting it calls Update()
IComparer<T>? Comparer { get; set; }

// Number of filtered items
int Count { get; }

// Index access (into the filtered list)
T this[int index] { get; }

// Forced recalculation
void Update();

// Unsubscribe from the source collection
void Dispose();
}
info

Always call Dispose() when done to unsubscribe from the source collection's events.

With MVVM

[ViewModel]
public partial class ListViewModel
{
[OneTimeBind] private ObservableList<ItemViewModel> _items;
[OneTimeBind] private FilteredList<ItemViewModel> _filteredItems;

public ListViewModel()
{
_items = new ObservableList<ItemViewModel>();
_filteredItems = new FilteredList<ItemViewModel>(_items)
{
Filter = item => item.IsCompleted
};
}
}

ObservableListSync

Keeps two collections in sync with automatic item conversion. The main pattern is Model → ViewModel.

// Model collection
ObservableList<TodoItem> todos = storage.Todos;

// A synchronized collection of ViewModels
IReadOnlyObservableListSync<TodoItemViewModel> todoViewModels =
todos.CreateSync(item => new TodoItemViewModel(item));

// todoViewModels mirrors every operation:
// - Add in todos → Add in todoViewModels (converted)
// - Remove in todos → Remove in todoViewModels
// - Replace, Move, Clear likewise

Example from the Todo List sample

[ViewModel]
public partial class TodoStorageViewModel
{
[OneTimeBind]
private IReadOnlyObservableListSync<TodoItemViewModel> _todoItemViewModels;

public TodoStorageViewModel(TodoStorage todoStorage)
{
_todoItemViewModels = todoStorage.Todos.CreateSync(
todo => CreateTodoViewModel(todo)
);
}

private TodoItemViewModel CreateTodoViewModel(TodoItem todo)
{
return new TodoItemViewModel(todo, EditCommand, DeleteCommand);
}
}

With cleanup on removal

// The second argument runs when an item is removed
var sync = source.CreateSync(
converter: model => new ItemViewModel(model),
remove: vm => vm.Dispose()
);

Binding collections to a View

Use the StarterKit binders to display collections:

BinderPurpose
ViewModelObservableListBinderDynamic list with a View factory
VirtualizedListItemSourceBinderVirtualized list
ViewModelCollectionBinder<T>Static collection (fixed items)

More: Collection Binders.


See also