☰
Delphi FMX StringGrid表头点击排序:从OnHeaderClick到混合类型排序
2026/10/14 17:40:26 网站建设 项目流程

简介:面向Delphi XE开发者的FireMonkey(FMX)表格排序实现方案,覆盖StringGrid组件点击表头触发升序/降序切换的完整交互逻辑,包含列点击事件监听、排序列识别、排序方向切换及数据源刷新等要点,适合需要在桌面或移动端FMX应用中快速实现数据列排序的初中级Delphi程序员。包体共72个文件,约7.71MB,以.pas/.dpr源文件、.dfm/.fmx界面定义、.dcu/.exe编译产物及.dproj/.deployproj工程配置为主,同时提供VCL与FMX双版本示例,便于对照迁移。已有431人学习浏览。资源内含可直接运行的Demo工程,附带完整源码、界面定义和可执行文件,演示排序状态记录、数据源Sort操作与列头排序指示符更新等关键代码;目录结构清晰区分VCL与FMX平台,并保留项目历史与恢复文件,可作为实际项目移植或教学参考。

1. 点击表头排序这个诉求,真的没那么简单

在 Delphi 的 FireMonkey 框架里做桌面或移动端表格,StringGrid 是绕不开的控件。不少开发者第一反应是它自带排序,结果把Options翻了个底朝天也没找到 Sort 属性,最后只能自己折腾。这份资源的核心就是补上这个缺口:给 FMX StringGrid 加一个「点击表头自动排序」的功能,并且处理了字符串、数值、日期混合的排序场景。适合刚转 FMX 的 Win32 老手,也适合被 TListView 折磨后想换表格方案的移动端开发者。先说结论:FMX 的 StringGrid 确实没有内置的 OnSort 事件,但通过OnHeaderClick加一个自定义排序函数,完全能做出干净利落的效果,而且代码量控制在百行以内——前提是你要绕过几个隐藏比较深的坑。

2. 先搞清楚 FMX StringGrid 的排序入口:OnHeaderClick 事件捕捉机制

很多人卡在第一步:不知道怎么判断用户点的是哪一列的表头。FMX 的 TStringGrid 继承自 TCustomGrid,它的表头其实是一组 THeader 对象,和传统 TDBGrid 的 OnTitleClick 事件不同,FMX 用的是OnHeaderClick,这个事件挂在 StringGrid 本身上,但默认情况下你根本触发不了——因为 HotTrack 和 HeaderOptions 的配置有讲究。

2.1 OnHeaderClick 事件的触发条件与参数解析

先看事件签名:

procedure TForm1.StringGrid1HeaderClick(Column: TColumn);

这个事件返回的是TColumn对象,也就是你点击的那一列。拿到 Column 之后,通过Column.Index就能知道用户点的是第几列。但有个前提:必须开启表格的HeaderOptions里的Clickable属性,否则事件不会被抛出。

关键代码是这样:

procedure TForm1.FormCreate(Sender: TObject); begin // 启用表头点击 StringGrid1.HeaderOptions.Clickable := True; // 可选:让表头有高亮悬停效果 StringGrid1.HeaderOptions.Highlight := True; end;

逻辑说明:HeaderOptions是 TGridHeaderOptions 类型,Clickable决定表头是否能被点击并触发 OnHeaderClick,Highlight控制鼠标悬停时是否有高亮反馈。两个属性都建议打开,前者是功能开关,后者是交互提示,否则用户不知道表头可以点。

参数说明:如果你用的是 Delphi 10.3 以上版本,HeaderOptions还多了StyleClass和Height可调;但在 XE 老版本里只有 Clickable、Highlight、AutoHeight 这几个选项,注意版本差异。

2.2 列对象的坑:TColumn 不等于 TStringColumn

OnHeaderClick事件返回的 Column 参数,它的实际类型可能不是TStringColumn,而是TCheckColumn、TProgressColumn之类。在 FMX 里,StringGrid 的列类型是独立的类,继承自TColumn。

如果你在排序函数里直接用Column as TStringColumn,遇到非文本列就会在运行时抛异常。我一般会先做一个类型防护:

