图形界面 I: 图形界面的动画 (第三章)·进阶篇
🖱️

图形界面 I: 图形界面的动画 (第三章)·进阶篇

(2/3)·静态面板只是半成品,本章把表单拖拽与控件悬停变色写进库里

进阶 第 2/3 篇
很多自制面板拖到图表上就僵在那,鼠标滑过按钮也没任何反馈,用户根本分不清哪能点。问题通常出在没开启光标追踪,也没给控件写状态机。本章把这两件事一次性补进基类。

◍ 面板坐标与鼠标命中判定的底层函数

做 MT5 自定义控件面板时,最容易被忽略的是坐标系转换:指标子窗口的 Y 轴原点并不等于图表可视区顶端,直接用鼠标事件里的 y 值去比对控件坐标会整体偏移。下面这段 CWindow 类的成员函数给出了标准解法。

MQL5 / C++
class="type">void CWindow::SetWindowProperties(class="type">void)
  {
  m_chart_width  =m_chart.WidthInPixels();
  m_chart_height =m_chart.HeightInPixels(m_subwin);
  }

class="type">int CWindow::YToRelative(const class="type">int y)
  {
  class="type">int chart_y_distance=m_chart.SubwindowY(m_subwin);
  class="kw">return(y-chart_y_distance);
  }

class="type">bool CWindow::CursorInsideCaption(const class="type">int x,const class="type">int y)
  {
  class="kw">return(x>m_x && x<X2()-m_right_limit && y>m_y && y<m_caption_bg.Y2());
  }
逐行看:SetWindowProperties 用 WidthInPixels 取图表像素宽,HeightInPixels 传入子窗口序号 m_subwin 取该子窗口高,避免在多窗布局下拿错高度。YToRelative 先通过 SubwindowY(m_subwin) 拿到子窗口顶端距图表顶的像素距离,再把传入的 y 减去它,得到相对子窗口的局部坐标。CursorInsideCaption 用转换后的坐标做边界判断:x 在 m_x 到 X2()-m_right_limit 之间,y 在 m_y 到标题背景底沿 m_caption_bg.Y2() 之间才认作命中标题栏。 实盘里拖拽面板卡顿或点不中,十有八九是忘了跑 YToRelative。开 MT5 在 OnChartEvent 里打印 SubwindowY 返回值,对比鼠标 y,就能确认偏移量。外汇与贵金属图表多子窗常见,这类坐标 bug 在高波动时段更易暴露,属正常技术风险。

MQL5 / C++
class="type">void CWindow::SetWindowProperties(class="type">void)
  {
  m_chart_width  =m_chart.WidthInPixels();
  m_chart_height =m_chart.HeightInPixels(m_subwin);
  }

class="type">int CWindow::YToRelative(const class="type">int y)
  {
  class="type">int chart_y_distance=m_chart.SubwindowY(m_subwin);
  class="kw">return(y-chart_y_distance);
  }

class="type">bool CWindow::CursorInsideCaption(const class="type">int x,const class="type">int y)
  {
  class="kw">return(x>m_x && x<X2()-m_right_limit && y>m_y && y<m_caption_bg.Y2());
  }

「面板拖拽与焦点判定的底层状态机」

自定义窗口类里,鼠标交互靠几个内部状态变量驱动。每次按键松开,ZeroPanelVariables() 会把 m_prev_x、m_prev_y、m_size_fixing_x/y 全部归零,并把 m_clamping_area_mouse 设为 NOT_PRESSED,相当于把拖拽上下文清空。 CheckMouseButtonState() 接收 x、y 坐标和按键状态字符串。state=="0" 直接调清零函数并返回;state=="1" 时若 m_clamping_area_mouse 已不是 NOT_PRESSED 就直接离开,避免重复记录。否则按光标是否在表头(Caption)内部分派为 PRESSED_INSIDE_HEADER 或 PRESSED_INSIDE_WINDOW,区域外则是 PRESSED_OUTSIDE。 焦点确认由 CheckMouseFocus() 完成:仅当 subwin==m_subwin 且不是表头拖拽替换模式时,才用坐标不等式 x>m_x && x<X2() && y>m_y && y<Y2() 判定 CElement 自身焦点,并同样对 rollup、close、unroll 三个按钮做独立边界检测。 开 MT5 把这几段塞进你的 CWindow 派生类,改 m_subwin 对应的图表子窗口号,就能在 OnInit 后移动面板验证焦点闪烁是否跟手。外汇与贵金属图表叠加此类控件属高风险定制,实盘前请在策略测试器跑满历史样本。

