☰
FireMonkey StringGrid点击表头排序:从事件绑定到数据重排的实现
2026/10/13 19:22:48 网站建设 项目流程

简介:Delphi XE FireMonkey FMX 环境下的 StingGrid(StringGrid)表格控件点击表头排序实现资源,面向需要为桌面或移动应用增加数据表格交互排序功能的 Delphi 开发者。压缩包共 72 个文件、约 7.71MB,包含 pas 源码、dfm/fmx 界面文件、dcu 编译单元、dpr 工程文件及 exe 示例程序等,完整覆盖 VCL 与 FMX 两套实现。包内 string_grid_sort_vcl 与 string_grid_sort_fmx 两个工程目录结构清晰,可直接打开工程对照学习;核心代码 sort_stringgrid.pas、Unit1.pas 演示了列头点击事件捕获、排序方向切换以及与 TClientDataSet 等数据源 SortFields 的结合方式,同时提供 Android 部署配置与 Win32 编译产物,便于跨平台验证。目前已有 431 人学习下载,适合正在使用 Delphi XE 开发数据展示界面、希望快速掌握 FMX 表格排序技巧的中高级开发者参考。

1. 点击表头排序,为什么 FMX StringGrid 不给你现成的

标题里的 StingGrid 其实就是 FireMonkey 的 TStringGrid,少一个 r 的拼写在检索里反而常见。做 Delphi XE 桌面工具的人,第一次在 FMX 里放 StringGrid 都会下意识找排序属性:VCL 里 DBGrid 点表头虽然不是开箱即用,但至少还有事件能接;FMX 这张表倒好,表头点下去毫无反应,连个 OnTitleClick 都不给你。这个题目要解决的,就是把“点击表头 → 取出排序列 → 升降序切换 → 整行重排”这四件事在 FireMonkey 里手动补完。适合未绑定 DataSource 的手工填表、行数几百到几千的小型管理界面;不适合几万行的大数据集。下文按数据模型、事件挂载、排序实现、比较规则、常见坑的顺序展开,代码以 Delphi XE 可编译为准。

2. FMX StringGrid 的存取模型:Cells、RowCount 与 Header

想排序,先搞清楚你要动的是谁的数据。FMX 的 TStringGrid 和 VCL 的 TStringGrid 除了名字像,数据模型完全不同。VCL 里直接操作 StringGrid.Cells[Col, Row] 这种行列二维数组,FMX 则把数据按列拆开,每一列是一个 TStringColumn 对象,里面维护一个字符串数组。这个差异决定了排序时不能直接写行列交换,必须绕一层。下面三节把存取、事件、填表三件事分别讲清楚。

2.1 TStringColumn.Cells 是数据本体,不是界面上那一格

在未绑定模式下,StringGrid 的数据本体是 TStringColumn.Cells 数组。通过(StringGrid1.Columns[0] as TStringColumn).Cells[2]才能读到第三行第一列的文本,而不是直接读什么 Row 对象。这个数组的行数由 StringGrid 的 RowCount 属性决定,RowCount 不是“当前显示行数”,而是这张表当前维护的总行数,默认值是 3。很多人第一次写 Cells 前不设 RowCount,结果索引越界,或者写进去的值后面一滚动就丢了,原因就是没理解 RowCount 才是数组长度。

为什么要强调“列优先”?因为排序算法习惯按行思考,但 FMX 的存储偏偏按列组织。这诱导不少人写出“逐行交换两个 Cell”的代码,逻辑上确实能跑,但一旦漏掉某一列,整行数据就错位了。正确思路是先把 Cells 理解成若干列字符串数组,排序时排“行号数组”,最后统一重写每一列,这样永远不担心漏列。

2.2 Header 不是字符串,是运行时控件:事件该挂在哪

TColumn.Header 只是表头显示的文本,它不接收鼠标事件。真正渲染表头、响应点击的是一个叫 Header 的运行时对象,类型是 THeader。StringGrid 在样式应用阶段才创建 Header,所以你在 FormCreate 里写StringGrid1.Header.OnColumnClick := Xxx经常拿到空引用,这不是代码写错,是对象还不存在。

