窗体皮肤实现 - 在标题栏上增加快速工具条(四)

前面都是绘制非客户区的基础工作。绘制非客户区一般的目的,都是想在上面做文章。前面一堆废话就是想在标题区域增加快速工具条

前续的基础工作完成,想要在标题区域增加特殊区域都非常方便。只要在绘制时控制自定义区域需要占用标题区域多少空间,然后直接在所占位置绘制。做这个事情前,稍微把代码规整了下。所以界面皮肤处理放到一个单元中。

主要处理步骤:
1、划出一个新区域(整个工具条作为一个区域)
2、处理区域检测(HitTest)
3、如果是新区域,把相应消息传给这个区域处理。
4、响应鼠标点击,执行Action

通过上述步骤就能扩展出所想要的标题区快速工具条的。

模块化表区域工具条

标题按钮区域是作为一个整体处理,这样比较容易控制和扩展。只要当检测区域是标题工具区时,消息交由工具条实现。

这样做的好处就是,简化自定义皮肤TskForm内部的处理。模块化比较清晰,简化实现逻辑。

HTCUSTOM = 100; //HTHELP + 1;       /// 自定义区域ID
HTCAPTIONTOOLBAR = HTCUSTOM + 1;    /// 标题工具区域ID

/// 检测区域时增加自定义区域的检测
function TskForm.HitTest(P: TPoint):integer;
begin
    ... ... (代码略)  
    ///
    ///  标题工具区域
    ///    需要前面扣除窗体图标区域
    if (Result = HTNOWHERE) and (FToolbar.Visible) then
    begin
      r.Left := rCaptionRect.Left + 2 + GetSystemMetrics(SM_CXSMICON) + SPALCE_CAPTIONAREA;
      R.Top := rCaptionRect.Top + (rCaptionRect.Height - FToolbar.Border.Height) div 2;
      R.Right := R.Left + FToolbar.Border.Width;
      R.Bottom := R.Top + FToolbar.Border.Height;

      if FToolbar.FOffset.X = -1 then
        FToolbar.FOffset := r.TopLeft;

      if PtInRect(r, p) then
        Result := HTCAPTIONTOOLBAR;
    end;
  end;
end;

标题工具条实现

1、准备绘制的区域
2、确定绘制区域大小
3、实现绘制
4、响应消息

确定绘制区域大小

考虑到按钮是动态增加上去,需要根据实际标题区域的按钮数量来确定实际大小。所有的Action存放在记录中,这样每次只要循环Action数组就可以获得相应宽度。

区域的宽度包括:两条分割线 + 下拉配置菜单 + Button * Count

/// 用于保存Action的信息
TcpToolButton = record
  Action: TBasicAction;
  Enabled: boolean;
  Visible: Boolean;
  ImageIndex: Integer;        // 考虑到标题功能图标和实际工具栏功能使用不同图标情况,分开图标索引
  Width: Word;                // 实际占用宽度,考虑后续加不同的按钮样式使用
  Fade: Word;                 // 褪色量 0 - 255
  SaveEvent: TNotifyEvent;    // 原始的Action OnChange事件
end;

///
/// 计算实际占用尺寸
function TcpToolbar.CalcSize: TRect;
const
  SIZE_SPLITER = 10;
  SIZE_POPMENU = 10;
  SIZE_BUTTON  = 20;
var
  w, h: Integer;
  I: Integer;
begin
  ///
  ///  占用宽度
  ///     如果考虑比较复杂的按钮样式和显示标题等功能,那么需要计算每个按钮实际占用宽度才能获得。
  w := SIZE_SPLITER * 2 + SIZE_POPMENU;
  for I := 0 to FCount - 1 do
    w := w + FItems[i].Width;
  h := SIZE_BUTTON;
  Result := Rect(0, 0, w, h);
end;

占用区域大小的问题解决,绘制问题主要考虑在什么位置绘制,怎么获得Action的图标和实际的状态。以正常情况考虑绘制区域:从原点(0,0)开始绘制,这样比较符合一般的习惯。只要在绘制前对画布重新设置原点,就能实现。

