Skip to content

Microsoft.UI.Reactor(Reactor)的 DataGrid<T> 是一张虚拟化表格,从一个 IDataSource<T> 惰性渲染行。契约的是数据源,不是数据:它按排序、筛选和搜索状态返回分页,声明自己的 Capabilities(服务端排序?可变更?),并为每一项产出一个稳定的 RowKey。网格是这个契约之上的一层薄视图 —— 它每次渲染都向数据源索取可见窗口,按键对返回的行做差异比对,只渲染发生变化的部分。这与 AG Grid"行数据 + 列定义"那种数组形输入相反,而更接近 TanStack Table 的无头切分思路:数据源拥有数据访问,DataGridState<T> 拥有排序/选择/编辑状态,而网格只是呈现。两列 Column<T>(...) 定义加一个 ListDataSource<T> 包装就是最小可用网格;换成 ObservableListDataSource<T> 就让它变成活的;针对你的 REST 或 GraphQL 端点实现一个自定义 IDataSource<T>,就能在不改动列代码的前提下把它变成服务端驱动的网格。先读数据源那一节 —— 本页其余各节讲的都是网格如何向它索取更多。

数据系统

Reactor 的数据系统提供一个由可插拔数据源抽象支撑的虚拟化 DataGrid<T>。你定义列(或自动生成它们),接上一个数据源,网格就会处理排序、筛选、搜索、选择和行内编辑。

数据源

所有数据都流经 IDataSource<T> —— 一个异步的、基于分页的抽象。你从不把裸列表交给网格;而是把数据包进一个能声明自身能力的数据源:

class DataSourceExample
{
    // 包装一个内存列表 —— 支持客户端排序、筛选、搜索
    static ListDataSource<Product> CreateSource() =>
        new(SampleProducts.Items, p => (RowKey)p.Id);

    // source.Capabilities → Sort | Filter | Search | Count | Mutate
}

ListDataSource<T> 包装一个内存列表并提供客户端的排序、筛选和搜索。对于数据绑定集合,用 ObservableListDataSource<T>,它跟踪 ObservableCollection<T> 的变更并触发 DataChanged

数据源 最适合
ListDataSource<T> 内存列表、本地数据
ObservableListDataSource<T> 可观察集合、实时更新的数据
自定义 IDataSource<T> REST API、数据库、GraphQL 端点

Capabilities 标志就是协商点。一个返回 ServerSort | ServerFilter 的数据源告诉网格:把排序/筛选发送DataRequest 并信任分页响应;一个返回 None 的数据源则接受网格的客户端兜底路径。自定义数据源通常处在两者之间 —— 服务端排序、客户端搜索 —— 而网格对每个标志独立尊重。把 REST 端点包成 IDataSource<T> 而不把 HttpClient 泄漏进组件的模式,参见 async-resources

定义列

Column<T>() 以流畅构造器定义列。每列有一个名称、一个访问器函数和可选配置:

class ExplicitColumnsDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Id", p => p.Id, width: 60),
            Column<Product>("Name", p => p.Name, width: 180),
            Column<Product>("Category", p => p.Category, width: 120),
            Column<Product>("Price", p => p.Price, format: "C2", width: 100),
            Column<Product>("Stock", p => p.Stock, width: 80),
        });

        return DataGrid<Product>(source, columns).Height(400);
    }
}

带显式列的 DataGrid

ColumnBuilder<T> 支持串联:

方法 效果
.Validate(validators...) 为行内编辑挂上校验器
.CellRenderer(fn) 自定义单元格渲染函数
.NotSortable() 禁用该列排序
.Build() 定稿出 FieldDescriptor

Column<T>(...)AutoColumns<T>(...)Microsoft.UI.Reactor.Advanced.Factories 上的静态方法 —— DataGrid 由可选的 Microsoft.UI.Reactor.Advanced 包提供(spec 062 §7)。加上包引用和第二个 using static

<PackageReference Include="Microsoft.UI.Reactor.Advanced" Version="0.1.0-preview.15" />
using static Microsoft.UI.Reactor.Factories;
using static Microsoft.UI.Reactor.Advanced.Factories;

自动生成列

为了快速 prototyping,AutoColumns<T>() 用反射从公开属性生成列:

class AutoColumnsDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var registry = UseMemo(() => new TypeRegistry());

        return DataGrid<Product>(source, registry).Height(400);
    }
}

带自动生成列的 DataGrid

