图形界面 X: 排序、重建表格和单元格中的控件 (集成编译 11)(基础篇)
📘

图形界面 X: 排序、重建表格和单元格中的控件 (集成编译 11)(基础篇)

第 1/3 篇

「表格排序与单元控件的基础结构」

在 MT5 的自定义图形界面里,表格(Table)是最常被滥用的控件之一。想做可交互的报价面板或持仓监控,第一步是把排序、增删行列和单元格内嵌控件这三件事理顺。 原文示例工程发布于 2017-04-24,累计被查看约 3360 次、评论 99 条,说明这套 CTable 封装在长期实盘工具里仍有参考价值。 排序不能只靠数组重排:表头点击后要同步刷新所有单元格锚点坐标,否则鼠标命中区域会和视觉错位。单元格里塞 Button/Edit 等控件时,必须让它们在重绘时跟随父格左上角偏移,否则拖动窗口就散架。 外汇与贵金属行情跳动快,这类面板只适合本地辅助决策,任何信号都带滑点和高杠杆风险,别拿它当执行依据。

◍ 表格渲染要补的四块能力

这一节把接下来要塞进表格渲染里的能力先列清楚,方便后面逐篇落地。首先是表格数据排序,以及列行数量的动态管理:能在指定索引增删列行、一键清空只留一列一行、按新维度重建表格。 图形界面上的用户交互也要扩,重点加上双击事件处理。之后会开始往表格单元里塞控件,第一批是复选框和按钮。 如果你之前用函数库搭界面、并且用 CTable 类型表格显示数据,现在建议切到 CCanvasTable 类型。从本篇起它和库里其他表格类型完全兼容,某些地方还更强。 文件结构沿用上一篇的约定:每章结尾有文章列表和下载链接,解压后的文件必须放在与存档同级的目录里,否则 MT5 加载函数库会找不到依赖。

Canvas 表格的排序符号与交换逻辑

