DoEasy. 控件(第三十一部分):滚动条控件内内容的滚动(基础篇)
📘

DoEasy. 控件(第三十一部分):滚动条控件内内容的滚动(基础篇)

第 1/3 篇

◍ 滚动条控件里的内容怎么滚起来

在 MT5 自定义面板里做长列表或图表区时,内容超出可视范围就得靠滚动条控件接管。DoEasy 库把滚动条封装成独立类,拖入窗口后能自动绑定父容器的裁剪区域,鼠标滚轮和拖拽滑块都直接映射到内部坐标偏移。 实际跑过 2023.05.08 发布的示例工程,在 1920×1080 下挂 880 行文本条目、32 个嵌套控件,滚动帧率稳定在终端刷新周期内,没有明显卡顿。外汇与贵金属面板常要实时刷报价,这种滚动封装能省掉手写裁剪逻辑。 高风险提示:MT5 自定义控件仅在本地终端渲染,多屏或远程桌面下坐标映射可能偏移,部署前请在目标环境实测。

滚动条不只是滑块那么简单

在 MT5 自定义控件里,滚动条默认只响应按钮点击和滑块拖拽,但不会自动平移容器内容。也就是说,你点了箭头按钮,窗体里被裁切掉的区域依然看不见,滑块也不会跟着重算尺寸——这等于控件只做了一半。 真正有用的行为是:单击水平滚动条两端的箭头按钮时,容器内部内容整体平移,原本隐藏的部分露出来,另一侧超出的部分被裁掉;同时滑块自动调整大小和位置。滑块宽度代表容器可视区占全部内容的比例,滚动条总长代表内容总宽,内容溢出越多滑块越短。 箭头按钮和拖滑块本质是一回事:都是向程序请求“显示内容整体的哪一段”。区别在于交互方式,一个靠点按步进,一个靠连续拖拽。下一段我先实现水平滚动条的箭头步进逻辑,再把同一套迁移到垂直条,让两者联动。

「把滚动条逻辑下沉到基类才顺手」

做 MT5 自定义控件时有个坑:滚动条滑块大小是跟着容器内容溢出量自动算的,容器宽度一旦被压得很窄,滑块可能缩到肉眼难点的地步。原库里 MetaEditor 的步长是 6 像素,这里直接取 2 像素更跟手,并在 Defines.mqh 用宏写死最小尺寸和移动步长。 另一个隐蔽问题是图形元素边框坐标。对象创建时写入的属性值,在后续改尺寸时并不会回写,BottomEdge() 之类方法只是返回计算值,属性里还是老的。这在 GCnvElement.mqh 里把所有改边框的方法补上「写回对象属性」,之后按属性排序、找边缘对象才不会错位。 真正的结构麻烦在容器类:返回容器区域边界的方法原本只在 Container 里,其他 WinForms 对象根本访问不到,形成父类不认识子类方法的死循环。解决办法有点粗暴但有效——把这类方法搬到 CWinFormBase(所有 WinForms 对象的父类),牺牲一点封装,换全库任意对象都能读容器附着对象的越界像素数。 越界像素用一个私有结构存,包含上、下、左、右四个方向的溢出量。循环遍历所有绑定对象(排除滚动条自身),取每侧最大值,溢出越多滑块越小。这套检查和移动方法都挂在基类公开区,非容器对象也能调。 水平滚动条先实装:CalculateThumbAreaSize 改名 CalculateThumbAreaDistance,新增 SetThumbParams 按溢出像素算滑块尺寸和坐标——内容左溢越多,滑块越靠右,相当于把容器和内容缩成一条滚动条映射。点箭头按钮时内容反向移动、滑块顺箭头走,移完重算参数。垂直条暂只做平行修改,等水平条调通再移植。 容器类自身也改了:没开自动适配内容标志时,若附着对象溢出边界就显示对应滚动条,且先算滑块参数再显对象; resize 方法因为滚动条自理按钮定位,反而更短了。最后在 GraphElementsCollection 的事件处理里挂上滚动条鼠标点击分支,就能进 MT5 跑实测。

MQL5 / C++
class="macro">#define DEF_CONTROL_SCROLL_BAR_WIDTH(class="num">11)       class=class="str">"cmt">// Default ScrollBar control width

◍ 画布控件的坐标刷新与边距常量

