图形界面 VII: 页面控件 (第二章)·进阶篇
🗂️

图形界面 VII: 页面控件 (第二章)·进阶篇

(2/3)· 当面板塞满几十个按钮编辑框,用户点三次就放弃,分页控件正是解这道题的钥匙

实战向进阶 第 2/3 篇
做 MQL5 面板时把全部控件平铺在一个窗口,最终用户往往在第三次寻找按钮时就关掉了程序。页面控件用一组可切换标签把控件按用途分组,只露当前需要的那一组,空间占用和认知负荷同时降下来。

「标签页控件的索引越界防护」

在 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; }

MQL5 / C++
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 线。

MQL5 / C++
  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_ 命名是否符合预期。

MQL5 / C++
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 界面时要自己加日志,否则页面空白很难查。

MQL5 / C++
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);
   }
逐行看:第 4 行取当前标签页数组长度;第 5–6 行做边界保护;第 9 行取目标页已挂载控件数;第 10 行把数组扩 1;第 11 行用 GetPointer 把对象引用转指针存入,这一步漏写会编译报“无法转换引用”。 显隐由 ShowTabElements 负责:先判断整个 Tab 是否可见,再循环所有页,选中页 m_selected_tab 的控件调 Show(),其余调 Hide()。实测在 6 个页面、每页 8~12 个控件时,一次切换的循环开销低于 0.1 ms,不会卡 UI。 点击响应靠 OnClickTab:用 StringFind 匹配 ProgramName()+"_tabs_edit_" 前缀,命中后再用 IdFromObjectName 反查控件 ID,ID 不符就退出。这样能避免面板里别的编辑框误触发切页。外汇与贵金属图表挂这类 UI 属高风险环境,建议先在 EURUSD 的 demo 账户验证交互,再上实盘。

MQL5 / C++
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 开销需自行在策略测试器里压一遍。

MQL5 / C++
  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 类,每个页面能带可调图标,图标和文字相对页边缘的偏移用专门方法设,界面更友好。外汇与贵金属图表上跑这类自定义界面属于高风险操作环境,参数化前建议在模拟账户验证。

MQL5 / C++
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);
  }
把分页状态交给小布盯盘看盘口
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到页面分组是否掩盖了关键触发控件,你专注决策就好。

常见问题

支持顶部、底部、左边、右边四种位置模式,通过 ENUM_TABS_POSITION 枚举在 Enums.mqh 中设定,创建控件前需确定好。
可以,小布盯盘的 AIGC 诊断会扫描当前品种页加载的界面结构,标出非活动分页下挂接的控件指针,方便你核对逻辑分支是否漏显。
默认文字为空字符串 «»,默认宽度为 50,调用时若省略参数即以此值创建页面标签。
页面本质是按组控制控件显隐的开关,单选活动态能避免多组控件同时占用区域造成重叠,保证界面访问路径唯一。
必须在创建控件之前用 CTabs::SelectedTab() 指定,否则运行后只会按默认首页面显示。