常见做法是放到 FormShow 之后再挂:

procedure TForm1.FormShow(Sender: TObject); begin // Header 此时已经建好,可以安全挂事件 StringGrid1.Header.OnColumnClick := DoHeaderClick; end;

FormShow 时样式已经应用过,Header 不会是 nil。如果你是在 FormCreate 里动态创建 StringGrid,那就要在创建完成后先调用一次 ApplyStyle,再挂事件,不然还是会踩空。我这边的习惯是坚持在 FormShow 挂,简单且不会因为样式重建而丢事件。重复 Show 时同一方法再次赋值不会有副作用,事件还是那一个,不会叠加。

2.3 先填数据再谈排序:RowCount、列类型与 BeginUpdate

填数据这一步看起来简单,但 RowCount 和列类型转换是后面所有排序的地基。下面用 TStringList 做演示数据源,Name=Value 的格式,分别填到两列:

procedure TForm1.FillGrid(const AData: TStringList); var I: Integer; ColName, ColValue: TStringColumn; begin StringGrid1.BeginUpdate; try // 行数由数据条数决定,必须先设置 StringGrid1.RowCount := AData.Count; for I := 0 to AData.Count - 1 do begin ColName := StringGrid1.Columns[0] as TStringColumn; ColValue := StringGrid1.Columns[1] as TStringColumn; if ColName <> nil then ColName.Cells[I] := AData.Names[I]; if ColValue <> nil then ColValue.Cells[I] := AData.ValueFromIndex[I]; end; finally StringGrid1.EndUpdate; end; end;

RowCount 必须在循环前设置,它决定 Cells 数组的可用索引范围。as 转换后要判空,因为设计器里某列可能被换成 TProgressColumn 或其他类型,直接强转会抛异常。BeginUpdate 不是所有 FMX 版本都有,如果你用的 XE 早期版本找不到这个方法,删掉这两行也能跑,只是行数上千时重绘会明显卡顿。还有一个小技巧:循环里反复StringGrid1.Columns[0]会做多次类型转换,数据量大时可以把列对象缓存在循环外,排序阶段更值得这么做。

3. 点击表头排序:事件绑定、索引排序与箭头切换

数据填好了,接下来是把点击表头变成有序表格。FMX 没有 OnSortColumn,我们就从 Header 事件自己补。实现分三步:绑定事件入口、排行索引数组、按新顺序重写所有列。排序期间用 BeginUpdate 包住,避免每写一格就重绘一次。

3.1 事件分发入口:Header.OnColumnClick 的完整写法

先定义排序状态和表头文本缓存。FSortCol 记录当前排序列索引,-1 表示从未排序;FSortOrder 记录升降序;FHeaderTexts 保存每列原始表头文本,用来之后画箭头和还原:

type TSortOrder = (soAsc, soDesc); // Form 的 private 区域 FSortCol: Integer; FSortOrder: TSortOrder; FHeaderTexts: array of string; procedure TForm1.FormShow(Sender: TObject); begin StringGrid1.Header.OnColumnClick := DoHeaderClick; InitHeaderTexts; end; procedure TForm1.InitHeaderTexts; var I: Integer; begin SetLength(FHeaderTexts, StringGrid1.ColumnCount); for I := 0 to StringGrid1.ColumnCount - 1 do if StringGrid1.Columns[I] <> nil then FHeaderTexts[I] := StringGrid1.Columns[I].Header; end; procedure TForm1.DoHeaderClick(Sender: TObject; const Column: TColumn); begin if Column = nil then Exit; if FSortCol = Column.Index then begin // 同一列:切换升降序 if FSortOrder = soAsc then FSortOrder := soDesc else FSortOrder := soAsc; end else begin // 新列:默认升序 FSortCol := Column.Index; FSortOrder := soAsc; end; SortGrid; UpdateHeaderArrows; end;

点击表头空白间隙时 Column 可能是 nil,必须先退出。Column.Index 是列索引,用它而不是缓存对象指针,因为 FMX 表头在样式重建后旧对象可能失效,索引更可靠。点击同一列切换方向,点击新列重置为升序,这是绝大多数表格软件的习惯,用户上手没有学习成本。