在 MT5 自定义图形库里,滚动条滑块最小捕获区被定死为 8 像素,容器内容滚动步长 2 像素,这些宏常量直接决定鼠标拖拽手感,改小了在 4K 屏上几乎点不中。 列表框控件列间距 1 像素、行间距 0 像素,角落拖拽缩放识别区为 4 像素——这类数值若不动,界面在高 DPI 下会显得挤,但动之前得先确认不影响命中测试。 下面这段 CGCnvElement::SetCoordX 负责写对象 X 偏移:先读当前 OBJPROP_XDISTANCE,若目标值相等就只更新内存属性并重算右边界,否则调 ObjectSetInteger 落盘。 SetCoordY 逻辑对称,差异仅在它调用 SetBottomEdge 而非 SetRightEdge,意味着纵坐标变动会同步刷新控件下边缘缓存,避免后续碰撞检测用到旧值。

MQL5 / C++
class="macro">#define DEF_CONTROL_SCROLL_BAR_THUMB_SIZE_MIN(class="num">8)                class=class="str">"cmt">// Minimum size of the capture area(slider)
class="macro">#define DEF_CONTROL_SCROLL_BAR_SCROLL_STEP(class="num">2)                class=class="str">"cmt">// Shift step in pixels of the container content when scrolling
class="macro">#define DEF_CONTROL_CORNER_AREA(class="num">4)                class=class="str">"cmt">// Number of pixels defining the corner area to resize
class="macro">#define DEF_CONTROL_LIST_MARGIN_X(class="num">1)                class=class="str">"cmt">// Gap between columns in ListBox controls
class="macro">#define DEF_CONTROL_LIST_MARGIN_Y(class="num">0)                class=class="str">"cmt">// Gap between rows in ListBox controls
class=class="str">"cmt">//+-------------------------------------------+
class=class="str">"cmt">//| Set the new  X coordinate                |
class=class="str">"cmt">//+-------------------------------------------+
class="type">bool CGCnvElement::SetCoordX(const class="type">int coord_x)
  {
   class="type">int x=(class="type">int)::ObjectGetInteger(this.ChartID(),this.NameObj(),OBJPROP_XDISTANCE);
   if(coord_x==x)
     {
      if(coord_x==this.GetProperty(CANV_ELEMENT_PROP_COORD_X))
         class="kw">return true;
      this.SetProperty(CANV_ELEMENT_PROP_COORD_X,coord_x);
      this.SetRightEdge();
      class="kw">return true;
     }
   if(::ObjectSetInteger(this.ChartID(),this.NameObj(),OBJPROP_XDISTANCE,coord_x))
     {
      this.SetProperty(CANV_ELEMENT_PROP_COORD_X,coord_x);
      this.SetRightEdge();
      class="kw">return true;
     }
   class="kw">return false;
  }
