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 (
HasMoreItemsandIsLoadingstay consistent across threads and prevent overlapping loads). - Forwards
ItemsChanged,StateChanged,PausedChangedfrom the underlyingListFeed<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 allIListFeed<T>operations viaInnerFeed(Add/Insert/Update/Move/BatchUpdate/Clear/Reset). - Extension:
IFeed<IReadOnlyList<T>>.ToIncrementalListFeed(...)seeds from existing items and reuses feed state.
Best Practices
- Keep batch sizes modest (20–50) to avoid UI stalls.
- Return accurate
hasMoreso the control stops requesting when done. - Always pass the provided
CancellationTokento API calls. - Dispose the feed when the view is torn down.
- Avoid overlapping loads; rely on the built-in gating instead of custom guards.
- When seeding from an existing feed, ensure initial
Valueis 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:
- Selection is tracked by
Id(Guid), not object reference SelectionHelperdetects the Replace operation- 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
- User selects item →
SelectionHelperextracts key viaSelectedValuePath→ pushes toSelectedValuebinding - User deselects →
nullis pushed toSelectedValue - Item is replaced (via
CollectionChanged.Replace):- WinUI clears selection (old reference gone)
SelectionHelperdetects 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:
- The key changes (different item selected)
- 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.