procedure TForm1.StringGrid1HeaderClick(Column: TColumn); var StrCol: TStringColumn; begin // TStringColumn 才是文本列,其他类型跳过 if Column is TStringColumn then begin StrCol := Column as TStringColumn; // 调用排序逻辑,StrCol.Index 就是列序号 SortGridByColumn(StrCol.Index, StrCol); end; end;

这样写的好处是:以后你往表格里加复选框列或进度条列,点击它们表头时不会触发排序,避免误操作。排序函数接收StrCol.Index作为列号,这个 Index 从 0 开始,和StringGrid1.Cells[Col, Row]的列参数一一对应。

2.3 排序状态追踪:升序/降序切换的元数据管理

一个实用的排序表格必须有升序/降序切换,而 FMX 没有内置的排序状态属性,你需要自己加成员变量来标记:

type TForm1 = class(TForm) // ... 已有声明 private FSortColumn: Integer; // 当前排序列 FSortAscending: Boolean; // 当前是否升序 end;

在 FormCreate 里初始化:

procedure TForm1.FormCreate(Sender: TObject); begin FSortColumn := -1; // -1 表示尚未排序 FSortAscending := True; end;

然后在 HeaderClick 事件里写判断逻辑:点击同一列则反转方向,点击新列则默认升序。这是很自然的交互习惯,但在代码里很多人忘了重置方向,导致第二次点击另一列时出现诡异的降序排列。

3. 排序核心改造:StringList 的 CustomSort 与值类型识别

排序的具体实现在很多博客上有不同版本,但大部分只是把字符串拿来 CompareText,导致数值排序变成字典序:2 排在 10 后面,这就是最常见的「排序无效」翻车现场。这一章给出一个成熟的排序函数,能把字符串、整数、浮点数、日期分开处理。

3.1 用 TStringList.Sort 还是自写快速排序

先把数据塞进 TStringList,每行对应一行原始数据。关键点是:TStringList的CustomSort接受一个排序回调函数,这个回调能拿到两个条目做比较,但默认条目只是字符串,丢失了「类型」信息,比较逻辑就会退化。

我的做法是:在填充 TStringList 时,把原始行号编码进Objects属性,比较时再从原表取值,根据列数据的特征动态判断类型:

function CompareRows(List: TStringList; Index1, Index2: Integer): Integer; var Val1, Val2: string; begin // 从原始表格取值 Val1 := List[Index1]; Val2 := List[Index2]; // 先尝试按数字比较 if TryStrToFloat(Val1, FVal1) and TryStrToFloat(Val2, FVal2) then Result := CompareValue(FVal1, FVal2) else // 否则回退到文本比较 Result := AnsiCompareText(Val1, Val2); end;

逻辑说明:TryStrToFloat是 Delphi 自带的字符串转浮点函数,转换成功说明这一列是数值型,用浮点比较;转换失败就用AnsiCompareText做不区分大小写的文本比较。CompareValue是 Math 单元里的函数,返回 -1、0、1 三个值,正好适合排序。这里用浮点比较会损失精度,但因为表格数据一般都只有几位小数,能接受。

3.2 完整排序函数:支持升序降序和原始行号还原

排序完不能只排 TStringList,还要把行数据重新写回 StringGrid。这里有个容易被忽视的细节:StringGrid1.RowCount必需大于等于数据行数,否则写入会越界。

procedure TForm1.SortGridByColumn(ColIndex: Integer; Col: TStringColumn); var SortList: TStringList; I, Row: Integer; begin // 参数校验 if StringGrid1.RowCount <= 1 then Exit; // 1. 构建待排序列表 SortList := TStringList.Create; try // 第一行是标题行,从第 1 行开始 for Row := 1 to StringGrid1.RowCount - 1 do begin // 保存整行数据,用列分隔符拼接 SortList.AddObject( StringGrid1.Cells[ColIndex, Row], TObject(Row) ); end; // 2. 执行自定义排序 SortList.CustomSort(CompareRows); // 3. 若降序,反转顺序 if not FSortAscending then // 注意:这里要遍历一半交换 for I := 0 to (SortList.Count div 2) - 1 do SortList.Exchange(I, SortList.Count - 1 - I); // 4. 把排序后的数据写回表格 for I := 0 to SortList.Count - 1 do begin // 每一行数据从原行号逐列拷贝 Row := Integer(SortList.Objects[I]); for ColIndex := 0 to StringGrid1.ColumnCount - 1 do StringGrid1.Cells[ColIndex, I + 1] := StringGrid1.Cells[ColIndex, Row]; end; // 5. 更新排序状态 FSortColumn := ColIndex; FSortAscending := not FSortAscending; finally SortList.Free; end; end;