自动生成为自定义类型元数据使用 TypeRegistry(若可用)。要在不必手工定义全部列的情况下微调个别列,可以给 AutoColumns<T>() 传一个 overrides 函数,或给基于 registry 的 DataGrid<T>(source, registry, …) 重载传 columnOverrides:

AutoColumns<T>() 是演示和管理后台的快路径。面向用户的网格请显式定义列 —— 自动生成的列遵循属性顺序(往往任意)、拿属性名当列头(对最终用户常常是错的),并且暴露每一个公开 getter(包括你并不想露出来的那些)。一旦有设计师碰这张网格,就切换到显式列。

排序与筛选

点击列头即可排序。网格把排序委托给数据源 —— ListDataSource 在客户端处理,而自定义数据源可以实现服务端排序:

class SortFilterDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Name", p => p.Name, width: 180),
            Column<Product>("Category", p => p.Category, width: 120),
            Column<Product>("Price", p => p.Price, format: "C2", width: 100),
            Column<Product>("Stock", p => p.Stock, width: 80).NotSortable(),
        });

        return DataGrid<Product>(source, columns, showSearch: true).Height(400);
    }
}

已排序并筛选的网格

筛选使用 FilterDescriptor,其 FilterOperator 枚举有 13 个运算符:EqualsNotEqualsContainsStartsWithEndsWithGreaterThanGreaterThanOrEqualLessThanLessThanOrEqualBetweenInIsNullIsNotNull

启用 showSearch: true 会加一个内置搜索栏,并高亮匹配的单元格。

选择

DataGrid 支持单选与多选模式。选择状态通过 onSelectionChanged 回调上报:

class SelectionDemo : Component
{
    public override Element Render()
    {
        var initialSelection = UseMemo<IReadOnlySet<RowKey>>(() => new HashSet<RowKey>());
        var (selected, setSelected) = UseState(initialSelection);

        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => AutoColumns<Product>());

        return VStack(12,
            TextBlock($"Selected: {selected.Count} items").Opacity(0.6),
            DataGrid<Product>(source, columns,
                selectionMode: SelectionMode.Multiple,
                onSelectionChanged: setSelected).Height(350)
        );
    }
}

多选网格

模式 行为
SelectionMode.None 不可选择(默认)
SelectionMode.Single 一次一行
SelectionMode.Multiple Ctrl+点击、Shift+点击、基于锚点

被选中的行由 RowKey 标识 —— 这是一个由你的数据源 GetRowKey 实现派生出的稳定身份。回调交给你的是完整快照(IReadOnlySet<RowKey>),而不是增删增量 —— 与 ListView 上的多选形状相同。把选择状态提升到父组件,这样它能扛住排序、筛选和刷新;参见下面的带提升选择的主从模式

行内编辑

设置 editable: true 启用行内编辑。有两种编辑模式可用:

class InlineEditingDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Id", p => p.Id, width: 60),
            Column<Product>("Name", p => p.Name, editable: true, width: 180),
            Column<Product>("Price", p => p.Price, editable: true,
                format: "C2", width: 100),
            Column<Product>("Stock", p => p.Stock, editable: true, width: 80),
        });

        return DataGrid<Product>(source, columns,
            editable: true,
            editMode: EditMode.Cell,
            onRowChanged: async (key, product) =>
            {
                // 持久化这次变更 —— 例如调用一个 API
            }).Height(400);
    }
}

行内单元格编辑

模式 行为
EditMode.Cell 一次编辑一个单元格;失焦/回车提交
EditMode.Row 编辑整行;显式的保存/取消按钮

键盘行为随模式而变。在 EditMode.Cell 下,Tab 提交当前单元格并在下一个上打开编辑器。在 EditMode.Row 下,行是工作单元:Enter(或保存按钮、或点击别处)一次性提交所有待定单元格,Esc(或取消)把它们全部丢弃,而 Tab 在行内各可编辑单元格之间循环键盘焦点 —— 只读列被跳过,越过最后一个则绕回第一个 —— 且不提交任何东西。Shift+Tab 反向走同一个循环,从第一个可编辑单元格绕回最后一个,同样从不提交。保存和取消不属于那个 Tab 循环;EnterEsc 是它们的键盘等价物。

打开编辑器会把真实的键盘焦点移进去,因此你可以立刻开始输入:在 EditMode.Cell 下是你打开的那个单元格,在 EditMode.Row 下是焦点环当时所在的那个单元格;若编辑是从该行的 Edit 按钮发起的,则是该行第一个可编辑单元格。