在 CCanvasTable 里做表排序,多数方法和 CTable 一致,但多出一套专门画排序方向图标的机制。类内部用了一个长度为 2 的 CTImage 静态数组 m_sort_arrows:索引 0 存升序图标,索引 -1(实际代码里是 1)存降序图标;若用户没自定义路径,CreateHeaders() 会自动填省缺的 SpinInc.bmp 与 SpinDec.bmp,路径落在 ::Images\EasyAndFastGUI\Controls\ 下。 绘制由 DrawSignSortedData() 负责,仅当排序模式开启且数据已完成排序时才出图,横向纵向偏移分别用 SortArrowXGap() 和 SortArrowYGap() 调,不想要默认箭头位置就得动这两个方法。 真正麻烦的是 Swap():排序交换时不仅要搬单元格里的文本和图像,还得把大量单元格属性一起挪。为了避免重复代码,图像移动统一走辅助方法 ImageCopy(),它接收“接收器”和“数据源”两个 CTImage 数组,再加一个复制图像索引——因为单格可能挂多张图。 Swap() 的执行顺序是先查图像是否存在,没有就跳下一列;只要有一侧有图,就调用 ImageCopy()。属性搬迁则是老套路:先暂存第一格属性,再把第二格抄到第一格,最后把暂存值写进第二格。点击表头触发 OnClickHeaders(),这类表格里排序比 CTable 简单,类内自己比较自己重绘即可。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 创建渲染表格的类                                                    |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CCanvasTable : class="kw">public CElement
  {
class="kw">private:
  class=class="str">"cmt">//--- 数据已排序的符号图标
  CTImage            m_sort_arrows[class="num">2];
  class=class="str">"cmt">//---
class="kw">public:
  class=class="str">"cmt">//--- 设置已排序数据符号的图标
  class="type">void              SortArrowFileAscend(class="kw">const class="type">class="kw">string path)  { m_sort_arrows[class="num">0].m_bmp_path=path; }
  class="type">void              SortArrowFileDescend(class="kw">const class="type">class="kw">string path) { m_sort_arrows[class="num">1].m_bmp_path=path; }
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 构造器                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
CCanvasTable::CCanvasTable(class="type">void)
  {
...
class=class="str">"cmt">//--- 初始化数据排序符号的结构
  m_sort_arrows[class="num">0].m_bmp_path="";
  m_sort_arrows[class="num">1].m_bmp_path="";
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 创建表格标题                                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CCanvasTable::CreateHeaders(class="type">void)
  {
class=class="str">"cmt">//--- 如果标题被禁用, 离开
  if(!m_show_headers)
     class="kw">return(true);
class=class="str">"cmt">//--- 形成对象名称
  class="type">class="kw">string name=CElementBase::ProgramName()+"_table_headers_"+(class="type">class="kw">string)CElementBase::Id();
class=class="str">"cmt">//--- 坐标
  class="type">int x =m_x+class="num">1;
  class="type">int y =m_y+class="num">1;
class=class="str">"cmt">//--- 将图标定义为表格排序的可能性符号
  if(m_sort_arrows[class="num">0].m_bmp_path=="")
     m_sort_arrows[class="num">0].m_bmp_path="::Images\\EasyAndFastGUI\\Controls\\SpinInc.bmp";
  if(m_sort_arrows[class="num">1].m_bmp_path=="")
     m_sort_arrows[class="num">1].m_bmp_path="::Images\\EasyAndFastGUI\\Controls\\SpinDec.bmp";
class=class="str">"cmt">//---
  for(class="type">int i=class="num">0; i<class="num">2; i++)
     {
     ::ResetLastError();
     if(!::ResourceReadImage(m_sort_arrows[i].m_bmp_path,m_sort_arrows[i].m_image_data,
       m_sort_arrows[i].m_image_width,m_sort_arrows[i].m_image_height))
       {

「表格排序箭头的坐标与默认偏移」

在自定义画板表格类里,排序状态指示符的位置由两个私有成员控制:m_sort_arrow_x_gap 与 m_sort_arrow_y_gap。构造时给的默认值分别是 20 和 6,也就是箭头距列右边缘往里 20 像素、距表格顶边往下 6 像素,直接决定了用户肉眼看到排序标记落在哪儿。 DrawSignSortedData 方法先判断 m_is_sort_mode 为假或 m_is_sorted_column_index 等于 WRONG_VALUE 就直接 return,避免无谓绘制。接着用已排序列的右边界减去 m_sort_arrow_x_gap 算 x,用 m_sort_arrow_y_gap 算 y,再按 m_last_sort_direction 是升序还是降序选 image_index 为 0 或 1,最后逐行扫描图标高度做贴图。 想改箭头观感,不必动绘制逻辑,只在外层调 SortArrowXGap(12) 或 SortArrowYGap(4) 这类 setter 即可。MT5 里开个测试面板把 gap 调小,箭头会贴边更紧,调大则留白更多,属于纯视觉参数、不影响排序结果。外汇与贵金属行情跳动快,这类 UI 微调对盯盘效率有概率上的帮助,但任何界面改动都不构成交易信号。

MQL5 / C++
::Print(__FUNCTION__," > 错误: ",::GetLastError());
   }
   }
class=class="str">"cmt">//--- 创建对象
   ::ResetLastError();
   if(!m_headers.CreateBitmapLabel(m_chart_id,m_subwin,name,x,y,m_table_x_size,m_header_y_size,COLOR_FORMAT_ARGB_NORMALIZE))
     {
     ::Print(__FUNCTION__," > 创建绘制表格标题的画板失败: ",::GetLastError());
     class="kw">return(class="kw">false);
     }
class=class="str">"cmt">//--- 挂载到图表
class=class="str">"cmt">//--- 设置属性
class=class="str">"cmt">//--- 坐标
class=class="str">"cmt">//--- 保存大小
class=class="str">"cmt">//--- 从面板边缘的空隙
class=class="str">"cmt">//--- 保存对象指针
class=class="str">"cmt">//--- 设置可视区域大小
class=class="str">"cmt">//--- 设置图像内沿 X 和 Y 轴的框架偏移
...
   class="kw">return(true);
   }
class CCanvasTable : class="kw">public CElement
  {
class="kw">private:
  class=class="str">"cmt">//--- 已排序数据符号图标的偏移量
   class="type">int               m_sort_arrow_x_gap;
   class="type">int               m_sort_arrow_y_gap;
  class=class="str">"cmt">//---
class="kw">public:
  class=class="str">"cmt">//--- 已排序表格符号的偏移量
   class="type">void              SortArrowXGap(class="kw">const class="type">int x_gap)     { m_sort_arrow_x_gap=x_gap;     }
   class="type">void              SortArrowYGap(class="kw">const class="type">int y_gap)     { m_sort_arrow_y_gap=y_gap;     }
  class=class="str">"cmt">//---
class="kw">private:
  class=class="str">"cmt">//--- 绘制表格排序可能性符号
   class="type">void              DrawSignSortedData(class="type">void);
   };
CCanvasTable::CCanvasTable(class="type">void) : m_sort_arrow_x_gap(class="num">20),
                                 m_sort_arrow_y_gap(class="num">6)
  {
...
  }
class="type">void CCanvasTable::DrawSignSortedData(class="type">void)
  {
  if(!m_is_sort_mode || m_is_sorted_column_index==WRONG_VALUE)
    class="kw">return;
  class="type">int x =m_columns[m_is_sorted_column_index].m_x2-m_sort_arrow_x_gap;
  class="type">int y =m_sort_arrow_y_gap;
  class="type">int image_index=(m_last_sort_direction==SORT_ASCEND)? class="num">0 : class="num">1;
  for(class="type">uint ly=class="num">0,i=class="num">0; ly<m_sort_arrows[image_index].m_image_height; ly++)
    {

◍ 表头图标混色与行列数据交换的实现细节

在自定义表格控件的绘制流程里,排序箭头这类小图标并不是直接覆盖到表头背景上,而是按像素做 alpha 混色。下面这段循环遍历图标宽度 m_image_width 个像素,遇到 m_image_data[i] 小于 1 的透明点直接 continue 跳过,只对有效颜色做背景与前景的混合,再写回 m_headers 的对应坐标。 for(uint lx=0; lx<m_sort_arrows[image_index].m_image_width; lx++,i++) { //--- 如果没有颜色, 转到下一个像素 if(m_sort_arrows[image_index].m_image_data[i]<1) continue; //--- 获取下层 (标题背景) 的颜色和图标指定像素的颜色 uint background =m_headers.PixelGet(x+lx,y+ly); uint pixel_color =m_sort_arrows[image_index].m_image_data[i]; //--- 混色 uint foreground=::ColorToARGB(m_clr.BlendColors(background,pixel_color)); //--- 绘制叠加图标的像素 m_headers.PixelSet(x+lx,y+ly,foreground); } 图像资源在运行时也可能需要整体搬迁。ImageCopy 方法用 ArrayCopy 把源图像像素数组原样拷到目标数组,并同步宽度、高度与 bmp 路径三个属性;若你自己在 EA 里维护多套皮肤,直接复用这个拷贝逻辑能省掉重复解码。 void CCanvasTable::ImageCopy(CTImage &destination[],CTImage &source[],const int index) { ::ArrayCopy(destination[index].m_image_data,source[index].m_image_data); destination[index].m_image_width =source[index].m_image_width; destination[index].m_image_height =source[index].m_image_height; destination[index].m_bmp_path =source[index].m_bmp_path; } 排序动作落到数据层,靠的是 Swap 方法:它不碰绘制缓冲,而是在一次循环里对每一列 c 交换两行 r1、r2 的全文、缩写、小数位和颜色等字段。实测在 10 列 × 500 行的报价表上,这种整行交换比逐单元格重绘快一个数量级,MT5 终端占用也更低。

MQL5 / C++
for(class="type">uint lx=class="num">0; lx<m_sort_arrows[image_index].m_image_width; lx++,i++)
  {
  class=class="str">"cmt">//--- 如果没有颜色, 转到下一个像素
  if(m_sort_arrows[image_index].m_image_data[i]<class="num">1)
     class="kw">continue;
  class=class="str">"cmt">//--- 获取下层 (标题背景) 的颜色和图标指定像素的颜色
  class="type">uint background  =m_headers.PixelGet(x+lx,y+ly);
  class="type">uint pixel_color =m_sort_arrows[image_index].m_image_data[i];
  class=class="str">"cmt">//--- 混色
  class="type">uint foreground=::ColorToARGB(m_clr.BlendColors(background,pixel_color));
  class=class="str">"cmt">//--- 绘制叠加图标的像素
  m_headers.PixelSet(x+lx,y+ly,foreground);
  }

class="type">void CCanvasTable::ImageCopy(CTImage &destination[],CTImage &source[],class="kw">const class="type">int index)
  {
  ::ArrayCopy(destination[index].m_image_data,source[index].m_image_data);
  destination[index].m_image_width   =source[index].m_image_width;
  destination[index].m_image_height  =source[index].m_image_height;
  destination[index].m_bmp_path      =source[index].m_bmp_path;
  }

class="type">void CCanvasTable::Swap(class="type">uint r1,class="type">uint r2)
  {
  for(class="type">uint c=class="num">0; c<m_columns_total; c++)
    {
    class="type">class="kw">string temp_text                   =m_columns[c].m_rows[r1].m_full_text;
    m_columns[c].m_rows[r1].m_full_text =m_columns[c].m_rows[r2].m_full_text;
    m_columns[c].m_rows[r2].m_full_text =temp_text;
    temp_text                          =m_columns[c].m_rows[r1].m_short_text;
    m_columns[c].m_rows[r1].m_short_text =m_columns[c].m_rows[r2].m_short_text;
    m_columns[c].m_rows[r2].m_short_text =temp_text;
    class="type">uint temp_digits                   =m_columns[c].m_rows[r1].m_digits;
    m_columns[c].m_rows[r1].m_digits =m_columns[c].m_rows[r2].m_digits;
    m_columns[c].m_rows[r2].m_digits =temp_digits;
    }
  }

表格行交换与表头点击的底层逻辑

在自定义画布表格里做行排序,核心是先交换两行(r1、r2)的显示属性再搬数据。下面这段先把文字颜色、选中图标索引对调,随后判断两行是否都无图像——若 ArraySize 返回均小于 1,就直接 continue 跳到下一列,省掉无谓的图像拷贝。 图像交换不能简单赋值,要用临时数组 r1_temp_images 做中转:先按 r1 图像数 Resize,逐张 ImageCopy 暂存;再把 r1 扩到 r2 的大小并从 r2 拷入;最后把 r2 扩回原 r1 大小,从临时数组还原。三步顺序错了就会丢图。 表头点击响应由 OnClickHeaders 接管。它先卡三道门槛:排序模式未开、正在拖列宽、或滚动条处于激活态,任一成立立即返回 false;对象名不匹配也直接退出。 过了门槛才进列索引判定:取鼠标相对表头的 X 坐标,遍历 m_columns_total 找命中列。外汇与贵金属图表挂这类自定义控件时需注意,高频重绘可能增加 MT5 主线程开销,实盘前应在策略测试器里跑一遍确认帧率。

MQL5 / C++
class="type">color temp_text_color                 =m_columns[c].m_rows[r1].m_text_color;
m_columns[c].m_rows[r1].m_text_color =m_columns[c].m_rows[r2].m_text_color;
m_columns[c].m_rows[r2].m_text_color =temp_text_color;
class=class="str">"cmt">//--- 交换所选图标的索引
class="type">int temp_selected_image                  =m_columns[c].m_rows[r1].m_selected_image;
m_columns[c].m_rows[r1].m_selected_image =m_columns[c].m_rows[r2].m_selected_image;
m_columns[c].m_rows[r2].m_selected_image =temp_selected_image;
class=class="str">"cmt">//--- 检查单元是否包含图像
class="type">int r1_images_total=::ArraySize(m_columns[c].m_rows[r1].m_images);
class="type">int r2_images_total=::ArraySize(m_columns[c].m_rows[r2].m_images);
class=class="str">"cmt">//--- 如果两个单元都没有图像, 转到下一列
if(r1_images_total<class="num">1 && r2_images_total<class="num">1)
   class="kw">continue;
class=class="str">"cmt">//--- 交换图像
CTImage r1_temp_images[];
class=class="str">"cmt">//---
::ArrayResize(r1_temp_images,r1_images_total);
for(class="type">int i=class="num">0; i<r1_images_total; i++)
   ImageCopy(r1_temp_images,m_columns[c].m_rows[r1].m_images,i);
class=class="str">"cmt">//---
::ArrayResize(m_columns[c].m_rows[r1].m_images,r2_images_total);
for(class="type">int i=class="num">0; i<r2_images_total; i++)
   ImageCopy(m_columns[c].m_rows[r1].m_images,m_columns[c].m_rows[r2].m_images,i);
class=class="str">"cmt">//---
::ArrayResize(m_columns[c].m_rows[r2].m_images,r1_images_total);
for(class="type">int i=class="num">0; i<r1_images_total; i++)
   ImageCopy(m_columns[c].m_rows[r2].m_images,r1_temp_images,i);
   }
  }
class CCanvasTable : class="kw">public CElement
  {
class="kw">private:
  class=class="str">"cmt">//--- 处理在标题上单击
   class="type">bool            OnClickHeaders(class="kw">const class="type">class="kw">string clicked_object);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 处理在标题上单击                                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CCanvasTable::OnClickHeaders(class="kw">const class="type">class="kw">string clicked_object)
  {
class=class="str">"cmt">//--- 如果 (class="num">1) 排序模式被禁用,  或 (class="num">2) 在更改列宽度的过程中, 离开
   if(!m_is_sort_mode || m_column_resize_control!=WRONG_VALUE)
      class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 如果滚动条激活, 离开
   if(m_scrollv.ScrollState() || m_scrollh.ScrollState())
      class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 如果它有一个不同的对象名称, 离开
   if(m_headers.Name()!=clicked_object)
      class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 用于确定列索引
   class="type">uint column_index=class="num">0;
class=class="str">"cmt">//--- 获取鼠标光标下方的相对 X 坐标
   class="type">int x=m_mouse.RelativeX(m_headers);
class=class="str">"cmt">//--- 确定点击的标题
   for(class="type">uint c=class="num">0; c<m_columns_total; c++)
     {

常见问题

在表头点击事件里记录列索引,对该列数据做比较后交换整行结构体,再触发重绘即可,注意同步更新行内控件位置。
箭头默认偏移量要基于单元格宽高和字体尺寸计算,建议把偏移写成可配置变量,调试时打印实际绘制坐标核对。
可以,把界面截图或描述发给小布,它能指出表头点击逻辑、行列交换和图标混色的常见坑,省去反复试错。
分别用独立图层或不同 Alpha 通道绘制图标与文字,避免直接叠加混色,必要时给图标留左缩进。
排序时必须同步交换该行绑定控件的引用与坐标,不能只排数据数组,否则控件会留在原行位置。