MQL5 / C++
class="type">void CWindow::ZeroPanelVariables(class="type">void)
  {
   m_prev_x                =class="num">0;
   m_prev_y                =class="num">0;
   m_size_fixing_x         =class="num">0;
   m_size_fixing_y         =class="num">0;
   m_clamping_area_mouse =NOT_PRESSED;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 验证鼠标按键的状态                                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CWindow::CheckMouseButtonState(const class="type">int x,const class="type">int y,const class="type">class="kw">string state)
  {
class=class="str">"cmt">//--- 如果按钮被松开
   if(state=="class="num">0")
     {
      class=class="str">"cmt">//--- 变量清零
      ZeroPanelVariables();
      class="kw">return;
     }
class=class="str">"cmt">//--- 如果按键被按下
   if(state=="class="num">1")
     {
      class=class="str">"cmt">//--- 如果状态已经被记录就离开
      if(m_clamping_area_mouse!=NOT_PRESSED)
        class="kw">return;
      class=class="str">"cmt">//--- 面板区域之外
      if(!CElement::MouseFocus())
        m_clamping_area_mouse=PRESSED_OUTSIDE;
      class=class="str">"cmt">//--- 面板区域之内
      else
        {
         class=class="str">"cmt">//--- 在表头之内
         if(CursorInsideCaption(x,y))
           {
            m_clamping_area_mouse=PRESSED_INSIDE_HEADER;
            class="kw">return;
           }
         class=class="str">"cmt">//--- 如果在窗口区域之内
         m_clamping_area_mouse=PRESSED_INSIDE_WINDOW;
        }
   }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 确认鼠标焦点                                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CWindow::CheckMouseFocus(const class="type">int x,const class="type">int y,const class="type">int subwin)
  {
class=class="str">"cmt">//--- 如果光标在程序窗口区域之内
   if(subwin==m_subwin)
     {
      class=class="str">"cmt">//--- 如果当前不是在表单的替换模式中
      if(m_clamping_area_mouse!=PRESSED_INSIDE_HEADER)
        {
         class=class="str">"cmt">//--- 确认光标位置
         CElement::MouseFocus(x>m_x && x<X2() && y>m_y && y<Y2());
         class=class="str">"cmt">//---
         m_button_rollup.MouseFocus(x>m_button_rollup.X() && x<m_button_rollup.X2() &&
                                    y>m_button_rollup.Y() && y<m_button_rollup.Y2());
         m_button_close.MouseFocus(x>m_button_close.X() && x<m_button_close.X2() &&
                                   y>m_button_close.Y() && y<m_button_close.Y2());
         m_button_unroll.MouseFocus(x>m_button_unroll.X() && x<m_button_unroll.X2() &&

面板拖拽时如何锁死图表滚动

在自绘 UI 面板里,最烦的就是拖窗口时图表跟着滚、止损线被误拽。CWindow 类用 SetChartState() 把这种干扰掐掉:只要光标在面板内且鼠标没按,或按键落在表单/表头区,就调 m_chart.MouseScroll(false) 和 CHART_DRAG_TRADE_LEVELS 置 false,把滚动和交易水平拖拽全禁了。 光标一旦移出窗口区域,这两句立刻翻回 true,图表恢复原生操作。逻辑判断靠 m_clamping_area_mouse 的三个状态:NOT_PRESSED、PRESSED_INSIDE_WINDOW、PRESSED_INSIDE_HEADER,覆盖「悬停」「体内按下」「标题栏按下」三种典型交互。 UpdateWindowXY() 负责真拖拽。非固定模式(m_movable==true)才往下走;首次按下时把 m_prev_x/y 和 m_size_fixing_x/y 存下来,后者是「表单边缘减光标」的偏移量。 边界限制用四个 int 算:limit_top 用 y 减偏移绝对值,limit_left 同理,limit_bottom 是 m_y+标题高,limit_right 是 m_x+宽。只有四个限都没超图表高宽(m_chart_height / m_chart_width),new_x/y_point 才赋值,否则窗口停在原位。外汇和贵金属图表波动快,这种钳制能明显降低误触概率,但高频拖拽仍可能丢帧,建议在 MT5 里用 Print() 打点验证限边界触发频率。

MQL5 / C++
class="type">void CWindow::SetChartState(const class="type">int subwindow_number)
  {
  if((CElement::MouseFocus() && m_clamping_area_mouse==NOT_PRESSED) ||
     m_clamping_area_mouse==PRESSED_INSIDE_WINDOW ||
     m_clamping_area_mouse==PRESSED_INSIDE_HEADER)
     {
     m_chart.MouseScroll(false);
     m_chart.SetInteger(CHART_DRAG_TRADE_LEVELS,false);
     }
  else
     {
     m_chart.MouseScroll(true);
     m_chart.SetInteger(CHART_DRAG_TRADE_LEVELS,true);
     }
  }

class="type">void CWindow::UpdateWindowXY(const class="type">int x,const class="type">int y)
  {
  if(!m_movable)
     class="kw">return;
  class="type">int new_x_point =class="num">0;
  class="type">int new_y_point =class="num">0;
  class="type">int limit_top    =class="num">0;
  class="type">int limit_left   =class="num">0;
  class="type">int limit_bottom =class="num">0;
  class="type">int limit_right  =class="num">0;
  if((class="type">bool)m_clamping_area_mouse)
     {
     if(m_prev_y==class="num">0 || m_prev_x==class="num">0)
       {
       m_prev_y=y;
       m_prev_x=x;
       }
     if(m_size_fixing_y==class="num">0 || m_size_fixing_x==class="num">0)
       {
       m_size_fixing_y=m_y-m_prev_y;
       m_size_fixing_x=m_x-m_prev_x;
       }
     }
  limit_top    =y-::fabs(m_size_fixing_y);
  limit_left   =x-::fabs(m_size_fixing_x);
  limit_bottom =m_y+m_caption_height;
  limit_right  =m_x+m_x_size;
  if(limit_bottom<m_chart_height && limit_top>=class="num">0 &&
     limit_right<m_chart_width && limit_left>=class="num">0)
     {
     new_y_point =y+m_size_fixing_y;
     new_x_point =x+m_size_fixing_x;
     }
  }

◍ 浮动面板贴边时的坐标回弹逻辑

当自定义窗口被拖到图表边界外,代码会按四个方向分别做夹紧:下溢出时 y 落到 chart_height 减标题高,上溢出时 y 归 0,右溢出时 x 限制在 chart_width 减面板宽,左溢出时 x 归 0,且都叠加了 size_fixing 偏移量。 只要 new_x_point 或 new_y_point 任一大于 0,就触发坐标重写:m_x 与 m_y 若算出 ≤0 则强制置 1,随后再分别用 chart_width - m_x_size - 1 和 chart_height - m_caption_height - 2 做二次封顶,避免面板画出可视区。 鼠标移动事件里先用 ChartXYToTimePrice 把光标像素坐标转成子窗口号、时间与价格;若转换失败直接 return,后续相对 Y 的计算就不会跑。 别把边界常数当死值:m_caption_height 和 m_x_size 若在你工程里被动态改过,上面那串减 1、减 2 的硬偏移可能让面板留白或咬边,开 MT5 把这两个变量打印出来核对最实在。

MQL5 / C++
else
    {
      if(limit_bottom>m_chart_height) class=class="str">"cmt">// > 向下
        {
         new_y_point =m_chart_height-m_caption_height;
         new_x_point =x+m_size_fixing_x;
        }
      if(limit_top<class="num">0) class=class="str">"cmt">// > 向上
        {
         new_y_point =class="num">0;
         new_x_point =x+m_size_fixing_x;
        }
      if(limit_right>m_chart_width) class=class="str">"cmt">// > 向右
        {
         new_x_point =m_chart_width-m_x_size;
         new_y_point =y+m_size_fixing_y;
        }
      if(limit_left<class="num">0) class=class="str">"cmt">// > 向左
        {
         new_x_point =class="num">0;
         new_y_point =y+m_size_fixing_y;
        }
     }
class=class="str">"cmt">//--- 如果有移动, 就更新坐标
   if(new_x_point>class="num">0 || new_y_point>class="num">0)
     {
      class=class="str">"cmt">//--- 调整表单坐标
      m_x =(new_x_point<=class="num">0)? class="num">1 : new_x_point;
      m_y =(new_y_point<=class="num">0)? class="num">1 : new_y_point;
      class=class="str">"cmt">//---
      if(new_x_point>class="num">0)
        m_x=(m_x>m_chart_width-m_x_size-class="num">1) ? m_chart_width-m_x_size-class="num">1 : m_x;
      if(new_y_point>class="num">0)
        m_y=(m_y>m_chart_height-m_caption_height-class="num">1) ? m_chart_height-m_caption_height-class="num">2 : m_y;
      class=class="str">"cmt">//--- 固定点清零
      m_prev_x=class="num">0;
      m_prev_y=class="num">0;
     }
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 图表事件处理函数                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CWindow::OnEvent(const class="type">int id,const class="type">long &lparam,const class="type">class="kw">double &dparam,const class="type">class="kw">string &sparam)
  {
   if(id==CHARTEVENT_MOUSE_MOVE)
     {
      class="type">int      x      =(class="type">int)lparam; class=class="str">"cmt">// X轴上的坐标
      class="type">int      y      =(class="type">int)dparam; class=class="str">"cmt">// Y轴上的坐标
      class="type">int      subwin =WRONG_VALUE; class=class="str">"cmt">// 光标所在的窗口编号
      class="type">class="kw">datetime time   =NULL;        class=class="str">"cmt">// 根据X坐标对应的时间
      class="type">class="kw">double   level  =class="num">0.0;         class=class="str">"cmt">// 根据Y坐标对应的水平 (价格) 
      class="type">int      rel_y  =class="num">0;           class=class="str">"cmt">// 用于标识相对Y坐标
      class=class="str">"cmt">//--- 取得光标位置
      if(!::ChartXYToTimePrice(m_chart_id,x,y,subwin,time,level))
        class="kw">return;
      class=class="str">"cmt">//--- 取得相对 Y 坐标

「窗口拖拽时的坐标同步逻辑」

在自定义控件里,鼠标在标题栏按下后窗口要跟着动,核心就是先把屏幕坐标转成相对坐标再做状态机判断。rel_y=YToRelative(y) 这步把像素 y 映射到窗口内部坐标系,后面 CheckMouseButtonState、CheckMouseFocus、SetChartState 依次确认按键、焦点和图表交互状态,只有 m_clamping_area_mouse==PRESSED_INSIDE_HEADER 才进 UpdateWindowXY 改位置——这意味着点错区域(比如边框外)窗口不会乱跳。 真正批量搬坐标的是 Moving() 函数。它接收 x、y 后,先把背景、标题栏、图标、标签和四个按钮的绝对 X/Y 全部重算:每个子元素都用自身 XGap()/YGap() 偏移量叠加新原点,例如 m_icon.X(x+m_icon.XGap())。 算完还要调一遍 X_Distance()/Y_Distance() 把数值写回图形对象,MT5 的 OBJ_LABEL 类控件不吃直接赋值,必须靠距离属性刷新界面。你在 EA 里若发现拖窗口后按钮错位,八成是漏了后半段 Distance 同步。外汇与贵金属图表加载这类自定义窗口需注意高波动时重绘可能掉帧。

MQL5 / C++
  rel_y=YToRelative(y);
  class=class="str">"cmt">//--- 验证和保存鼠标按键状态
  CheckMouseButtonState(x,rel_y,sparam);
  class=class="str">"cmt">//--- 验证鼠标焦点
  CheckMouseFocus(x,rel_y,subwin);
  class=class="str">"cmt">//--- 设置图表状态
  SetChartState(subwin);
  class=class="str">"cmt">//--- 如果管理被移交到窗口, 标记它的位置
  if(m_clamping_area_mouse==PRESSED_INSIDE_HEADER)
    {
     class=class="str">"cmt">//--- 更新窗口坐标
     UpdateWindowXY(x,rel_y);
    }
  class="kw">return;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 移动窗口                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CWindow::Moving(const class="type">int x,const class="type">int y)
  {
class=class="str">"cmt">//--- 在变量中保存坐标
   m_bg.X(x);
   m_bg.Y(y);
   m_caption_bg.X(x);
   m_caption_bg.Y(y);
   m_icon.X(x+m_icon.XGap());
   m_icon.Y(y+m_icon.YGap());
   m_label.X(x+m_label.XGap());
   m_label.Y(y+m_label.YGap());
   m_button_close.X(x+m_button_close.XGap());
   m_button_close.Y(y+m_button_close.YGap());
   m_button_unroll.X(x+m_button_unroll.XGap());
   m_button_unroll.Y(y+m_button_unroll.YGap());
   m_button_rollup.X(x+m_button_rollup.XGap());
   m_button_rollup.Y(y+m_button_rollup.YGap());
   m_button_tooltip.X(x+m_button_tooltip.XGap());
   m_button_tooltip.Y(y+m_button_tooltip.YGap());
class=class="str">"cmt">//--- 更新图形对象的坐标
   m_bg.X_Distance(m_bg.X());
   m_bg.Y_Distance(m_bg.Y());
   m_caption_bg.X_Distance(m_caption_bg.X());
   m_caption_bg.Y_Distance(m_caption_bg.Y());
   m_icon.X_Distance(m_icon.X());
   m_icon.Y_Distance(m_icon.Y());
   m_label.X_Distance(m_label.X());
   m_label.Y_Distance(m_label.Y());
   m_button_close.X_Distance(m_button_close.X());
   m_button_close.Y_Distance(m_button_close.Y());
   m_button_unroll.X_Distance(m_button_unroll.X());
   m_button_unroll.Y_Distance(m_button_unroll.Y());
   m_button_rollup.X_Distance(m_button_rollup.X());
   m_button_rollup.Y_Distance(m_button_rollup.Y());
   m_button_tooltip.X_Distance(m_button_tooltip.X());
   m_button_tooltip.Y_Distance(m_button_tooltip.Y());
  }
把界面响应诊断交给小布
这些光标追踪与悬停变色的逻辑小布盯盘的 AIGC 已内置,打开对应品种页即可看到实时控件状态,你只需专注交互设计。

常见问题

默认图表参数不追踪光标坐标与按键,需要在构造时启用 CChart 的光标跟踪并把对象附加到当前图表子窗口。
若 MQL 程序是指标且不在主窗口,需根据所在子窗口调整 Y 坐标,否则拖拽会错位。
分为未按下、表单外按下、表单内按下、表单头部按下四种,用以判定拖拽起点与响应范围。
小布内置了控件状态与光标追踪的模板,可输出基础窗体代码,你再按品种页需求改样式即可。
它提供 CChart 类来开启光标与鼠标事件追踪,是让图形界面活起来的底层依赖。