/// 绘制工具条
if FToolbar.Visible and (rCaptionRect.Right > rCaptionRect.Left) then
begin
  /// 防止出现绘制出多余区域,当区域不够时需要进行剪切。
  ///  如: 窗体缩小时
  CurrentIdx := 0;
  bClipRegion := rCaptionRect.Width < FToolbar.Border.Width;
  if bClipRegion then
  begin
    ClipRegion := CreateRectRgnIndirect(rCaptionRect);
    CurrentIdx := SelectClipRgn(DC, ClipRegion);
    DeleteObject(ClipRegion);
  end;

  /// 设置原点偏移量
  iLeftOff := rCaptionRect.Left;
  iTopOff := rCaptionRect.Top + (rCaptionRect.Height - FToolbar.Border.Height) div 2;
  MoveWindowOrg(DC, iLeftOff, iTopOff);
  FToolbar.Paint(DC);
  MoveWindowOrg(DC, -iLeftOff, -iTopOff);

  if bClipRegion then
    SelectClipRgn(DC, CurrentIdx);

  /// 扣除工具条区域
  rCaptionRect.Left := rCaptionRect.Left + FToolbar.Border.Width + SPALCE_CAPTIONAREA;
end;

获取Action的图标

直接从ImageList中获取。考虑标题区域是纯色,能让标题工具条显的更美观(个人审美),能让工具条支持2中不同的图标。画了一组纯白的图标用于标题区域的显示。

// 创建Bmp,支持透明
// cIcon := TBitmap.Create;
// cIcon.PixelFormat := pf32bit;  // 支持透明
// cIcon.alphaFormat := afIgnored;

function TcpToolbar.LoadActionIcon(Idx: Integer; AImg: TBitmap):Boolean;
var
  bHasImg: Boolean;
begin
  /// 获取Action的图标
  AImg.Canvas.Brush.Color := clBlack;
  AImg.Canvas.FillRect(Rect(0,0, AImg.Width, AImg.Height));
  bHasImg := False;
  if (FImages <> nil) and (FItems[Idx].ImageIndex >= 0) then
    bHasImg := FImages.GetBitmap(FItems[Idx].ImageIndex, AImg);
  if not bHasImg and (FItems[Idx].Action is TCustomAction) then
    with TCustomAction(FItems[Idx].Action) do
      if (Images <> nil) and (ImageIndex >= 0) then
        bHasImg := Images.GetBitmap(ImageIndex, AImg);
  Result := bHasImg;
end;

绘制工具条

有了尺寸和Action就可以直接进行绘制。鼠标滑过和按下状态的处理方法和系统按钮区域的方法一致。

procedure TcpToolbar.Paint(DC: HDC);

  function GetActionState(Idx: Integer): TSkinIndicator;
  begin
    Result := siInactive;
    if (Idx = FPressedIndex) and (FHotIndex = FPressedIndex) then
      Result := siPressed
    else if Idx = FHotIndex then
      Result := siHover;
  end;

var
  cIcon: TBitmap;
  r: TRect;
  I: Integer;
  iOpacity: byte;
begin
  ///
  ///  工具条绘制
  ///

  /// 分割线
  r := Border;
  r.Right := r.Left + RES_CAPTIONTOOLBAR.w;
  SkinData.DrawElement(DC, steSplitter, r);
  OffsetRect(r, r.Right - r.Left, 0);

  /// 绘制Button
  cIcon := TBitmap.Create;
  cIcon.PixelFormat := pf32bit;
  cIcon.alphaFormat := afIgnored;
  for I := 0 to FCount - 1 do
  begin
    r.Right := r.Left + FItems[i].Width;
    if FItems[I].Enabled then
      SkinData.DrawButtonBackground(DC, GetActionState(i), r, FItems[i].Fade);
    if LoadActionIcon(i, cIcon) then
    begin
      iOpacity := 255;
      /// 处理不可用状态,图标颜色变暗。
      ///   简易处理,增加绘制透明度。
      if not FItems[i].Enabled then
        iOpacity := 100;

      SkinData.DrawIcon(DC, r, cIcon, iOpacity);
    end;
    OffsetRect(r, r.Right - r.Left, 0);
  end;
  cIcon.free;

  /// 分割条
  r.Right := r.Left + RES_CAPTIONTOOLBAR.w;
  SkinData.DrawElement(DC, steSplitter, r);
  OffsetRect(r, r.Right - r.Left, 0);

  /// 绘制下拉菜单按钮
  r.Right := r.Left + RES_CAPTIONTOOLBAR.w;
  SkinData.DrawElement(DC, stePopdown, r);