Shift+Tab 在所有适用之处都是 Tab 的镜像:纯导航时它把焦点单元格往后移;而在两种编辑模式下,它提交的东西与 Tab 提交的完全一致 —— EditMode.Cell 下是该单元格,EditMode.Row 下是什么都不提交 —— 区别只在于行走方向。它也镜像 Tab 的触及范围而不只是方向:在 EditMode.Cell 下它走到上一个单元格,不管那个单元格是否可编辑,只在可编辑时才重新打开编辑器;而在 EditMode.Row 下它留在本行内并跳过只读列。

编辑支持校验 —— 通过 Column<T>().Validate() 挂上校验器。onRowChanged 回调在一次成功提交之后触发,收到 RowKey 和更新后的项。对于可变类,网格就地更新;对于记录,它用变更后的值创建一个新实例。校验器目录与表单用的是同一套 —— Validate.Required()Validate.Range(min, max)Validate.Must<T>(predicate) 等等。

列宽调整与重排

用户可以拖拽列边框调整宽度、拖拽列头重排。列状态(宽度、顺序、可见性、固定)由 DataGridState 管理,并可持久化:

class ColumnFeaturesDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Id", p => p.Id, width: 60,
                pin: PinPosition.Left),
            Column<Product>("Name", p => p.Name, width: 200),
            Column<Product>("Category", p => p.Category, width: 140),
            Column<Product>("Price", p => p.Price, format: "C2", width: 120),
            Column<Product>("Stock", p => p.Stock, width: 100),
        });

        return DataGrid<Product>(source, columns).Height(400);
    }
}

列宽调整与固定

把列固定到 PinPosition.LeftPinPosition.Right,让它们在横向滚动时保持可见。在列定义里设置 width 作为初始宽度,或让网格自动定尺寸。

增量分页

对于大数据集,DataPageCache<T> 在用户滚动时按块加载数据。网格为未加载的块显示占位行:

class PagingDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() =>
        {
            var products = Enumerable.Range(1, 10_000)
                .Select(i => new Product(i, $"Product {i}",
                    i % 3 == 0 ? "Electronics" : i % 3 == 1 ? "Furniture" : "Accessories",
                    Math.Round(10 + i * 0.99, 2), i % 200))
                .ToList();
            return new ListDataSource<Product>(products, p => (RowKey)p.Id);
        });

        var columns = UseMemo(() => AutoColumns<Product>());

        // DataPageCache 按需加载 50 行一块,LRU 缓存保留 20 块
        return DataGrid<Product>(source, columns).Height(400);
    }
}

带块加载的增量分页

缓存采用 LRU 淘汰策略 —— 达到 maxBlocks 时,最久未访问的块被淘汰。BlockLoaded 事件在某个块加载完成时触发,引起受影响行的重新渲染。

DataPageCache<T> 遵循拉取模型:网格索取某个行索引,缓存返回已加载的块,或发起获取并返回一个 Loading 占位符。这与 Compose Paging 3 所用的分页形状相同,并且与 VirtualList.onVisibleRangeChanged 的"滚动即获取"模式不同 —— 它以行索引为键,而不是滚动位置。当你想要一个"总数已知"的表面时用 DataPageCache<T>;当你想要一个"总数未知"的无限信息流时用可见范围回调。

行详情

展开个别行以显示额外的详细内容。传入一个 rowDetailTemplate 来渲染每行下方的可展开内容:

class RowDetailsDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => AutoColumns<Product>());

        return DataGrid<Product>(source, columns,
            rowDetailTemplate: (product, key) =>
                VStack(8,
                    TextBlock($"Product ID: {product.Id}").Bold(),
                    TextBlock($"Full details for {product.Name}"),
                    TextBlock($"Category: {product.Category}"),
                    TextBlock($"Unit price: {product.Price:C2}, Stock: {product.Stock}")
                ).Padding(16).Background(Theme.CardBackground)
        ).Height(400);
    }
}

展开的行详情

行详情是惰性渲染的 —— 模板函数只在某行被展开时运行。用它来展示关联数据、内联表单或嵌套网格。

用 DataGridState 做无头测试

