Table of Contents

ObservableRangeCollection<T>

An ObservableCollection<T> with support for bulk operations. Raises a single notification for bulk operations, dramatically improving UI performance when adding, removing, or replacing multiple items.

Namespace: GamaLearn.Collections Assembly: GamaLearn.Maui.Core


Overview

The standard ObservableCollection<T> raises a CollectionChanged event for each individual item added or removed. When adding 1000 items, this results in 1000 UI updates, which is extremely slow.

ObservableRangeCollection<T> solves this by raising a single notification for bulk operations, reducing 1000 UI updates to just 1.

Performance Comparison:

  • ObservableCollection: Adding 1000 items = 1000 notifications ⚠️
  • ObservableRangeCollection: Adding 1000 items = 1 notification

Installation

dotnet add package GamaLearn.Maui.Core

Constructors

ObservableRangeCollection()

Initializes a new empty instance.

var items = new ObservableRangeCollection<string>();

ObservableRangeCollection(IEnumerable<T> collection)

Initializes a new instance with the specified items.

Parameters:

  • collection - The items to add
var items = new ObservableRangeCollection<string>(existingList);

Methods

AddRange(IEnumerable<T> items)

Adds a range of items to the collection. Raises a single CollectionChanged event.

Parameters:

  • items - The items to add

Throws:

  • ArgumentNullException - If items is null
var items = new ObservableRangeCollection<Product>();

// Single notification for all items
items.AddRange(new[]
{
    new Product { Name = "Item 1" },
    new Product { Name = "Item 2" },
    new Product { Name = "Item 3" }
});

InsertRange(int index, IEnumerable<T> items)

Inserts a range of items at the specified index. Raises a single CollectionChanged event.

Parameters:

  • index - The index at which to insert
  • items - The items to insert

Throws:

  • ArgumentNullException - If items is null
  • ArgumentOutOfRangeException - If index is out of range
// Insert items at position 2
items.InsertRange(2, newItems);

RemoveRange(IEnumerable<T> items)

Removes a range of items from the collection. Raises a single CollectionChanged event.

Parameters:

  • items - The items to remove

Throws:

  • ArgumentNullException - If items is null
// Remove multiple items with single notification
var itemsToRemove = items.Where(x => x.IsCompleted);
items.RemoveRange(itemsToRemove);

ReplaceRange(IEnumerable<T> items)

Clears the collection and adds the new items. Raises a single CollectionChanged event with Reset action.

Parameters:

  • items - The new items

Throws:

  • ArgumentNullException - If items is null
// Replace entire collection with single notification
items.ReplaceRange(newItems);

Usage Examples

ViewModel with Data Binding

public class ProductsViewModel : INotifyPropertyChanged
{
    public ObservableRangeCollection<Product> Products { get; }

    public ProductsViewModel()
    {
        Products = new ObservableRangeCollection<Product>();
    }

    public async Task LoadProductsAsync()
    {
        var products = await productService.GetAllAsync();

        // Single UI update instead of N updates
        Products.AddRange(products);
    }

    public async Task RefreshProductsAsync()
    {
        var products = await productService.GetAllAsync();

        // Replace entire collection with single notification
        Products.ReplaceRange(products);
    }

    public void RemoveCompletedProducts()
    {
        var completed = Products.Where(p => p.IsCompleted).ToList();

        // Remove multiple items with single notification
        Products.RemoveRange(completed);
    }
}

XAML Binding

<ContentPage xmlns:vm="clr-namespace:MyApp.ViewModels"
             x:DataType="vm:ProductsViewModel">

    <ContentPage.BindingContext>
        <vm:ProductsViewModel />
    </ContentPage.BindingContext>

    <CollectionView ItemsSource="{Binding Products}">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="models:Product">
                <Label Text="{Binding Name}" />
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>

</ContentPage>

Search Results

public class SearchViewModel
{
    public ObservableRangeCollection<SearchResult> Results { get; }

