图形界面 X: 排序、重建表格和单元格中的控件 (集成编译 11)(基础篇)
「表格排序与单元控件的基础结构」
在 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 简单,类内自己比较自己重绘即可。
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 微调对盯盘效率有概率上的帮助,但任何界面改动都不构成交易信号。
::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 终端占用也更低。
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 主线程开销,实盘前应在策略测试器里跑一遍确认帧率。
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++) {