DataGridState<T> 是网格内部使用的无头状态机 —— 排序描述符、筛选描述符、选择、聚焦单元格、编辑缓冲区。它没有任何 UI 依赖;你可以在单元测试里针对一个 ListDataSource<T> 构造一个,派发排序/选择调用,然后断言结果状态,而无须挂载网格。这与 TanStack Table 在核心逻辑与呈现之间划出的分离是一致的。大多数应用从不直接碰 DataGridState<T> —— 网格挂载时自己拥有一个 —— 但如果你要发布一个自定义数据层,无头状态就是你的测试应该驱动的东西。与之配对的渲染器 fixture 模式见 testing

注意: 不要在 Render() 里内联构造 ListDataSource<T>。每次渲染都创建一个新实例,网格那个以数据源身份为键的 useMemo 就会失效,分页缓存被清空,滚动位置重置,选择也被清空(选择键是相对数据源GetRowKey 来解释的,而不是相对那些项)。正确的形状是 UseMemo(() => new ListDataSource<T>(items, x => (RowKey)x.Id), items) —— 只有底层列表引用变化时数据源才重建。同样的规则也适用于 AutoColumns<T>() 和显式 Column<T>() 数组:稳定身份很重要。第一个失效症状通常是"我一改任何东西选择就消失" —— 那就是数据源身份在 churn。

模式

带提升选择的主从

网格在左,详情面板在右,父组件持有被选中的键。网格的 onSelectionChanged 直通写入父级状态;详情面板从同一份状态读取。选择能扛住排序变化、筛选变化和刷新,因为那份状态住在网格之外:

class MasterDetailDemo : Component
{
    public override Element Render()
    {
        // 选择住在父级,因此能扛住排序、筛选和刷新。
        var (selected, setSelected) = UseState<RowKey?>(null);

        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Name", p => p.Name, width: 180),
            Column<Product>("Price", p => p.Price, format: "C2", width: 100),
        });

        var detail = SampleProducts.Items.FirstOrDefault(
            p => selected is { } key && (RowKey)p.Id == key);

        return HStack(0,
            DataGrid<Product>(source, columns,
                selectionMode: SelectionMode.Single,
                // 回调交给你的是完整选择快照,而不是增量。
                onSelectionChanged: keys => setSelected(
                    keys.Count == 0 ? null : (RowKey?)keys.First())
            ).Width(480).Height(350),
            detail is null
                ? Border(Caption("Select a product")).Padding(24)
                : VStack(4,
                    SubHeading(detail.Name),
                    Caption($"{detail.Category} — {detail.Price:C2}")
                  ).Padding(24).Width(360));
    }
}

带子网格、乐观更新和异步详情加载的完整模式见 recipes/master-detail 范例。关键的结构点在于:选择状态要比网格活得更久。

通过 ObservableListDataSource 接入实时数据

当你的数据是一个由 UseObservableTree 来源或后台 worker 驱动的 ObservableCollection<T> 时,ObservableListDataSource<T> 就是那座桥。它监听 CollectionChanged,触发 IObservableDataSource<T>.DataChanged,网格则以重新获取可见分页来响应。没有渲染循环 Hook;网格在挂载时订阅、卸载时退订:

class ObservableSourceDemo : Component
{
    public override Element Render()
    {
        // 一个稳定的集合实例。UseMemo 接受工厂方法,因此集合只分配一次;
        // UseRef 会在每次渲染时重新求值其参数,然后把那份副本丢掉。
        var collection = UseMemo(() => new ObservableCollection<Product>(SampleProducts.Items));

        var source = UseMemo(() => new ObservableListDataSource<Product>(
            collection, p => (RowKey)p.Id), collection);

        // 数据源订阅了 CollectionChanged 且是 IDisposable,所以仅仅
        // 记忆化它还不够 —— 卸载时要 dispose,否则集合会一直
        // 把数据源(及其逐项订阅)拽在手里。
        UseEffect(() => () => source.Dispose(), source);

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Name", p => p.Name, width: 180),
            Column<Product>("Stock", p => p.Stock, width: 80),
        });

        return VStack(12,
            // 变更引发 CollectionChanged -> DataChanged -> 网格重新获取。
            Button("Add product", () => collection.Add(new Product(
                collection.Count + 1, "New item", "Accessories", 9.99, 1))),
            DataGrid<Product>(source, columns).Height(320));
    }
}

这个变更模式对 incoming 的服务端推送同样有效(SignalR 信息流、WebSocket 流)—— 在 UI 线程上推进那个可观察集合(参见 threading-and-dispatch),网格自会跟上。

用自定义 IDataSource 做服务端驱动分页