这个函数有四个关键设计:

  • TStringList.AddObject的第二参数是TObject,但 Delphi 的TObject本质是指针,在 Win32 和 Win64 下都能存下 Integer 行号。需要强制转换,注意不要用Pointer(Row),因为TObject(Row)在 64 位下会自动扩展符号,Integer(Row)回读时也一致。
  • 第 4 步写回时,外层循环遍历排序后的索引,内层循环拷贝该行的所有列。这里把ColIndex临时变量重新用来做列遍历,不太优雅但效率最高——你不需要新声明一个变量。
  • 反转操作只在降序时执行,而第 5 步每次都把 FSortAscending 取反,所以下一次点击同列就会变回升序。这是个简洁的切换逻辑。

3.3 日期与中文字符串的比较优化

日期字符串如果格式统一是YYYY-MM-DD,字典序和日期序恰好一致,直接用文本比较没问题。但如果你的数据是DD/MM/YYYY格式,那文本比较就错得离谱了。

针对日期列,我一般会加一个格式探测:

function IsDateString(const S: string): Boolean; var D: TDateTime; begin // 尝试按当前系统短日期格式解析 Result := TryStrToDate(S, D); end;

在 CompareRows 里增加一个日期分支:

if IsDateString(Val1) and IsDateString(Val2) then begin // 转成 TDateTime 再比较 Result := CompareDateTime(StrToDateTime(Val1), StrToDateTime(Val2)); end else if TryStrToFloat(Val1) and TryStrToFloat(Val2) then // 数值比较分支 else // 文本回退

中文排序是个更棘手的问题:AnsiCompareText是按内码比较,在 GBK 编码下「张三」会排在「李四」前面,但这不是拼音序也不是笔画序,纯属编码顺序。如果你需要中文拼音排序,就得引入 ICU 库或者用 Windows API 的 CompareStringOrdinal,这在 FMX 跨平台下还得包一层接口。做桌面中文业务系统时,这个需求常有,但 FMX 没有原生支持,常见办法是维护一个拼音索引列,排序时按拼音列排。我在一个进销存项目的模拟项目中就是这么干的:加一个不可见的拼音列,排序时用拼音列做比较键,然后按主键还原行数据。

4. 后续联动与状态刷新:排序后当前行、选择状态和滚动条的位置陷阱

排序完成后,表格的行号已经变了,但你之前选中的行还在旧行号上,选中的内容就跳到了别的记录。这是排序功能体验感的最大扣分项,不处理的话用户会以为程序出了 bug。

4.1 记录当前行指针并持久化行定位

排序前先记录当前选中行的主键值(比如 ID 列的值),排序后再反查该主键在新行号的位置,重新定位。

procedure TForm1.PreserveSelectionBeforeSort(ColIndex: Integer); var CurrentRow: Integer; IDValue: string; begin CurrentRow := StringGrid1.Selected; if CurrentRow < 1 then Exit; // 假设第 0 列是 ID 列 IDValue := StringGrid1.Cells[0, CurrentRow]; // 存成成员变量,排序结束后使用 FAfterSortRow := FindRowById(IDValue); end;

FindRowById就是遍历列找到匹配项:

function TForm1.FindRowById(const AId: string): Integer; var I: Integer; begin Result := -1; for I := 1 to StringGrid1.RowCount - 1 do if StringGrid1.Cells[0, I] = AId then begin Result := I; Break; end; end;

排序完成后执行定位:

if FAfterSortRow >= 1 then begin StringGrid1.Selected := FAfterSortRow; // 让选中的行滚动到可视区域 StringGrid1.ShowSelected; end;

ShowSelected是 FMX 的 TCustomGrid 的方法,会把当前选中行滚动到视野内。排序后数据量大的表格可能会让选中行跑到可视区域外,不调用这个方法,用户会以为选择丢了。

4.2 排序后的滚动条位置与视觉回归