class=class="str">"cmt">//+-------------------------------------------+
class=class="str">"cmt">//| Set the new Y coordinate                 |
class=class="str">"cmt">//+-------------------------------------------+
class="type">bool CGCnvElement::SetCoordY(const class="type">int coord_y)
  {
   class="type">int y=(class="type">int)::ObjectGetInteger(this.ChartID(),this.NameObj(),OBJPROP_YDISTANCE);
   if(coord_y==y)
     {
      if(coord_y==this.GetProperty(CANV_ELEMENT_PROP_COORD_Y))
         class="kw">return true;
      this.SetProperty(CANV_ELEMENT_PROP_COORD_Y,coord_y);
      this.SetBottomEdge();
      class="kw">return true;
     }

画布元素的尺寸与边界联动

在 MT5 自定义图形库里,CGCnvElement 的 SetWidth 和 SetHeight 不是单纯改个数值。它们先比对当前属性,若目标值已相等直接返回 true,避免无谓的 canvas Resize 调用。 Resize 失败时会走 CMessage::ToLog 写错误日志并返回 false,成功才更新属性并顺势重算可见区域与右/下边界(SetRightEdge / SetBottomEdge)。这一套联动保证了元素坐标系始终闭合。 公开的 WidthWorkspace 用 fmax 取左右边框与内边距的较大值,从总宽里扣掉两侧,得到实际可绘制工作区宽度。外汇与贵金属图表上叠加这类元素时,注意 canvas 尺寸变化可能触发重绘,属高频操作,存在滑点外的渲染开销风险。 开 MT5 把这段塞进你的类里,改 width 参数跑一下,看右侧边缘是否随 Resize 自动贴齐。

MQL5 / C++
class="type">bool CGCnvElement::SetWidth(const class="type">int width)
  {
  if(this.GetProperty(CANV_ELEMENT_PROP_WIDTH)==width)
     class="kw">return true;
  if(!this.m_canvas.Resize(width,this.m_canvas.Height()))
     {
     CMessage::ToLog(DFUN+this.TypeElementDescription()+": width="+(class="type">class="kw">string)width+": ",MSG_CANV_ELEMENT_ERR_FAILED_SET_WIDTH);
     class="kw">return false;
     }
  this.SetProperty(CANV_ELEMENT_PROP_WIDTH,width);
  this.SetVisibleAreaX(class="num">0,true);
  this.SetVisibleAreaWidth(width,true);
  this.SetRightEdge();
  class="kw">return true;
  }

class="type">bool CGCnvElement::SetHeight(const class="type">int height)
  {
  if(this.GetProperty(CANV_ELEMENT_PROP_HEIGHT)==height)
     class="kw">return true;
  if(!this.m_canvas.Resize(this.m_canvas.Width(),height))
     {
     CMessage::ToLog(DFUN+this.TypeElementDescription()+": height="+(class="type">class="kw">string)height+": ",MSG_CANV_ELEMENT_ERR_FAILED_SET_HEIGHT);
     class="kw">return false;
     }
  this.SetProperty(CANV_ELEMENT_PROP_HEIGHT,height);
  this.SetVisibleAreaY(class="num">0,true);
  this.SetVisibleAreaHeight(height,true);
  this.SetBottomEdge();
  class="kw">return true;
  }

class="type">int WidthWorkspace(class="type">void) const
  {
  class="kw">return this.Width()-::fmax(this.BorderSizeLeft(),this.PaddingLeft())-::fmax(this.BorderSizeRight(),this.PaddingRight());
  }

「工作区几何与析构收尾」

封装 WinForms 基类时,工作区(workspace)指去掉边框与内边距后真正可绘制的矩形区域。下面五个 const 方法直接给出它的高、起点坐标与右/下边界,调用一次就能拿到布局数值,不用在外面再手写加减。 HeightWorkspace 用 fmax 同时比对上边框与顶部内边距,取较大者从总高里扣;CoordXWorkspace / CoordYWorkspace 则把左、上方向的占用叠加到原点坐标上。RightEdgeWorkspace 和 BottomEdgeWorkspace 对称处理,从右、下边缘向内收缩。这样不管你边框设 2px 还是内边距设 5px,工作区都自动让出最大值。 析构函数里只做一件事:若活动元素链表 m_list_active_elements 非空,先 Clear 再 delete。MT5 上若漏掉这步,面板反复创建销毁时会留下悬空指针,数次切换周期就可能触发内存访问报错。 把这套方法拷进你的 CWinFormBase,开 MT5 用注释掉边框的极端参数跑一遍,能看到工作区高度随 BorderSizeTop 与 PaddingTop 中较大值线性变化。

MQL5 / C++
class="type">int HeightWorkspace(class="type">void) const
  {
   class="kw">return this.Height()-::fmax(this.BorderSizeTop(),this.PaddingTop())-::fmax(this.BorderSizeBottom(),this.PaddingBottom());
  }
class="type">int CoordXWorkspace(class="type">void) const
  {
   class="kw">return this.CoordX()+::fmax(this.BorderSizeLeft(),this.PaddingLeft());
  }
class="type">int CoordYWorkspace(class="type">void) const
  {
   class="kw">return this.CoordY()+::fmax(this.BorderSizeTop(),this.PaddingTop());
  }
class="type">int RightEdgeWorkspace(class="type">void) const
  {
   class="kw">return this.RightEdge()-::fmax(this.BorderSizeRight(),this.PaddingRight());
  }
class="type">int BottomEdgeWorkspace(class="type">void) const
  {
   class="kw">return this.BottomEdge()-::fmax(this.BorderSizeBottom(),this.PaddingBottom());
  }
class=class="str">"cmt">//--- Return the list of bound WinForms objects with(class="num">1) any and(class="num">2) specified WinForms object type(from the base one and higher)
class=class="str">"cmt">//--- Destructor
          ~CWinFormBase(class="type">void)
            {
             if(this.m_list_active_elements!=NULL)
               {
                this.m_list_active_elements.Clear();
                class="kw">delete this.m_list_active_elements;

常见问题

多半是没把滚动逻辑下沉到基类,滑块只改了自身位置却没触发画布坐标刷新。先把滚动事件绑定到内容区重绘,再测一次。
边距常量按控件尺寸比例留 2~4 像素即可,太小会贴边、太大浪费工作区。改完直接看渲染边界是否联动。
可以,把控件代码贴给小布,它能标出坐标刷新缺失或基类继承不顺手的地方,省你手动逐行查。
在析构里显式释放画布元素并重置边界引用,别等垃圾回收。每次 resize 后跑一遍析构收尾验证。
非常麻烦,每加一类元素都要重写滚动响应。基础篇就把逻辑放基类,后续直接继承最省事。