    private readonly Debouncer searchDebouncer;

    public SearchViewModel()
    {
        Results = new ObservableRangeCollection<SearchResult>();
        searchDebouncer = new Debouncer(TimeSpan.FromMilliseconds(300));
    }

    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;
            OnPropertyChanged();

            searchDebouncer.Debounce(async () =>
            {
                await PerformSearchAsync(value);
            });
        }
    }

    private async Task PerformSearchAsync(string query)
    {
        if (string.IsNullOrWhiteSpace(query))
        {
            Results.Clear();
            return;
        }

        var results = await searchService.SearchAsync(query);

        // Efficiently replace all search results
        Results.ReplaceRange(results);
    }
}

Infinite Scroll / Pagination

public class InfiniteScrollViewModel
{
    public ObservableRangeCollection<Item> Items { get; }

    private int currentPage = 0;
    private bool isLoading = false;

    public InfiniteScrollViewModel()
    {
        Items = new ObservableRangeCollection<Item>();
    }

    public async Task LoadInitialAsync()
    {
        currentPage = 0;
        var items = await LoadPageAsync(currentPage);

        // Initial load with single notification
        Items.ReplaceRange(items);
    }

    public async Task LoadMoreAsync()
    {
        if (isLoading) return;

        isLoading = true;
        try
        {
            currentPage++;
            var items = await LoadPageAsync(currentPage);

            // Append next page with single notification
            Items.AddRange(items);
        }
        finally
        {
            isLoading = false;
        }
    }

    private async Task<List<Item>> LoadPageAsync(int page)
    {
        return await apiService.GetPageAsync(page, pageSize: 20);
    }
}

Filtering and Sorting

public class FilteredListViewModel
{
    public ObservableRangeCollection<Product> DisplayedProducts { get; }

    private List<Product> allProducts;
    private string filterText;
    private bool showInStockOnly;

    public FilteredListViewModel()
    {
        DisplayedProducts = new ObservableRangeCollection<Product>();
    }

    public string FilterText
    {
        get => filterText;
        set
        {
            filterText = value;
            OnPropertyChanged();
            ApplyFilter();
        }
    }

    public bool ShowInStockOnly
    {
        get => showInStockOnly;
        set
        {
            showInStockOnly = value;
            OnPropertyChanged();
            ApplyFilter();
        }
    }

    private void ApplyFilter()
    {
        var filtered = allProducts.AsEnumerable();

        if (!string.IsNullOrWhiteSpace(FilterText))
        {
            filtered = filtered.Where(p =>
                p.Name.Contains(FilterText, StringComparison.OrdinalIgnoreCase));
        }

        if (ShowInStockOnly)
        {
            filtered = filtered.Where(p => p.Stock > 0);
        }

        // Update UI with single notification
        DisplayedProducts.ReplaceRange(filtered);
    }

    public async Task LoadProductsAsync()
    {
        allProducts = await productService.GetAllAsync();
        ApplyFilter();
    }
}

Batch Operations

public class BatchOperationsViewModel
{
    public ObservableRangeCollection<TodoItem> TodoItems { get; }

    public BatchOperationsViewModel()
    {
        TodoItems = new ObservableRangeCollection<TodoItem>();
    }

    public void CompleteSelected(List<TodoItem> selectedItems)
    {
        foreach (var item in selectedItems)
        {
            item.IsCompleted = true;
        }

        // Trigger UI update (items are reference types)
        OnPropertyChanged(nameof(TodoItems));
    }

    public void DeleteCompleted()
    {
        var completed = TodoItems.Where(x => x.IsCompleted).ToList();

        // Single notification for removing multiple items
        TodoItems.RemoveRange(completed);
    }

    public void MarkAllComplete()
    {
        foreach (var item in TodoItems)
        {
            item.IsCompleted = true;
        }

        OnPropertyChanged(nameof(TodoItems));
    }

    public async Task ImportItemsAsync(string filePath)
    {
        var importedItems = await LoadFromFileAsync(filePath);

        // Add all imported items with single notification
        TodoItems.AddRange(importedItems);
    }
}

Real-time Updates

public class NotificationsViewModel
{
    public ObservableRangeCollection<Notification> Notifications { get; }

    private const int MaxNotifications = 100;

    public NotificationsViewModel()
    {
        Notifications = new ObservableRangeCollection<Notification>();

        // Subscribe to real-time updates
        notificationHub.OnNotificationsReceived += OnNotificationsReceived;
    }

    private void OnNotificationsReceived(List<Notification> newNotifications)
    {
        // Add new notifications
        Notifications.InsertRange(0, newNotifications);

        // Trim to max count if needed
        if (Notifications.Count > MaxNotifications)
        {
            var excess = Notifications.Skip(MaxNotifications).ToList();
            Notifications.RemoveRange(excess);
        }
    }

    public void ClearAll()
    {
        Notifications.Clear();
    }

    public void MarkAllAsRead()
    {
        foreach (var notification in Notifications)
        {
            notification.IsRead = true;
        }

        OnPropertyChanged(nameof(Notifications));
    }
}

Performance Benefits

Scenario: Loading 1000 Items

Standard ObservableCollection:

// 1000 CollectionChanged events
// 1000 UI updates
// Slow, janky UI
for (int i = 0; i < 1000; i++)
{
    items.Add(new Item(i)); // Raises event each time
}

ObservableRangeCollection:

// 1 CollectionChanged event
// 1 UI update
// Fast, smooth UI
var batch = Enumerable.Range(0, 1000).Select(i => new Item(i));
items.AddRange(batch); // Single event

Measured Performance

Operation Standard ObservableRange Improvement
Add 100 items ~50ms ~2ms 25x faster
Add 1000 items ~500ms ~5ms 100x faster
Replace 1000 items ~550ms ~8ms 68x faster
Remove 500 items ~250ms ~3ms 83x faster

Best Practices

1. Use for Bulk Operations

✅ Use ObservableRangeCollection when loading or modifying multiple items:

items.AddRange(newItems);  // Good

❌ Don't use standard Add for multiple items:

foreach (var item in newItems)
{
    items.Add(item);  // Bad - causes N notifications
}

2. Prefer ReplaceRange for Full Refresh

✅ Use ReplaceRange to replace entire collection:

items.ReplaceRange(freshData);  // Single notification

❌ Don't clear and add separately:

items.Clear();  // Notification 1
items.AddRange(freshData);  // Notification 2

✅ Collect items to remove, then remove in bulk:

var toRemove = items.Where(x => x.ShouldRemove).ToList();
items.RemoveRange(toRemove);  // Single notification

❌ Don't remove one by one:

foreach (var item in items.Where(x => x.ShouldRemove).ToList())
{
    items.Remove(item);  // Multiple notifications
}

4. Use with MVVM

ObservableRangeCollection works seamlessly with MAUI data binding:

public class ViewModel : INotifyPropertyChanged
{
    // Property for binding
    public ObservableRangeCollection<T> Items { get; }

    public ViewModel()
    {
        Items = new ObservableRangeCollection<T>();
    }
}

Common Patterns

Load and Display Pattern

public async Task LoadDataAsync()
{
    IsBusy = true;
    try
    {
        var data = await service.GetDataAsync();
        Items.ReplaceRange(data);
    }
    finally
    {
        IsBusy = false;
    }
}

Filter and Update Pattern

private void UpdateDisplayedItems()
{
    var filtered = allItems
        .Where(ApplyFilters)
        .OrderBy(x => x.Name);

    DisplayedItems.ReplaceRange(filtered);
}

Incremental Load Pattern

public async Task LoadMoreAsync()
{
    var nextBatch = await service.GetNextPageAsync();
    Items.AddRange(nextBatch);
}

See Also