数据量超过一屏时,排序后滚动条还在原来的位置比例,但内容已经不同了。你可以根据排序前滚动条位置的比例恢复一个近似位置。

var ScrollPosRatio: Single; begin // 排序前记录比例 ScrollPosRatio := StringGrid1.VScrollBar.Value / StringGrid1.VScrollBar.Max; // 排序后恢复 StringGrid1.VScrollBar.Value := ScrollPosRatio * StringGrid1.VScrollBar.Max; end;

这个方案在行数变化越大时越精确,行数接近时用户体验很顺。注意 VScrollBar 在 FMX 里默认是懒加载的,如果表格内容不足一屏,Max可能为 0,除零要判断。

5. 避坑与常见问题:FMX 表头排序的六个典型翻车场景

这一章值得你收藏。我在两个项目里踩过这些坑,都是实际运行后用户在验收阶段反馈的。

5.1 点击表头事件完全没反应

现象:设置好HeaderOptions.Clickable := True,运行后点击表头没有任何反应,OnHeaderClick 事件也没触发。

原因:在 Delphi XE 时代,FMX 的OnHeaderClick事件在部分版本中有 Bug,必须同时把HeaderOptions.Enabled设为 True 才能收到事件。另外,用代码动态创建 StringGrid 时,如果没有调用StringGrid1.RowCount := N,默认行数是 0,表头可能不会显示。

解决:

StringGrid1.HeaderOptions.Enabled := True; StringGrid1.HeaderOptions.Clickable := True;

如果还不行,检查你的 StringGrid 是否被放进了 TScrollBox 或其他容器里,容器截获了鼠标事件。这时候给容器设HitTest := False即可。

5.2 数字列排序结果变成 1, 10, 2, 3

现象:一列存的是「1」「10」「2」「3」,排序后变成「1」「10」「2」「3」这种字典序。

原因:CompareRows里没有做数值识别,直接走文本比较分支。

解决:严格按前面的TryStrToFloat分支处理。还有一个隐蔽问题:TryStrToFloat在部分欧洲语言环境下对小数点分隔符敏感,如果系统区域设置是德语系,1.5会被解析成 15。商用系统建议统一用FormatSettings指定'.'为小数点再解析。

5.3 空值和 NULL 被排到最前面,一列数据乱跳

现象:某列有空字符串,排序之后空值的行全部挤到表格最上方,而且这些行的其他列数据显示错乱。

原因:文本比较时空字符串排在前面,且写回表格时行数据拷贝用的是临时变量,排序后行号索引对不上。

解决:比较函数里对空值单独处理:

if (Val1 = '') and (Val2 = '') then Result := 0 else if Val1 = '' then Result := 1 // 空值排最后 else if Val2 = '' then Result := -1 else // 正常比较分支

把空值沉底,对数值列和文本列都适用。关键是要保证「空值永远在末尾」,不要硬编码在中间位置。

5.4 排序后表格出现空白行或数据丢失

现象:调用排序函数后,表格中间出现了整行空白,或者行数减少。

原因:StringGrid1.RowCount与实际数据行数不一致。如果直接把RowCount设成比数据少的数字,FMX 会自动裁剪尾部行。更隐蔽的是:在排序写回时,如果列的个数和行数不匹配,访问Cells[Col, Row]会访问到未创建的 Cell。

解决:排序函数开头做保护:

if (StringGrid1.RowCount <= 0) or (StringGrid1.ColumnCount <= 0) then Exit;

如果数据行是动态增加的,确保每次 AddRow 后 RowCount 保持同步,不要迷信BeginUpdate/EndUpdate,这两者在 FMX 的 StringGrid 里只对重绘有效,对数据结构没有锁定作用。

5.5 点击表头后整列内容被清空

现象:排序后原数据全丢了,表格里只剩下表头。

原因:常见于排序函数里误写了StringGrid1.Clear或者StringGrid1.Rows[Row].Clear。还有一种可能:你在排序写回时把目标行号写错了,比如从 0 开始写,把表头行覆盖了。

解决:明确区分表头行和数据行的边界——第 0 行永远是表头,数据从第 1 行开始。排序循环所有从 0 到 RowCount 的代码都是安全隐患。写回时Row + 1作目标。

5.6 大表格排序时界面卡死

现象:20000 行数据点击排序,界面冻结 3 到 5 秒,用户体验极差。