3.2 排序不动单元格:只排行索引数组

最容易翻车的写法是双层循环交换 Cells——先读排序列的值,按大小交换两行,再把所有列的对应行一起交换。逻辑上没大错,但代码极易漏列,漏掉任一列整行就对不上。更稳的做法是排“行索引数组”,最后统一重写,漏列问题从根上消失:

procedure TForm1.SortGrid; var Col: TStringColumn; I, J, Tmp: Integer; Idx: array of Integer; begin if (FSortCol < 0) or (FSortCol >= StringGrid1.ColumnCount) then Exit; if not (StringGrid1.Columns[FSortCol] is TStringColumn) then Exit; Col := StringGrid1.Columns[FSortCol] as TStringColumn; // 生成 0..RowCount-1 的行号数组 SetLength(Idx, StringGrid1.RowCount); for I := 0 to StringGrid1.RowCount - 1 do Idx[I] := I; // 插入排序:几百行最稳,不用写复杂快排 for I := 1 to StringGrid1.RowCount - 1 do begin J := I; while (J > 0) and (CompareRows(Idx[J], Idx[J - 1], Col) < 0) do begin Tmp := Idx[J]; Idx[J] := Idx[J - 1]; Idx[J - 1] := Tmp; Dec(J); end; end; ApplyOrder(Idx); // 排序完回到首行,视觉上更直白 StringGrid1.Row := 0; end;

CompareRows 是下一章要给的比较函数,这里只需要它返回负、零、正。Idx 数组保存的是“排序后的原行号”,比如 Idx[0] = 5,表示新第一行是旧第五行。插入排序对几百行足够,几千行就会明显变慢,那就不如换成快速排序,或者干脆走数据集排序。RowCount 为 0 时循环自动跳过,不会崩。

3.3 升降序状态与表头箭头:点击同一列切换,点新列重置

排序后表头如果不给任何提示,用户根本不知道当前按哪列排、是升序还是降序。FMX 的 TColumn 没有 VCL 那种排序指示器,最省事的做法是改 Header 文本:先把所有列还原,再给排序列加一个“ ↑”或“ ↓”:

procedure TForm1.UpdateHeaderArrows; var I: Integer; begin // 先全部还原成原始表头 for I := 0 to StringGrid1.ColumnCount - 1 do if StringGrid1.Columns[I] <> nil then StringGrid1.Columns[I].Header := FHeaderTexts[I]; // 再给排序列追加箭头 if (FSortCol >= 0) and (FSortCol < StringGrid1.ColumnCount) then begin if FSortOrder = soAsc then StringGrid1.Columns[FSortCol].Header := FHeaderTexts[FSortCol] + ' ↑' else StringGrid1.Columns[FSortCol].Header := FHeaderTexts[FSortCol] + ' ↓'; end; end;

“↑”和“↓”是双字节符号,在某些字体下会让列宽多出一点。如果界面把表头文本和列宽卡得很死,可以换成“(升序) / (降序)”或者“A / D”,效果一样,只是没那么直观。FMX 的列宽有时会根据内容自适应,动手改 Header 文本后列宽变化属于正常现象,不用慌。

4. 排序规则:文本、数字、中文和空行怎么处理

排序列本质上是字符串数组,但用户眼里的数据可能是数字、日期、中文。字符串排序“看起来能用”,一遇到 10 和 9 就露馅。这一章把比较规则拆开,照着写就能避开最常见的排序翻车点。

4.1 文本排序:Trim 与 CompareStr 的使用

普通文本列直接用 Trim 去掉首尾空格后 CompareStr。CompareStr 在 Delphi XE 里按 Unicode 编码逐字符比较,速度快、结果稳定。不要拿 Cells 原始值直接比,用户输入的空格会导致“abc”和“ abc”排到不同位置,体验很差。

代码里需要注意,用 AnsiCompareStr 会受系统区域设置影响,中文系统下近似按拼音排,但结果不稳定,换一台英文系统顺序就变了。我一般不用它做排序判据。如果产品确实要求拼音排序,只能对字符串做拼音转码后额外存一个排序键,这属于另一个复杂度,不在本标题的范围内。

