也谈自己实现菜单

2019-05-20 admin

        Windows的菜单我们每天都在使用,一般情况下,如果需要美化菜单,例如改变字体、背景颜色等,都是直接在菜单的AdvancedDrawItem事件里面进行自绘即可。但如果想实现一些特殊效果,比如说菜单半透明,就的自己实现整个菜单了。

        实现一个菜单大概需要处理这些问题:(1)绘制各个菜单项。(2)菜单窗口弹出来后,所属窗口不应该失去焦点。(3)点击了菜单项后应该关闭菜单。对于问题1,我们可以简单的根据菜单项的数量,然后判断状态(比如说鼠标现在是否位于该项目上面)进行绘制;对于问题3,需要判断鼠标点击事件。对于问题2,我们可以类似实现自定义Combobox一样,响应弹出窗口的WM_ACTIVATE消息:

procedure TFrmMenu.WMActivate(var Message: TWMActivate);
begin
  inherited;
  if Message.Active = Integer(False) then
  begin
    if Visible then
    begin
      Close;
    end;
  end
  else
  begin
    SendMessage(FrmParent.Handle, WM_NCACTIVATE, 1, 0);
  end;
end;

        就这样,我们第一个版本出炉了。点击这里下载源码。

        如果我们仔细观察,会发现这个菜单跟系统的菜单还是有区别的,点击按钮弹出我们的菜单,然后在窗口标题栏右键弹出系统菜单来对比,会发现以下的不同(弹出我们的菜单后,不要关闭):(1)移动鼠标到其它控件(比如说Panel),会触发该控件的鼠标进入CM_MOUSEEnter和鼠标离开CM_MOUSELEAVE消息。移动到窗口标题按钮也是一样(触发NC消息)。(2)移动鼠标到输入框,比如说TMemo上,会触发对方的WM_SETCURSOR消息,从而改变鼠标形状。怎么办呢?通过搜索,找到这篇文章《DirectUI中模态对话框和菜单的原理(自己控制整个Windows消息循环。或者,用菜单模拟窗体打开时用SetCapture取得控制权,一旦窗体收到WM_CAPTURECHANGED消息就把窗体退出)》
这篇文章介绍的是SDK方式的C代码实现代码,但是后面有一句:“我的做法是菜单模拟窗体打开时用SetCapture取得控制权,一旦窗体收到WM_CAPTURECHANGED消息就把窗体退出。”,于是我们简单修改一下,便有了第二个版本。点击这里下载源码。

        我们再仔细观察,模态窗口解决了版本1的问题(1)和问题(2)—实际上问题1只解决了一半,如果移动鼠标到窗口标题按钮上,还是会触发消息的。最重要的是,版本二带来了一个新问题:弹出菜单后,不要关闭,直接点击其它控件(比如说按钮1或标题栏最大化按钮),发现没有触发点击事件,而是菜单关闭而已。于是我们再搜索,又发现一篇跟前面的文章差不多的另外一篇:新版MenuDemo——使用Duilib模拟Windows原生菜单于是我们把它翻译成Delphi,并完善了一下(比如说该VC版本的源码没有处理WM_MOUSEACTIVATE消息,在菜单关闭后所属窗口会因为获得焦点闪烁一下)。第三个版本出炉:点击这里下载源码。

        通过运行第三个版本,我们发现好像又回到了版本1:(1)移动鼠标到窗口标题按钮(触发NC消息)。(2)移动鼠标到输入框,比如说TMemo上,会触发对方的WM_SETCURSOR消息,从而改变鼠标形状。

        抽完一支烟后,我们决定在版本三的基础上,加上类似版本二的模态窗口功能,但又要解决版本二的问题:(1)在消息循环开始前,我们使用API SetCapture(m_hWindow);来实现类似的模态窗口,在菜单关闭后使用ReleaseCapture;还原。(2)对于NC消息和客户区点击消息,我们在消息循环里面转发:

        原文的代码:


      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        //click on other window
        if not IsMenuWnd(msg.hwnd) then
        begin
          DestroyMenu;

          //为了和菜单再次的弹出消息同步
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);
          bInterceptOther := True;
          bMenuDestroyed := True;
        end;
      end 

        修改后的代码:

      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        h := WindowFromPoint(Mouse.CursorPos); //判断鼠标点击的窗口
        if h <> m_hWindow then
        begin
          DestroyMenu;
          msg.hwnd := h;

          if h = m_hWndOwner then //点击的菜单所属窗口
          begin
            {
            判断点击的是什么地方,用于发送WM_MOUSEMOVE,从而触发WM_SETCURSOR。

            可以作如下测试:

                 菜单弹出后,不要关闭,然后鼠标移动到四个角上面,或者边框上面,
            点下鼠标按钮不要松开,这时候,菜单关闭,然后鼠标形状变成窗口可改变
            大小形状,移动鼠标即可改变窗口大小。

            如果不处理,则会出现鼠标形状改变,但拖动鼠标不会改变大小(可以注释掉
            下面的
            SendMessage(m_hWndOwner, WM_SETCURSOR, m_hWndOwner, MakeLong(xRet, WM_MOUSEMOVE));
            来对比效果。
            }
            xRet := SendMessage(m_hWndOwner, WM_NCHITTEST, 0, MakeLong(Mouse.CursorPos.X, Mouse.CursorPos.Y));
            if xRet <> HTCLIENT then
            begin
              SendMessage(m_hWndOwner, WM_SETCURSOR, m_hWndOwner, MakeLong(xRet, WM_MOUSEMOVE)); //边框鼠标

              //下面两个消息是为了响应菜单未关闭时点击标题栏按钮
              case msg.message of
                WM_LBUTTONDOWN: msg.message := WM_NCLBUTTONDOWN;
                WM_RBUTTONDOWN: msg.message := WM_NCRBUTTONDOWN;
              end;
              msg.wParam := xRet;
              msg.lParam := MakeLong(Mouse.CursorPos.X, Mouse.CursorPos.Y);
            end;
          end;

          //可能点击的是窗口上面的按钮
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);


          if IsMenuWnd(msg.hwnd) then nRet := m_nSelectIndex;

          DestroyMenu;
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);

          bInterceptOther := True;
          bMenuDestroyed := True;
        end;

      end

        点击这里下载源码。

        现在看起来,我们的菜单好像跟操作系统的一模一样了。但是,实际上,如果再仔细观察,菜单弹出后,直接双击窗口标题栏来最大化窗口,或双击窗口左上角图标来关闭窗口,没有达到预期效果。当然,迅雷和QQ之类的自定义菜单也是不够“完美”的,最多也就是版本三左右的效果:

Windows XP Professional-2019-04-06-12-50-56

        估计原理都是跟上面引用的文章一样。但是也不排除人家想要的就是这种所谓不完美的效果。

        如何解决这个问题呢?从现象上看,应该是跟鼠标双击消息有关。我们是否类似上面单击一样,把消息WM_LBUTTONDBLCLK转化为WM_NCLBUTTONDBLCLK即可解决问题?回头看看上面的代码:

      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        if (msg.message = WM_LBUTTONDBLCLK) then ShowMessage('触发了鼠标双击事件!');//加上这一句
        ......
      end

        原来我们实在是太天真了,这个消息压根就没触发。网上搜索,找到这篇文章:Windows如何区分鼠标双击和两次单击,上面说:“两次单击会产生四个鼠标点击消息,如果第三个消息(第二次按下)和第二个消息(第一次弹起引发的WM_LBUTTONUP)间隔短于指定值,则把第三个消息处理成WM_LBUTTONDBLCLK消息;第四个消息照旧,WM_LBUTTONUP。”。通过打印消息,发现我们的程序的确触发了两次WM_LBUTTONDOWN,而且时间间隔、点击坐标都符合条件。拿出Spy++,我们对比一下弹出系统标题栏菜单后直接双击标题栏,和弹出我们的菜单后直接双击标题栏,通过消息对比,发现收到的消息类型、顺序都是一模一样的,但系统的,第二个WM_LBUTTONDOWN后,会产生一个WM_LBUTTONDBLCLK,而我们的自定义菜单就是不会出现这个消息。

        现在我们只能猜想,有可能是操作系统还有什么消息发送给了菜单窗口,但Spy++没有截获?最后,我们再回到第三个版本,把代码进一步精简,就是只处理我们需要的消息即可。关键代码是:

//if PeekMessage(msg, 0, 0, 0, PM_REMOVE) then
 if PeekMessage(msg, 0, 0, 0, PM_NOYIELD or PM_NOREMOVE) then//不删除,下面判断是属于需要处理的,我们才从消息队列删除并处理

        最终,得到完美的第五个版本。点击这里下载。

分类:界面设计

发表评论

(必填)

(必填), (Hidden)

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

TrackBack URL  |  这篇文章上的评论的RSS feed


近期文章

近期评论

文章归档

分类目录