原因:排序本身是 O(n log n) 没问题,问题在写回阶段——逐格赋值Cells[Col, Row] := value每次都会触发重绘,两万行十列就是二十万次重绘。

解决:排序前后包一层重绘锁定:

StringGrid1.BeginUpdate; try // 排序与写回 finally StringGrid1.EndUpdate; end;

不奏效的时候(XE 版 BeginUpdate 不完整),改用临时内存数组保存整个表格的二维数据,排序后一次性把数据填充到一个内存网格,再通过StringGrid1.Rows整段赋值。这在 XE5 之后可用,之前只能用 Cells。

6. 进阶玩法:列状态标注、双键排序与表头自定义绘制

基础排序做完,你可能还想要视觉反馈:当前按哪一列排序,升序还是降序。FMX 的 StringGrid 表头默认没有排序箭头,需要自己在 Column 上做小标记。

6.1 用 TColumn.StyleLookup 制作箭头标识

TStringColumn 的StyleLookup可以指定样式类。FMX 自带了一个arrowup和arrowdown风格资源,但在多数平台上并没有直接暴露,常见做法是自己在表头文字后拼一个符号:

procedure TForm1.UpdateHeaderIndicators; var I: Integer; begin for I := 0 to StringGrid1.ColumnCount - 1 do begin // 清掉旧标记 StringGrid1.Columns[I].Header := StringReplace(StringGrid1.Columns[I].Header, ' ↑', '', [rfReplaceAll]); StringGrid1.Columns[I].Header := StringReplace(StringGrid1.Columns[I].Header, ' ↓', '', [rfReplaceAll]); end; // 当前列加箭头 if FSortColumn >= 0 then begin if FSortAscending then StringGrid1.Columns[FSortColumn].Header := StringGrid1.Columns[FSortColumn].Header + ' ↑' else StringGrid1.Columns[FSortColumn].Header := StringGrid1.Columns[FSortColumn].Header + ' ↓'; end; end;

这个方法简单直接,局限是 Header 里的字会变宽,列宽可能跳动。在桌面端无伤大雅,移动端要注意列宽预设。

6.2 多列排序的扩展思路

真实表格里用户经常想排两列:先按部门排,再按工号排。实现思路是记录一个排序列数组,在 CompareRows 回调里依次比较多个列的值,依次判断。这个扩展其实很自然,只要把 SortList 里保存的不再是单列的值,而是一个链表或数组结构即可。

技术上有两种做法:一是用记录类型封装多列值存入 Objects;二是维护全局排序列栈,排序时逐列比较。我个人的偏好是全局排序列栈,因为它改动最小——只在 CompareRows 里加一层循环。

6.3 关于虚拟表格和 Lazy Load 的提醒

如果你将来把 StringGrid 换成 TListView 或第三方网格控件(比如某商业表格控件),排序逻辑需要重新考虑:虚拟表格只加载可视区域的行,不能直接遍历Cells。这时候排序应在数据源层做,把原始数据数组排序后再刷新视图。StringGrid 的 Cells 机制在数据量超过 10 万行时效率很糟糕,我的习惯是超过 5 万行就换 TListView 或者用自绘网格,FMX 的 StringGrid 真不是为大数据设计的。

6.4 性能验证与单元测试建议

排序函数写完,别直接上项目。我一般会搭一个临时窗体,随机生成一万行整数、小数、日期和中文混合数据,点击每一列验证排序是否正确。写一个简单的「排序校验函数」:

function IsSorted(Grid: TStringGrid; Col: Integer): Boolean; var I: Integer; begin Result := True; for I := 1 to Grid.RowCount - 2 do begin // 假定已按浮点分支判断 if Grid.Cells[Col, I] > Grid.Cells[Col, I + 1] then begin Result := False; Break; end; end; end;

这个函数可以在调试时直接调用,确认排序正确性后再移除。也别忘测试空值列——空字符串在>比较时会怎样影响结果。

那次在模拟项目 X 里,就是因为没给空值单独分支,上线后老板的数据里有一列大量空格,排序直接乱掉。从那以后我每次写排序都会强制遍历一遍空值、全角字符和大写混合情况,走完一遍确认无误再交付。希望这套路对你有用。

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

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

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

立即咨询