4.2 数字列排序:先把字符串转浮点再比

为什么数字列要单独说?因为if SA < SB then这种写法在字符串比较规则下,10 一定排在 9 前面。字符串逐字符比较,'1' 小于 '9',所以“100”排在“9”之前是必然结果,不是玄学。要按数值排,就得先把文本转成 Double。下面这段比较逻辑,既是文本排序也是数字排序的统一入口:

function TForm1.CompareRows(A, B: Integer; Col: TStringColumn): Integer; var SA, SB: string; VA, VB: Double; NumA, NumB: Boolean; begin SA := Trim(Col.Cells[A]); SB := Trim(Col.Cells[B]); // 空行永远沉底,不受升/降序影响 if (SA = '') and (SB = '') then Exit(0); if SA = '' then Exit(1); if SB = '' then Exit(-1); // 两列都能转数字,按数值比 NumA := TryStrToFloat(SA, VA); NumB := TryStrToFloat(SB, VB); if NumA and NumB then begin if VA < VB then Result := -1 else if VA > VB then Result := 1 else Result := 0; end else Result := CompareStr(SA, SB); // 降序在此统一翻转 if FSortOrder = soDesc then Result := -Result; end;

空行分支放在方向翻转之前,目的是让空行在升序和降序时都固定在最后。如果你希望空行排最前,把那两个 Exit 的返回值对调,再挪到方向翻转之后处理,但绝大多数表格场景都是“空数据不许插队”。

TryStrToFloat 使用系统区域设置,中文系统默认小数点没问题;如果程序会部署到欧洲等用逗号做小数点的环境,建议提前创建一个固定的 TFormatSettings,把 DecimalSeparator 设成 '.' 再传入 TryStrToFloat,否则排序在不同机器上会得到不同结果。

4.3 中文排序:不追求拼音,要追求稳定

中文字符串排序是入门者最容易纠结的点。CompareStr 对中文按 Unicode 码点排序,也就是“一”和“聿”的先后看起来没什么规律,但至少结果稳定。对多数内网工具、管理后台、数据导出界面来说,用户真正在意的是点一下表头能变顺序、再点一下能反过来,而不是中文必须按拼音排。

如果硬要拼音序,字符串本身不带读音信息,必须引入拼音库或预先存拼音列,这已经超出“点击表头排序”的范畴。我的原则是:所有比较固定成同一种规则,宁可排序结果“看起来没规律”,也不要不同机器上顺序不一致。稳定比玄学重要。

5. FMX 表头排序常见问题与排查:从事件不触发到数据错行

这一章写给正在改代码但排不出来的读者。每个问题都是我在实际代码里见过或排查过的,按“现象 → 原因 → 解决”写,对照自己的代码逐条查。

5.1 Header 事件一直不触发:挂载时机和 Header 重建

现象:按前面代码写了 FormShow 挂事件,点击表头没任何反应,断点都不进。

原因有两种。一是 Header 对象在挂载时还没创建完,事件挂到了一个旧引用或者空引用上,点击自然不触发。二是运行时动态增删列、切换样式主题触发了 Header 重建,旧的事件丢失。

解决:把挂事件封装成一个独立过程,FormShow 里调用,每次动态增删列后也调用一次。增删列后重新绑定不会重复触发,因为赋值同一个方法只会覆盖,不会叠加。如果还不放心,在事件处理函数里临时加一句弹窗或 Output 输出,确认到底有没有进去。

5.2 点击 A 列却排了 B 列:Index 与 Columns 的对应关系

现象:点“日期”列,结果数据按“数量”列排好了,或者排序列完全对不上。

原因:Header 事件里的 Column 是 Header 内部管理的列对象,有的 FMX 版本它和 Grid.Columns 里的对象是同一批,有的版本只是索引相同、对象不同。如果运行期隐藏列、删除列后再添加,索引对应关系就可能错位。

解决:处理函数里不要直接用传入的 Column 去读 Cells,而是用StringGrid1.Columns[Column.Index]重新取列对象。同时打印一下 Column.Index 和 StringGrid1.ColumnCount,确认索引范围没有越界。这个习惯能帮你快速定位到底是事件问题还是索引问题。

