图形界面 VII: 页面控件 (第二章)·进阶篇
(2/3)· 当面板塞满几十个按钮编辑框,用户点三次就放弃,分页控件正是解这道题的钥匙
「标签页控件的索引越界防护」
在 MT5 自定义面板里做多页签(CTabs)时,当前选中页索引 m_selected_tab 必须被夹紧在合法区间,否则后续按索引访问 m_tab[] 会越界崩溃。 CheckTabIndex() 的做法很直接:用 ArraySize(m_tab) 取页面数组长度,若 m_selected_tab<0 则归零,若 ≥数组长度则置为 array_size-1。这个边界修正应在每次切换页签后调用。 如果组里一个页面都没有(m_tabs_total<1),CreateTabs 相关流程会直接 Print 报错并返回 false,提示至少要用 AddTab() 加一个页。外汇与贵金属 GUI 工具开发属高风险定制,逻辑漏洞可能导致 EA 面板卡死。 下面这段是核心边界函数的原文,逐行看: // 定义检查函数属于 CTabs 类 void CTabs::CheckTabIndex(void) { // 取页面数组 m_tab 的实际大小 int array_size=::ArraySize(m_tab); // 小于 0 就夹到 0 if(m_selected_tab<0) m_selected_tab=0; // 大于等于长度就夹到末位索引 if(m_selected_tab>=array_size) m_selected_tab=array_size-1; }
class="type">void CTabs::CheckTabIndex(class="type">void) { class=class="str">"cmt">//--- 检查是否超出数组范围 class="type">int array_size=::ArraySize(m_tab); if(m_selected_tab<class="num">0) m_selected_tab=class="num">0; if(m_selected_tab>=array_size) m_selected_tab=array_size-class="num">1; }
标签页总宽与背景区坐标的实算逻辑
做 MT5 自定义标签页控件时,背景区尺寸不能直接拿元素总尺寸用,得先由 SumWidthTabs() 算可用页签总宽。左/右布局时该函数只返回首个标签宽度;上下布局则返回所有标签宽度之和再减去 (标签数-1) 个像素重叠量,这是 GUI 像素对齐的常见坑。 CreateMainArea() 里用 switch 分流四种位置模式:TABS_TOP 时主区 y 坐标要下移 m_tab_y_size-1,高度减掉标签高;TABS_BOTTOM 主区从元素原点起、高度同样减标签高;TABS_LEFT / TABS_RIGHT 则是水平方向让出 SumWidthTabs()-1 像素给标签条,主区宽度变为 XSize()-SumWidthTabs()+1。 直接把上面代码塞进你的 CTabs 类就能在 MT5 图表上验证:若标签数 m_tabs_total=5、各宽 60,上下布局 SumWidthTabs()=60*5-(5-1)=296,右布局只返 60。外汇与贵金属图表挂这类控件属高风险环境,对象坐标算错会直接遮住 K 线。
class="type">int width=class="num">0; class=class="str">"cmt">//--- 如果页面是偏左或者偏右的位置,返回第一个页面的宽度 if(m_position_mode==TABS_LEFT || m_position_mode==TABS_RIGHT) class="kw">return(m_tab[class="num">0].m_width); class=class="str">"cmt">//--- 所有页面的总宽度 for(class="type">int i=class="num">0; i<m_tabs_total; i++) width=width+m_tab[i].m_width; class=class="str">"cmt">//--- 考虑到一个像素点的重叠 width=width-(m_tabs_total-class="num">1); class="kw">return(width); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 创建通用背景区域 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTabs::CreateMainArea(class="type">void) { class=class="str">"cmt">//--- 构造对象名称 class="type">class="kw">string name=CElement::ProgramName()+"_tabs_main_area_"+(class="type">class="kw">string)CElement::Id(); class=class="str">"cmt">//--- 坐标 class="type">int x=class="num">0; class="type">int y=class="num">0; class=class="str">"cmt">//--- 大小 class="type">int x_size=class="num">0; class="type">int y_size=class="num">0; class=class="str">"cmt">//--- 计算相对页面位置的坐标和大小 class="kw">switch(m_position_mode) { case TABS_TOP : x =CElement::X(); y =CElement::Y()+m_tab_y_size-class="num">1; x_size =CElement::XSize(); y_size =CElement::YSize()-m_tab_y_size; class="kw">break; case TABS_BOTTOM : x =CElement::X(); y =CElement::Y(); x_size =CElement::XSize(); y_size =CElement::YSize()-m_tab_y_size; class="kw">break; case TABS_RIGHT : x =CElement::X(); y =CElement::Y(); x_size =CElement::XSize()-SumWidthTabs()+class="num">1; y_size =CElement::YSize(); class="kw">break; case TABS_LEFT : x =CElement::X()+SumWidthTabs()-class="num">1; y =CElement::Y(); x_size =CElement::XSize()-SumWidthTabs()+class="num">1; y_size =CElement::YSize(); class="kw">break; } class=class="str">"cmt">//--- 创建对象 if(!m_main_area.Create(m_chart_id,name,m_subwin,x,y,x_size,y_size)) class="kw">return(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="kw">return(true); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 创建页面背景 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTabs::CreateTabsArea(class="type">void) { class=class="str">"cmt">//--- 构造对象名称 class="type">class="kw">string name=CElement::ProgramName()+"_tabs_area_"+(class="type">class="kw">string)CElement::Id(); class=class="str">"cmt">//--- 坐标 class="type">int x=CElement::X(); class="type">int y=CElement::Y(); class=class="str">"cmt">//--- 大小 class="type">int x_size=SumWidthTabs();
◍ 标签页区域与按钮的坐标推导
在 CTabs 的页面区创建逻辑里,y_size 的计算直接受排版模式影响:顶部或底部模式取单个标签高度 m_tab_y_size,而左/右模式则按 m_tab_y_size*m_tabs_total-(m_tabs_total-1) 累加并扣掉标签间的 1 像素缝隙。 底部模式会把 y 重算为 CElement::Y2()-m_tab_y_size-1,右方模式则把 x 推到 CElement::X2()-x_size。这说明多标签控件的锚点永远贴着父元素外缘,而不是从左上角顺推。 CreateButtons() 里单独页面的坐标用前缀和递推:横向模式下 x 取上一个标签宽度减 1 的偏移,纵向模式则 y 按 m_tab_y_size-1 步进。循环里对象名拼成 ProgramName()+"_tabs_edit_"+i+"__"+Id(),方便在 MT5 对象列表里核对每个标签。 把控件挂到某页用 AddToElementsArray(tab_index, object),调用前务必确认 tab_index 小于 m_tabs_total,否则数组越界会让整个面板创建返回 false。开 MT5 跑一遍,观察对象列表里的 _tabs_edit_ 命名是否符合预期。
class="type">int y_size=class="num">0; class=class="str">"cmt">//--- 计算相对页面位置的大小 if(m_position_mode==TABS_TOP || m_position_mode==TABS_BOTTOM) { y_size=m_tab_y_size; } else { y_size=m_tab_y_size*m_tabs_total-(m_tabs_total-class="num">1); } class=class="str">"cmt">//--- 对于页面在底部和在右方的模式调整坐标 if(m_position_mode==TABS_BOTTOM) { y=CElement::Y2()-m_tab_y_size-class="num">1; } else if(m_position_mode==TABS_RIGHT) { x=CElement::X2()-x_size; } class=class="str">"cmt">//--- 创建对象 if(!m_tabs_area.Create(m_chart_id,name,m_subwin,x,y,x_size,y_size)) class="kw">return(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="kw">return(true); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 创建页面 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CTabs::CreateButtons(class="type">void) { class=class="str">"cmt">//--- 坐标 class="type">int x =CElement::X(); class="type">int y =CElement::Y(); class=class="str">"cmt">//--- 计算相对页面位置的坐标 if(m_position_mode==TABS_BOTTOM) y=CElement::Y2()-m_tab_y_size-class="num">1; else if(m_position_mode==TABS_RIGHT) x=CElement::X2()-SumWidthTabs(); class=class="str">"cmt">//--- 计算所选页面的索引 CheckTabIndex(); class=class="str">"cmt">//--- 创建页面 for(class="type">int i=class="num">0; i<m_tabs_total; i++) { class=class="str">"cmt">//--- 构建对象名称 class="type">class="kw">string name=CElement::ProgramName()+"_tabs_edit_"+(class="type">class="kw">string)i+"__"+(class="type">class="kw">string)CElement::Id(); class=class="str">"cmt">//--- 计算每个单独页面上相对页面的坐标 if(m_position_mode==TABS_TOP || m_position_mode==TABS_BOTTOM) x=(i>class="num">0) ? x+m_tab[i-class="num">1].m_width-class="num">1 : CElement::X(); else y=(i>class="num">0) ? y+m_tab_y_size-class="num">1 : CElement::Y(); class=class="str">"cmt">//--- 创建对象 if(!m_tabs[i].Create(m_chart_id,name,m_subwin,x,y,m_tab[i].m_width,m_tab_y_size)) class="kw">return(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="kw">return(true); } class CTabs : class="kw">public CElement { class="kw">public: class=class="str">"cmt">//--- 把控件加到页面数组中 class="type">void AddToElementsArray(class="kw">const class="type">int tab_index,CElement &object); };
「标签页控件的挂载与显隐切换逻辑」
在 MT5 自定义面板里做多标签(Tab)容器时,核心是两个动作:把子控件挂到指定页,以及切换时只让当前页的控件可见。下面这段 CTabs 的成员函数给出了最省事的实现路径。 AddToElementsArray 先拿 ArraySize(m_tab) 确认 tab_index 落在 [0, array_size-1] 区间内,越界直接 return,不抛错但静默丢弃,这点写 EA 界面时要自己加日志,否则页面空白很难查。
class="type">void CTabs::AddToElementsArray(class="kw">const class="type">int tab_index,CElement &object) { class=class="str">"cmt">//--- 检查是否超出数组范围 class="type">int array_size=::ArraySize(m_tab); if(array_size<class="num">1 || tab_index<class="num">0 || tab_index>=array_size) class="kw">return; class=class="str">"cmt">//--- 把传入控件的指针加到指定页面的数组中 class="type">int size=::ArraySize(m_tab[tab_index].elements); ::ArrayResize(m_tab[tab_index].elements,size+class="num">1); m_tab[tab_index].elements[size]=::GetPointer(object); }
class="type">void CTabs::AddToElementsArray(class="kw">const class="type">int tab_index,CElement &object) { class=class="str">"cmt">//--- 检查是否超出数组范围 class="type">int array_size=::ArraySize(m_tab); if(array_size<class="num">1 || tab_index<class="num">0 || tab_index>=array_size) class="kw">return; class=class="str">"cmt">//--- 把传入控件的指针加到指定页面的数组中 class="type">int size=::ArraySize(m_tab[tab_index].elements); ::ArrayResize(m_tab[tab_index].elements,size+class="num">1); m_tab[tab_index].elements[size]=::GetPointer(object); }
标签页点击与悬停的事件分发逻辑
在自绘多标签面板里,用户交互靠图表事件回调驱动,而不是轮询。上面这段代码把鼠标移动和左键点击两类事件拆开处理,能保证 MT5 主图在 60fps 重绘压力下仍只做最小必要刷新。 鼠标移动事件进来先判可见性,隐藏状态直接 return,避免对后台面板做无谓的命中测试。随后把 lparam/dparam 强转成 int 坐标,遍历所有标签调用 MouseFocus(),用 X/Y/X2/Y2 四个边界做矩形包含判断——这一步决定高亮态跟不跟手。 左键点击走 OnClickTab(sparam),sparam 就是被点中对象的名字。函数内循环比对 m_tabs[i].Name() 与 clicked_object,命中后写 SelectedTab(i) 并切换选中色与背景色,其余标签回落到未选中配色,最后 ShowTabElements() 只暴露当前页控件。 实盘写面板时建议把 m_tabs_total 控制在 10 以内,标签越多这套 O(n) 遍历在鼠标移动里越容易肉眼察觉卡顿;外汇与贵金属图表波动快、事件密,这类 GUI 开销需自行在策略测试器里压一遍。
for(class="type">int i=class="num">0; i<m_tabs_total; i++) { class=class="str">"cmt">//--- 如果点击了这个页面 if(m_tabs[i].Name()==clicked_object) { class=class="str">"cmt">//--- 保存所选择页面的索引 SelectedTab(i); class=class="str">"cmt">//--- 设置颜色 m_tabs[i].Color(m_tab_text_color_selected); m_tabs[i].BackColor(m_tab_color_selected); } else { class=class="str">"cmt">//--- 设置非活动页面的颜色 m_tabs[i].Color(m_tab_text_color); m_tabs[i].BackColor(m_tab_color); } } class=class="str">"cmt">//--- 只显示所选页面的控件 ShowTabElements(); class="kw">return(true); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 图表事件处理函数 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CTabs::OnEvent(class="kw">const class="type">int id,class="kw">const class="type">long &lparam,class="kw">const class="type">class="kw">double &dparam,class="kw">const class="type">class="kw">string &sparam) { class=class="str">"cmt">//--- 处理鼠标光标移动事件 if(id==CHARTEVENT_MOUSE_MOVE) { class=class="str">"cmt">//--- 如果元件是隐藏的就退出 if(!CElement::IsVisible()) class="kw">return; class=class="str">"cmt">//--- 坐标 class="type">int x=(class="type">int)lparam; class="type">int y=(class="type">int)dparam; for(class="type">int i=class="num">0; i<m_tabs_total; i++) m_tabs[i].MouseFocus(x>m_tabs[i].X() && x<m_tabs[i].X2() && y>m_tabs[i].Y() && y<m_tabs[i].Y2()); class=class="str">"cmt">//--- class="kw">return; } class=class="str">"cmt">//--- 处理鼠标左键点击对象事件 if(id==CHARTEVENT_OBJECT_CLICK) { class=class="str">"cmt">//--- 按下一个页面 if(OnClickTab(sparam)) class="kw">return; } }
◍ 把页面控件摆到四个边上去试
想验证页面控件在不同边缘的表现,最省事的办法是拿前一篇的 EA 改:把图形界面里主菜单和状态条之外的元件全删掉,只留可切换位置的页面。位置模式(上/下/左/右)和外部高度参数化后,四种布局都能在 MT5 里一键切换。 在 Program.mqh 的自定义类里加两个 input:TabsPosition 默认 TABS_TOP,TabsHeight 设 20。CProgram 中声明 CTabs 实例 m_tabs,并写 CreateTabs() 方法。页面共 4 个,文字与宽度用数组传,默认选中索引 1(第二个页面),高度和位置走外部参数。 编译挂到图表后,改 TabsPosition 就能看到图 2~5 的四种排布:顶部、底部、左边、右边。实测四种模式均正常渲染,说明位置逻辑与锚点计算无误。 再复制一份 EA 删掉外部参数,强制页面在顶部,往四个页分别塞不同控件组:页 1 文字标签表、页 2 编辑框表、页 3 绘制表、页 4 放 4 个复选框+4 个带复选框编辑框+4 个带复选框组合框+一条分隔线。挂控件用 CTabs::AddToElementsArray(页索引, 控件对象),例如分隔线挂到索引 3 的页。界面建完必须调 CTabs::ShowTabElements(),否则所有页控件会叠在一起显出来。 附件里另有一版 CIconTabs 类,每个页面能带可调图标,图标和文字相对页边缘的偏移用专门方法设,界面更友好。外汇与贵金属图表上跑这类自定义界面属于高风险操作环境,参数化前建议在模拟账户验证。
class=class="str">"cmt">//--- External parameters of the Expert Advisor class="kw">input ENUM_TABS_POSITION TabsPosition =TABS_TOP; class=class="str">"cmt">// 页面位置 class="kw">input class="type">int TabsHeight =class="num">20; class=class="str">"cmt">// 页面高度 class CProgram : class="kw">public CWndEvents { class="kw">private: class=class="str">"cmt">//--- 页面 CTabs m_tabs; class=class="str">"cmt">//--- class="kw">private: class=class="str">"cmt">//--- 页面 class="macro">#define TABS1_GAP_X(class="num">4) class="macro">#define TABS1_GAP_Y(class="num">45) class="type">bool CreateTabs(class="type">void); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 创建页面的区域 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CProgram::CreateTabs(class="type">void) { class="macro">#define TABS1_TOTAL class="num">4 class=class="str">"cmt">//--- 传入面板对象 m_tabs.WindowPointer(m_window1); class=class="str">"cmt">//--- 坐标 class="type">int x=m_window1.X()+TABS1_GAP_X; class="type">int y=m_window1.Y()+TABS1_GAP_Y; class=class="str">"cmt">//--- 页面文字和宽度的数组 class="type">class="kw">string tabs_text[]={"Tab class="num">1","Tab class="num">2","Tab class="num">3","Tab class="num">4"}; class="type">int tabs_width[]={class="num">90,class="num">90,class="num">90,class="num">90}; class=class="str">"cmt">//--- 在创建之前设置属性 m_tabs.XSize(class="num">596); m_tabs.YSize(class="num">243); m_tabs.TabYSize(TabsHeight); m_tabs.PositionMode(TabsPosition); m_tabs.SelectedTab((m_tabs.SelectedTab()==WRONG_VALUE) ? class="num">1 : m_tabs.SelectedTab()); m_tabs.AreaColor(clrWhite); m_tabs.TabBackColor(C&class="macro">#x27;class="num">225,class="num">225,class="num">225&class="macro">#x27;); m_tabs.TabBackColorHover(C&class="macro">#x27;class="num">240,class="num">240,class="num">240&class="macro">#x27;); m_tabs.TabBackColorSelected(clrWhite); m_tabs.TabBorderColor(clrSilver); m_tabs.TabTextColor(clrGray); m_tabs.TabTextColorSelected(clrBlack); class=class="str">"cmt">//--- 使用指定属性添加页面 for(class="type">int i=class="num">0; i<TABS1_TOTAL; i++) m_tabs.AddTab(tabs_text[i],tabs_width[i]); class=class="str">"cmt">//--- 创建控件 if(!m_tabs.CreateTabs(m_chart_id,m_subwin,x,y)) class="kw">return(false); class=class="str">"cmt">//--- 把对象加到对象组的通用数组中 CWndContainer::AddToElementsArray(class="num">0,m_tabs); class="kw">return(true); }