Data sources
The grid reads your data through a count and an indexer, nothing more. Which source you hand it decides whether it follows changes, how it treats a feed, and whether the rows have to be in memory at all.
Choosing a source
| Source | Use it for |
|---|---|
ListItemsSource<T> | A list that does not change while it is shown, or that you replace as a whole. |
ObservableItemsSource<T> | An ObservableCollection and items that raise PropertyChanged. The grid follows adds, removes and property changes. |
KeyedItemsSource<TKey, TValue> | A feed that sends a whole new object per update (market data, telemetry, a message bus). Upsert by key. |
RingItemsSource | An event or log view: append-only, bounded, the oldest rows fall off the front. |
VirtualItemsSource | Rows that are not in memory: the count is known, pages are fetched as they scroll into view. Read-only, sorted and filtered by you. |
RemoteItemsSource | Rows on a server that also sorts and filters: the grid sends queries, the server answers with counts and pages. |
TreeNode roots | A hierarchy, shown by TreeView<T>. |
All of them implement IItemsSource — Count and GetItem(index) — and you can implement it yourself. One rule is part of that contract: GetItem returns null for an index that is out of range and never throws, because the render thread may read through a window the UI thread has just shrunk.
var source = new ListItemsSource<OrderRecord>(orders); // wraps, does not copy
var view = new GridView<OrderRecord>(columns, source).AsDataGrid();Sorting and filtering never touch your list. GridView puts a SortFilterSource in front of whatever you pass, which reorders indexes instead of copying items. For the same reason, view.Source is not the object you passed in: keep your own reference to the source if you need it later.
Live data from a collection
ObservableItemsSource<T> wraps any list that raises INotifyCollectionChanged, and subscribes to INotifyPropertyChanged on its items. A structural change reshapes the view, a property change repaints that one row. You do not call anything after a change.
var orders = new ObservableCollection<Order>(loaded);
var source = new ObservableItemsSource<Order>(orders);
var view = new GridView<Order>(columns, source).AsDataGrid();
orders.Add(new Order { Id = 1001, Customer = "Nord AG" }); // the grid adds the row
orders[0].Amount = 250m; // and repaints this one
using (source.BatchUpdates())
{
foreach (var order in imported)
{
orders.Add(order);
}
} // one structural change for the whole import, not one per rowChange the collection on the UI thread, as with any bound collection. Changes are collected and applied once per frame, so a burst of updates costs one reaction, not one per change. Dispose() the source when the grid goes away; it unsubscribes from the collection and the items.
On the controls, ItemsSource does this for you: an ObservableCollection assigned there is observed automatically, a plain list is read once, and an IItemsSource passes through unchanged. Setting it keeps the columns of the current view. See MVVM and binding.
Feeds that send whole objects
Many feeds do not mutate objects, they send a new one per update. Replacing the item in a collection would lose the selection, the open editor and the scroll anchor on every tick, because those follow the row by object reference. KeyedItemsSource keeps one row object per key and swaps the value inside it, so the row survives its values.
var quotes = new KeyedItemsSource<string, Quote>();
var view = new GridView<KeyedRow<Quote>>(
[
Column.Text<KeyedRow<Quote>>("Symbol", r => r.Value.Symbol).WithKey("symbol"),
TypedColumn.Number<KeyedRow<Quote>>("Price", r => r.Value.Price, format: "N2").WithKey("price"),
TypedColumn.Number<KeyedRow<Quote>>("Change", r => r.Value.Change, format: "+0.00;-0.00;0.00")
.WithKey("change")
.Style(r => r.Value.Change < 0, foreground: Palette.Red),
],
quotes);
// For every message from the feed, on the UI thread:
quotes.Upsert(symbol, new Quote(symbol, price, change));The cost is visible in the column getters — r => r.Value.Price instead of r => r.Price. An update is reported as a change of one item, so the projection re-tests its filter and its sort position; an insert is reported as an append at the end. Remove(key) and Find(key) do what they say.
A grid sorted by a column that changes several times a second would reorder under the reader's eyes. Settling holds row moves back; values still update at once:
view.SettleIntervalMs = 500; // a sorted live grid moves rows at most twice a second
view.SettleSuspended = true; // ... or holds every move, e.g. while a dialog is openLogs and event streams
A stream has no end, so keeping everything is a memory leak with a delay. RingItemsSource keeps at most capacity rows and drops the oldest in blocks — a block rather than one row at a time, because every removal at the front shifts the rows below it.
var log = new RingItemsSource(capacity: 100_000);
var view = new GridView<LogEntry>(logColumns, log);
log.Append(new LogEntry(DateTime.Now, "INFO", "Connected")); // on the UI threadAppend on the UI thread. For data you push on a clock — a simulation, a poll — use grid.Api.Timing.Every rather than a dispatcher timer: its ticks arrive on the UI thread in step with the frames, and the elapsed time it passes in lets a feed keep its speed when a frame is late.
var feed = grid.Api.Timing.Every(TimeSpan.FromMilliseconds(100), elapsed =>
{
// runs on the UI thread, in step with the grid's frames
log.Append(new LogEntry(DateTime.Now, "INFO", $"tick after {elapsed.TotalMilliseconds:0} ms"));
});
feed.Dispose(); // stops itRows that are not in memory
VirtualItemsSource needs the total count up front and a function that returns a range of items. It fetches pages as the renderer asks for visible rows, keeps a bounded number of them, and returns null for rows that have not arrived — those draw as placeholder bars. GetItem never blocks.
var source = new VirtualItemsSource(totalCount, async (start, count) =>
{
List<Order> page = await loadPageAsync(start, count); // HTTP, SQL, ... yours
return page.Cast<object?>().ToList();
}, pageSize: 200);
var grid = new ArionGrid(new GridView<Order>(columns, source));
// A page arrives on the fetch's thread: repaint its rows on the UI thread.
source.RangeLoaded += (first, last) =>
grid.Dispatcher.InvokeAsync(() => grid.InvalidateRange(first, last));
source.FetchFailed += ex => Log(ex.Message);source.RangeLoaded += (first, last) =>
Dispatcher.UIThread.Post(() => grid.InvalidateRange(first, last));RangeLoaded is raised on the fetch's thread. Repainting the arrived rows is the application's step, on the UI thread, as above. This source is read-only and does not sort or filter over the wire: when your query changes, build a new source.
Server-side sorting and filtering
RemoteItemsSource turns the grid's gestures into queries. A click on a header, text in the filter row and the value list of the funnel become a GridQuery — sorts, column filters and value filters — that your IRemoteDataProvider answers with a count, pages, and distinct values with their counts.
public sealed class OrderProvider : IRemoteDataProvider
{
public Task<int> CountAsync(GridQuery query, CancellationToken cancel)
=> Task.FromResult(0); // SELECT COUNT(*) ... WHERE <query.Filters, query.ValueFilters>
public Task<IReadOnlyList<object?>> FetchAsync(GridQuery query, int start, int count,
CancellationToken cancel)
=> Task.FromResult<IReadOnlyList<object?>>([]); // ... ORDER BY <query.Sorts> OFFSET start
public Task<IReadOnlyList<(string Value, int Count)>> DistinctAsync(GridQuery query,
int elementId, CancellationToken cancel)
=> Task.FromResult<IReadOnlyList<(string Value, int Count)>>([]); // the funnel's value list
}var source = new RemoteItemsSource(new OrderProvider(), pageSize: 200);
var view = new GridView<Order>(columns, source).AsDataGrid();
// GridQuery names columns by element id; this is how the server learns which is which.
int amountId = view.ColumnIdOfKey("amount");
source.Dispose(); // when the grid goes away: cancels what is still runningEvery method receives a CancellationToken: a newer query cancels the older one, and a late answer is discarded rather than drawn. Unlike the virtual source, arriving pages repaint on their own. GridQuery.Sorts carries SortDescriptor(ElementId, Descending), Filters carries ColumnFilterDescriptor(ElementId, Op, Text) and ValueFilters carries ValueFilterDescriptor(ElementId, Allowed). To write a source of your own that takes queries, implement IQueryableItemsSource.
Master-detail
A detail band is a full-width row under an expanded data row, drawn from a composition — the same Cell vocabulary as a composed column. Column.Expander<T>() adds the chevron that opens it.
var view = new GridView<Order>(
[
Column.Expander<Order>(),
Column.Text<Order>("Customer", o => o.Customer).WithKey("customer").Fill(),
TypedColumn.Number<Order>("Amount", o => (double)o.Amount, format: "N2").WithKey("amount"),
],
new ListItemsSource<Order>(orders))
.WithRowDetails(88, Cell.Stack(5,
Cell.Text<Order>(o => $"#{o.Id} {o.Customer}", fontSize: 14, weight: FontWeight.SemiBold),
Cell.Text<Order>(o => $"Booked {o.Booked:d}, due {o.Due:d}", fontSize: 12, color: Palette.TextDim),
Cell.Progress<Order>(o => (double)o.Amount / 100_000)));Expansion is kept by item reference, so an open band stays open across sorting, filtering and grouping. From code, address the row by its item, not by a row number that changes with every sort:
grid.Api.Rows.ToggleDetail(order); // open or close the band under this order
grid.Api.Rows.SetDetailExpanded(order, true);
bool open = grid.Api.Rows.IsDetailExpanded(order);Trees
A tree takes TreeNode roots — each with an Item and optional Children — and shows them with TreeView<T>; Column.Tree supplies the indented label column. The construction is on Views. Once it exists:
tree.SetAllCollapsed(true); // fold everything
tree.CompressSingleChildChains = true; // a chain of only-children shows as one row
tree.SetRoots(LoadDepartments()); // new hierarchy, same columnsA node with IsSummary set marks a summary row. Space or a click on the chevron folds a node; the same key folds a group row.
Columns from a type
For a quick start or an admin screen, Column.AutoGenerate<T>() builds one column per public readable property: text, checkbox, number, date and enum columns by property type, editable where there is a public setter, keyed by property name. [ArionColumn] adjusts single properties; [Display] and [Browsable] are honoured too.
public sealed class Customer
{
[ArionColumn(Header = "No.", Width = 70, ReadOnly = true)]
public int Number { get; set; }
public string Name { get; set; } = "";
[ArionColumn(Format = "N2")]
public decimal Balance { get; set; }
[ArionColumn(Ignore = true)]
public string InternalNote { get; set; } = "";
}var columns = Column.AutoGenerate<Customer>(o => o.DateFormat = "yyyy-MM-dd");
var view = new GridView<Customer>(columns, new ListItemsSource<Customer>(customers)).AsDataGrid();It uses reflection once per call and is not trim- or AOT-safe; the columns themselves read through compiled accessors. Declared columns remain the way to get composed cells, styles and summaries.
Replacing the data
The controls take new data without a new view:
grid.ItemsSource = customers; // new data, same columns
grid.SetItems(customers, // data and columns in one call
Column.Text<Customer>("Name", c => c.Name).WithKey("name"));ItemsSource exchanges only the data and keeps the columns; SetItems sets both; SetItemsSource takes an IItemsSource you built.