5.3 排序后整行错位:只重写了排序列

现象:第一列按字母排对了,后面的“数量”“备注”列还是原来的顺序,整行数据被拆开了。

原因:排序时只把排序列的 Cells 按新顺序重写,其他列没有跟着动。这是最典型的漏列问题,也是为什么前面反复强调“排索引数组,最后统一重写所有列”。

解决:写一个 ApplyOrder 过程,遍历 StringGrid 的所有列,每一列都按 Idx 数组重写 Cells。哪怕某一列不是 TStringColumn 也要跳过,而不是直接跳过整列数据。这样不管后面加了几列,排序都能把整行一起带走。

5.4 绑定了 DataSource 时改 Cells 无效

现象:表格绑定了 TDataSource 或者活绑定数据,排序时改 Cells 没效果,滚动一下又恢复原样。

原因:绑定模式下显示内容由数据集和绑定系统控制,Cells 数组不一定是数据源头,手工改写只会改到界面层,数据一变就“打回原形”。

解决:绑定模式不要走 Cells 排序。正确做法是排底层数据集:换 ClientDataSet 并设置 IndexName,或者重新执行带 ORDER BY 的查询。如果项目确实需要在内存里排序后再塞回表格,把数据集的记录先读到一个临时数组,解除绑定或改用未绑定模式,再按前面 FillGrid 的方式重填。这个标题的所有代码都是针对未绑定模式,先确认你的表格模式再动手。

5.5 动态增删列后数组越界、箭头错乱

现象:运行期用 AddObject 加了列,或者删了一列,再点表头就报数组越界,或者表头箭头指向错误列。

原因:FSortCol 和 FHeaderTexts 是静态维护的,列数变了,索引数组没有同步。删除排序列后 FSortCol 仍指向旧索引,更新箭头时自然出错。

解决:增删列后重新调用 InitHeaderTexts,重建 FHeaderTexts。删除列时如果被删的正好是排序列,把 FSortCol 重置为 -1,清掉箭头。动态列多的界面,建议在每次列集合变化后统一走一遍“重置排序状态”的过程,不要靠人工记得改索引。

6. 把排序稳定下来:一个可复用流程与两个验证习惯

到这里,核心代码已经齐了,最后给一个能直接用的流程清单,以及我自己的验证习惯。

不建议每次都从零开始写 FMX 排序。我一般按下面十步走,每完成一步就检查一次,能省掉大半排查时间:

  1. 确认 StringGrid 没有绑定 DataSource,绑定模式必须走数据集排序。
  2. 填数据前先设 RowCount,再用 TStringColumn.Cells 填充。
  3. FormShow 里挂 Header.OnColumnClick。
  4. 保存当前所有列原始表头到 FHeaderTexts。
  5. 点击表头时更新 FSortCol 和 FSortOrder。
  6. 生成 0 到 RowCount-1 的行索引数组。
  7. 用统一的 CompareRows 对索引数组排序。
  8. 遍历所有列,按排序后的索引重写 Cells。
  9. 更新表头箭头,排序列加 ↑ 或 ↓。
  10. 排序完成后把 StringGrid.Row 设回 0。

验证习惯上,我会准备两批测试数据。第一批是 10 行混合数据,里面必须有空行、重复值、纯数字和纯文本,连续点击 A 列升序、降序,再切到 B 列,最后切回 A 列,观察空行是否一直垫底、重复值是否稳定、升降序是否正确翻转。第二批是运行期动态删掉排序列、再加一个新列,然后点击新表头,确认不报数组越界、箭头不乱指。这两个用例基本覆盖了 FMX 表头排序八成以上的坑。

我自己在 FMX 项目里被 Header 挂载时机坑过不止一次,后来养成的习惯是一定先确认事件有没有进、Column.Index 是不是自己想的那个,再碰排序数据。排序算法本身不会骗人,会骗人的往往是拿错了列、挂错了事件、写漏了某列数据。按这个流程走,希望你不用重走我这些弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询