end;

响应鼠标事件

对于一个工具条,需要相应的事件有三个鼠标滑过按下弹起滑过是出现Hot效果,按下时处理Button被压下的效果,弹起时执行实际的Action事件。简单处理处理的这三种效果,如果考虑动画效果。那么需要创建一个时钟,设置个背景褪色量(其实是个Alpha透明通道值),然后根据褪色量在时钟消息中进行绘制。时钟最好设置在主皮肤类(TskForm)上,不必为每个区域创建一个句柄,这样可以减少系统资源(句柄)的占用。

统一消息入口,如果处理了此消息就返回True。这样可以让外部知道是否此消息被处理,以便外部作进一步的响应处理。

function TFormCaptionPlugin.HandleMessage(var Message: TMessage): Boolean;
begin
  Result := True;

  case Message.Msg of
    WM_NCMOUSEMOVE    : MouseMove(ScreenToClient(TWMNCMouseMove(Message).XCursor, TWMNCMouseMove(Message).YCursor));
    WM_NCLBUTTONDOWN  : MouseDown(mbLeft, ScreenToClient(TWMNCLButtonDown(Message).XCursor, TWMNCLButtonDown(Message).YCursor));
    WM_NCHITTEST      : HitWindowTest(ScreenToClient(TWMNCHitTest(Message).XPos, TWMNCHitTest(Message).YPos));
    WM_NCLBUTTONUP    : MouseUp(mbLeft, ScreenToClient(TWMNCLButtonUp(Message).XCursor, TWMNCLButtonUp(Message).YCursor));

    else
      Result := False;
  end;
end;

这里一个比较关键的是,鼠标在这个区域内的实际位置。一般窗体都会有Handle,所以能直接通过API转换鼠标位置。

区域需要依靠主窗口的位置才能获得。每次窗口在处理尺寸时,区域的偏移位置是可以获得的。像标题工具条这种左靠齐,其实这个偏移位置算好后就肯定是不会变的。

// 偏移量 = 有效标题区域 - 系统图标位置 - 区域间隙
r.Left := rCaptionRect.Left + 2 + GetSystemMetrics(SM_CXSMICON) + SPALCE_CAPTIONAREA;
r.Top := rCaptionRect.Top + (rCaptionRect.Height - FToolbar.Border.Height) div 2;
function TFormCaptionPlugin.ScreenToClient(x, y: Integer): TPoint;
var
  P: TPoint;
begin
  /// 调整位置 
  ///    以 FOffset 为中心位置
  P := FOwner.NormalizePoint(Point(x, Y));
  p.X := p.X - FOffset.X;
  p.Y := p.y - FOffset.Y;
  Result := p;
end;

上面鼠标的消息最终通过HitTest获取,实际鼠标所在按钮位置。这个处理方法和外部的TskForm处理方法一致,检测位置设置状态参数然后再重绘。

如:鼠标滑过时的消息处理。

procedure TcpToolbar.MouseMove(p: TPoint);
var
  iIdx: Integer;
begin
  /// 鼠标滑入时设置HotIndex值
  iIdx := HitTest(p);
  if iIdx <> FHotIndex then
  begin
    FHotIndex := iIdx;
    Invalidate;
  end;
end;

坐标所在按钮区域检测 HitTest

function TcpToolbar.HitTest(P: TPoint): integer;
var
  iOff: Integer;
  iIdx: integer;
  I: Integer;
begin
  ///
  ///  检测鼠标位置
  ///    鼠标位置的 FCount位 为工具条系统菜单位置。
  iIdx := -1;
  iOff := RES_CAPTIONTOOLBAR.w;
  if p.x > iOff then
  begin
    for I := 0 to FCount - 1 do
    begin
      if p.X < iOff then
        Break;

      iIdx := i;
      inc(iOff, FItems[i].Width);
    end;

    if p.x > iOff then
    begin
      iIdx := -1;
      inc(iOff, RES_CAPTIONTOOLBAR.w);
      if p.x > iOff then
        iIdx := FCount;  // FCount 为系统菜单按钮
    end;
  end;

  Result := iIdx;
end;

还有些细节方面的处理,如鼠标离开这个区域时的处理。这样整个工具区的基本处理完成,整个工具条区域的处理还是相对比较简单。

