Table of Contents

WinUI Integration

DevBitsLab.Feeds.WinUI adds IncrementalListFeed<T> for WinUI virtualized list controls via ISupportIncrementalLoading, now implementing IListFeed<T> / IFeed<IReadOnlyList<T>> for a uniform API and extension support.

Installation

dotnet add package DevBitsLab.Feeds.WinUI

Quick Start (from any IFeed<IReadOnlyList<T>>)

using DevBitsLab.Feeds;
using DevBitsLab.Feeds.WinUI;

public class ProductsViewModel
{
    public IncrementalListFeed<Product> Products { get; }

    public ProductsViewModel(IFeed<IReadOnlyList<Product>> source)
    {
        // Reuse existing feed state; seeds current items when available
        Products = source.ToIncrementalListFeed(async (count, ct) =>
        {
            var items = await Api.GetProductsAsync(skip: Products.Count, take: (int)count, ct);
            return (items, hasMore: items.Count == count);
        });
    }
}

Quick Start (standalone incremental)

using DevBitsLab.Feeds.WinUI;

public class ProductsViewModel
{
    public IncrementalListFeed<Product> Products { get; }

    public ProductsViewModel(IProductService service)
    {
        Products = new IncrementalListFeed<Product>(async (count, ct) =>
        {
            var items = await service.GetProductsAsync(skip: Products.Count, take: (int)count, ct);
            return (items, hasMore: items.Count == count);
        });
    }
}

XAML Binding

IncrementalListFeed<T> implements INotifyCollectionChanged, so you can bind it directly—no ObservableCollection needed.

<ListView ItemsSource="{x:Bind ViewModel.Products}" IsItemClickEnabled="True">
    <ListView.ItemTemplate>
        <DataTemplate x:DataType="models:Product">
            <TextBlock Text="{x:Bind Name}" />
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

The control automatically calls LoadMoreItemsAsync as the user scrolls.

Common Patterns

Page-number pagination

var page = 0;
const int PageSize = 20;
Products = new IncrementalListFeed<Product>(async (count, ct) =>
{
    var items = await api.GetAsync(page: page++, pageSize: PageSize, ct);
    return (items, hasMore: items.Count == PageSize);
});

Cursor pagination

string? cursor = null;
Products = new IncrementalListFeed<Product>(async (count, ct) =>
{
    var result = await api.GetAsync(cursor: cursor, limit: count, ct);
    cursor = result.NextCursor;
    return (result.Items, hasMore: result.NextCursor is not null);
});

Pull-to-refresh

await Products.LoadMoreItemsAsync(0); // optional prefetch
Products.Reset(); // clears items and allows fresh loading

Error handling (surface errors via the feed)

Products = new IncrementalListFeed<Product>(async (count, ct) =>
{
    // Let exceptions bubble; the feed captures them into State/HasError/Error
    var items = await api.GetAsync(skip: Products.Count, take: (int)count, ct);
    return (items, hasMore: items.Count == count);
});

// Observe errors from the feed instead of swallowing them
Products.StateChanged += (s, e) =>
{
    if (Products.HasError && Products.Error is { } error)
    {
        ShowError(error.Message);
    }
};

// You normally don't need a try/catch around LoadMoreItemsAsync; the feed tracks errors.
// Only catch if you must prevent an exception from bubbling (e.g., fire-and-forget),
// but still rely on Products.Error for UI state.

API Notes

  • Implements ISupportIncrementalLoading, INotifyCollectionChanged, IListFeed<T>, IFeed<IReadOnlyList<T>>, IReadOnlyList<T>, IList (for binding).
  • Thread-safe load gating via interlocked flags (HasMoreItems and IsLoading stay consistent across threads and prevent overlapping loads).
  • Forwards ItemsChanged, StateChanged, PausedChanged from the underlying ListFeed<T> for uniform feed semantics.
  • Properties: Count, HasMoreItems, IsLoading, InnerFeed, State, HasError, Error, HasValue, IsPaused, Value.
  • Methods: LoadMoreItemsAsync(count), Reset(), Reset(IEnumerable<T>), Dispose(), plus all IListFeed<T> operations via InnerFeed (Add/Insert/Update/Move/BatchUpdate/Clear/Reset).
  • Extension: IFeed<IReadOnlyList<T>>.ToIncrementalListFeed(...) seeds from existing items and reuses feed state.

Best Practices

  1. Keep batch sizes modest (20–50) to avoid UI stalls.
  2. Return accurate hasMore so the control stops requesting when done.
  3. Always pass the provided CancellationToken to API calls.
  4. Dispose the feed when the view is torn down.
  5. Avoid overlapping loads; rely on the built-in gating instead of custom guards.
  6. When seeding from an existing feed, ensure initial Value is available before constructing if you depend on preloaded items.

SelectionHelper

WinUI's Selector controls (GridView, ListView, etc.) lose selection when an item is replaced in the collection because they track selection by object reference. When you update an immutable record with with { }, the new instance has a different reference, causing selection loss.

SelectionHelper provides attached properties that track selection by key value instead of object reference, automatically preserving selection across item replacements.

The Problem

// When you update an item:
Assets.Update(selectedId, asset => asset with { Name = "Updated" });

// WinUI sees:
// 1. Old Asset instance removed (selection cleared!)
// 2. New Asset instance added
// Result: Selection is lost

The Solution

<GridView
    ItemsSource="{x:Bind ViewModel.Assets}"
    feeds:SelectionHelper.SelectedValue="{x:Bind ViewModel.SelectedAssetId.Value, Mode=TwoWay}"
    feeds:SelectionHelper.SelectedValuePath="Id">
    <!-- ... -->
</GridView>

Now when an item is replaced:

  1. Selection is tracked by Id (Guid), not object reference
  2. SelectionHelper detects the Replace operation
  3. Selection is automatically restored to the new item instance

Complete Example

ViewModel:

public partial class MainViewModel : ObservableObject
{
    [BindableFeed]
    public partial IncrementalListFeed<Guid, Asset> Assets { get; init; }

    [BindableFeed]
    public partial State<Guid?> SelectedAssetId { get; init; }

    [BindableFeed]
    public partial Feed<Asset?> SelectedAsset { get; init; }

    public MainViewModel()
    {
        Assets = IncrementalListFeed<Guid, Asset>
            .Create(LoadAssetsAsync, asset => asset.Id);

        SelectedAssetId = State<Guid?>.FromValue(null);
        
        // SelectFrom automatically updates when the item at the key changes
        SelectedAsset = SelectedAssetId.SelectFrom(Assets);
    }

    [RelayCommand]
    public void UpdateAsset()
    {
        if (SelectedAssetId is { HasValue: true, Value: { } selectedId })
        {
            // O(1) update by key - selection is preserved!
            Assets.Update(selectedId, asset => asset with { Name = "Updated" });
        }
    }
}

XAML:

<Page
    xmlns:feeds="using:DevBitsLab.Feeds.WinUI">

    <Grid>
        <TextBlock>
            <Run Text="Selected:" />
            <Run Text="{x:Bind ViewModel.SelectedAsset.Value.Name, FallbackValue='None', Mode=OneWay}" />
        </TextBlock>

        <GridView
            ItemsSource="{x:Bind ViewModel.Assets}"
            feeds:SelectionHelper.SelectedValue="{x:Bind ViewModel.SelectedAssetId.Value, Mode=TwoWay}"
            feeds:SelectionHelper.SelectedValuePath="Id">
            <GridView.ItemTemplate>
                <DataTemplate x:DataType="models:Asset">
                    <TextBlock Text="{x:Bind Name}" />
                </DataTemplate>
            </GridView.ItemTemplate>
        </GridView>
    </Grid>
</Page>

API Reference

Property Type Description
SelectedValue object? The key value of the selected item. Bind with Mode=TwoWay.
SelectedValuePath string? Property path to extract the key from items (e.g., "Id").

How It Works

  1. User selects itemSelectionHelper extracts key via SelectedValuePath → pushes to SelectedValue binding
  2. User deselectsnull is pushed to SelectedValue
  3. Item is replaced (via CollectionChanged.Replace):
    • WinUI clears selection (old reference gone)
    • SelectionHelper detects the Replace affected the selected key
    • Selection is restored to the new item instance via DispatcherQueue

Comparison with Built-in SelectedValue

WinUI's built-in SelectedValue/SelectedValuePath doesn't work correctly during Replace operations because it clears selection before checking if the new item has the same key. SelectionHelper tracks the last known value and restores it after Replace.

Scenario Built-in SelectionHelper
User selects
User deselects
Item replaced ✗ (selection lost) ✓ (selection preserved)
Item removed

Keyed Incremental List Feed

IncrementalListFeed<TKey, T> combines virtualized loading with O(1) key-based access, perfect for large collections where you need both incremental loading and efficient item lookup/updates.

Creating a Keyed Incremental Feed

// Create with key selector
var assets = IncrementalListFeed<Guid, Asset>.Create(
    loadMoreAsync: async (count, ct) =>
    {
        var items = await api.GetAssetsAsync(skip: assets.Count, take: (int)count, ct);
        return (items, hasMore: items.Count == count);
    },
    keySelector: asset => asset.Id);

// O(1) access by key
var asset = assets[assetId];

// O(1) update by key
assets.Update(assetId, a => a with { Name = "Updated" });

Performance

Operation Complexity Notes
Access by key O(1) Dictionary lookup
Update by key O(1) No linear search
Access by index O(1) Array access
Contains key O(1) Dictionary check
Add item O(1) amortized Automatic key indexing

Combining with SelectFrom

SelectFrom creates a derived feed that updates when either:

  1. The key changes (different item selected)
  2. The item at the key changes (same key, new instance)
// Traditional approach - doesn't update when item changes:
SelectedAsset = SelectedAssetId.Select(id => id.HasValue ? Assets[id.Value] : null);

// SelectFrom approach - updates automatically:
SelectedAsset = SelectedAssetId.SelectFrom(Assets);

See the ListFeedExtensions documentation for more details.