对于藏在分页 REST 或 GraphQL 端点后面的数据,直接实现 IDataSource<T>GetPageAsync(DataRequest request, CancellationToken cancellationToken = default) 通过 DataRequest 收到排序/筛选/搜索状态和所请求的分页偏移,取消令牌作为单独参数;返回一个带各项和 TotalCount(若已知)的 DataPage<T>。把网格挂到你的数据源上 —— 同样的列代码,同样的选择回调。设置 Capabilities = ServerSort | ServerFilter | ServerCount 以退出客户端兜底路径(DataSourceCapabilities 还带有 ServerSearchServerSelectMutateRefresh)。recipes/paginated-list 范例为列表把这个形状从头走到尾;网格的接线方式完全相同。

常见错误

每次渲染都重建数据源

// 不要这样:
public override Element Render()
{
    var source = new ListDataSource<Product>(SampleProducts.Items, p => (RowKey)p.Id);
    return DataGrid<Product>(source, columns);
}
class ExplicitColumnsDemo : Component
{
    public override Element Render()
    {
        var source = UseMemo(() => new ListDataSource<Product>(
            SampleProducts.Items, p => (RowKey)p.Id));

        var columns = UseMemo(() => new FieldDescriptor[]
        {
            Column<Product>("Id", p => p.Id, width: 60),
            Column<Product>("Name", p => p.Name, width: 180),
            Column<Product>("Category", p => p.Category, width: 120),
            Column<Product>("Price", p => p.Price, format: "C2", width: 100),
            Column<Product>("Stock", p => p.Stock, width: 80),
        });

        return DataGrid<Product>(source, columns).Height(400);
    }
}

每次渲染都新建一个 ListDataSource<T>,会 churn 网格的内部身份、清空分页缓存并清掉选择。把数据源构造包在一个以底层数据为键的 UseMemo 里 —— 数据源跨渲染存活,网格保住滚动位置,选择也能在下一次状态更新中存活。

把选择存在网格内部

// 不要这样:
DataGrid<Order>(source, columns,
    selectionMode: SelectionMode.Multiple)
// (没有 onSelectionChanged —— 选择状态只存在于网格内部)

网格确实在内部维护选择,但读取它需要一个 ref,而且那份状态对你组件的其余部分不可见。请把选择提升出来:var initialSelection = UseMemo<IReadOnlySet<RowKey>>(() => new HashSet<RowKey>()) 加上 var (selected, setSelected) = UseState(initialSelection)onSelectionChanged: setSelected。把状态声明为 IReadOnlySet<RowKey> 而不是 HashSet<RowKey> —— 那正是回调的确切参数类型,setter 可以直接绑定。工具栏、徽标和详情面板要读"哪些被选中了",现在都能读到。

在生产环境用 AutoColumns

// 不要这样:
DataGrid<Order>(source, registry)
// —— 依赖属性名当列头、属性顺序当列顺序,
//    并且假定每个公开 getter 都是个合理的列

AutoColumns<T>() 适用于演示和管理工具。生产网格显式定义列:Column<Order>("Order #", o => o.Id, width: 80) —— 列头经过审阅,顺序是刻意的,你也不会不小心把 o.InternalAuditFlag 露出来。多出的这五行,在设计师第一次要求用"Order #"而不是"Id"时就回本了。

提示

ListDataSource 加显式列开始。 自动列和自定义数据源都会增加复杂度。先用一份简单的内存列表把网格跑通,再演进。

电子表格式编辑用 EditMode.Cell 单元格模式对快速编辑更快。当编辑在提交前需要跨多个字段校验时,用 EditMode.Row

固定 ID 或键列。 当可能横向滚动时,把标识列固定住,这样用户始终知道自己在看哪一行。

不可变数据优先用记录。 网格同时支持可变类和不可变记录。记录更简单也更安全 —— 网格会自动创建 with 副本。

等高行请设置 rowHeight 固定高度启用 O(1) 的滚动偏移计算。只有当行高确实参差时才省略它。

下一步

  • WinForms 互操作 —— 下一篇主题:在 WinForms 应用内承载 Reactor 组件
  • 集合 —— 面向非表格数据的更简单列表与网格元素
  • 表单与输入 —— 网格编辑所用的受控输入与校验模式
  • 高级模式 —— 性能调优、错误边界与可观察数据绑定
  • Hooks —— 支撑 DataGrid 内部状态的 Hook 系统