Action状态处理

Action处理主要是考虑,当外部改变Action状态。如:无效不可见的一些事件处理。标准的处理方法是在关联Action是创建一个ActionLink实现联动,由于TskForm没有从TControl继承,没法使用此方法进行处理。在TBasicAction改变状态时会触发一个OnChange的保护(protected)事件,可以直接把事件挂接上去,就能简单处理状态。

技巧:保护方法的访问,创建一个访问类,进行引用。下面写法就应使用父类的保护方法。
TacWinControl = class(TWinControl);
TacAction = class(TBasicAction);

ZeroMemory(@FItems[FCount], SizeOf(TcpToolButton));
  FItems[FCount].Action := Action;
  FItems[FCount].Enabled := true;       // <--- 这里应该获取Actoin的当前状态,这里简略处理。
  FItems[FCount].Visible := True;       // <--- 同上,注:现有代码中并未处理此状态
  FItems[FCount].ImageIndex := AImageIndex;
  FItems[FCount].Width := 20;
  FItems[FCount].Fade  := 255;
  FItems[FCount].SaveEvent := TacAction(Action).OnChange;  // 保存原事件
  TacAction(Action).OnChange := DoOnActionChange;          // 挂接事件

注意:不要把原事件丢了,需要保存。防止外部有挂接的情况下出现原事件无法执行。

根据状态的不同,直接修改记录的Enabled 和 Visible 这两个状态。绘制时可以直接使用。

procedure TcpToolbar.DoOnActionChange(Sender: TObject);
var
  idx: Integer;
  bResize: Boolean;
begin
  if Sender is TBasicAction then
  begin
    idx := IndexOf(TBasicAction(Sender));
    if (idx >= 0) and (idx < FCount) then
    begin
      ///  外部状态改变响应
      if FItems[idx].Action.InheritsFrom(TContainedAction) then
      begin
        FItems[idx].Enabled := TContainedAction(Sender).Enabled;
        bResize := FItems[idx].Visible <> TContainedAction(Sender).Visible;
        if bResize then
        begin
          FItems[idx].Visible := not FItems[idx].Visible;
          Update
        end
        else
          Invalidate;
      end;

      /// 执行原有事件
      if Assigned(FItems[idx].SaveEvent) then
        FItems[idx].SaveEvent(Sender);
    end;
  end;
end;

在绘制时就可以通过记录中的状态和鼠标位置状态进行判断,来绘制出所需要的效果

... ...
  // 如果按钮有效,那么进行按钮底色绘制。
  if FItems[I].Enabled then
    SkinData.DrawButtonBackground(DC, GetActionState(i), r, FItems[i].Fade);
  if LoadActionIcon(i, cIcon) then
  begin
    /// 处理不可用状态,图标颜色变暗。
    ///   简易处理,增加绘制透明度。
    iOpacity := 255;
    if not FItems[i].Enabled then
      iOpacity := 100;

    SkinData.DrawIcon(DC, r, cIcon, iOpacity);
  end;
  ... ...

  // 获取Action底色的显示状态
  //  按下状态、滑过状态、默认状态
  function GetActionState(Idx: Integer): TSkinIndicator;
  begin
    Result := siInactive;
    if (Idx = FPressedIndex) and (FHotIndex = FPressedIndex) then
      Result := siPressed
    else if Idx = FHotIndex then
      Result := siHover;
  end;

在窗体上加入测试Action

procedure TForm11.FormCreate(Sender: TObject);
begin
  FTest.Toolbar.Images := ImageList2;
  FTest.Toolbar.Add(Action1, 0);
  FTest.Toolbar.Add(Action2, 1);
  FTest.Toolbar.Add(Action3, 2);
end;

完成~~

最终效果,就是上面的GIF效果。想做的更好,那么就需要在细节上考虑。细节是最花时间的地方。

相关功能实现:

其实这个功能在Win7下已经有此接口可以实现(很久以前用过具体名字忘记了,没写日志的后果-_-),系统自带的画图就是使用此接口实现的。但有个问题就是XP下木有此功能。感兴趣的可以Google一下。

相关API:

  • MoveWindowOrg ---- 设置绘制原点
  • CreateRectRgnIndirect ---- 创建区域
  • SelectClipRgn ---- 剪切绘制区域

开发环境:

  • XE3
  • Win7

完整源代码: