
1. 为什么要在PB里折腾自定义按钮PowerBuilder后面统一简称PB这个老家伙做企业级管理系统的朋友应该都不陌生。它的原生控件风格停留在Win95/XP那个年代按钮灰扑扑的圆角、渐变、图标、悬停变色这些现代UI的基本诉求靠原生commandbutton基本实现不了。很多人第一反应是去调Windows API用SendMessage、SetWindowLong、BitBlt那一套去重绘按钮或者干脆用u_dw这类第三方控件。但API重绘这条路坑是真的多。我早年做过一个项目客户要求按钮带圆角和渐变团队里有人用GDI32的CreateRoundRectRgn配合SetWindowRgn去裁剪按钮区域结果在XP上跑得好好的一到Win7开Aero主题就出现边缘锯齿和残影窗口拖动时按钮区域还会闪烁。更麻烦的是一旦系统DPI缩放不是100%整个按钮的坐标全乱套点击热区跟视觉区域对不上。后来排查了两天发现是GetWindowRect拿到的尺寸和实际绘制尺寸在高DPI下不一致导致的。所以这篇要聊的是一条完全不碰API重绘的路子用PB自己的对象体系通过用户对象UserObject 数据窗口DataWindow 图片控件的组合做出视觉上完全自定义、行为上又跟原生按钮一致的按钮。核心思路一句话概括把按钮的外观和行为拆开外观交给数据窗口或图片去画行为用PB事件去模拟。这条路适合谁适合那些维护老PB系统、又不想引入外部控件、不想碰API、但确实需要把界面做得像样一点的开发者。它不需要你懂Win32消息机制只需要你熟悉PB的UserObject、DataWindow和事件模型。下面我把整套方案拆开讲包括选型理由、具体实现、踩过的坑以及一些能直接抄的代码。2. 三种不碰API的实现路线对比在动手之前得先把可选路线理清楚。PB里做自定义按钮不碰API的前提下主流有三条路各有各的适用场景选错了后面会很难受。2.1 路线一图片控件 鼠标事件模拟这是最直观的做法。放一个picturebutton或者普通picture控件准备三张图正常态、悬停态、按下态。然后在控件的MouseMove、LButtonDown、LButtonUp事件里切换图片同时用TriggerEvent去触发自定义的clicked事件。这条路的优点是实现快、视觉还原度高设计师给什么图就能做出什么效果圆角、阴影、渐变全都不是问题。缺点是图片资源管理麻烦一个按钮三张图一个系统几十个按钮就是上百张图改个颜色要重新导一遍。而且图片缩放时如果比例没算好会出现拉伸变形。2.2 路线二数据窗口 计算字段模拟按钮这是我最推荐的一条路也是本文的重点。核心做法是建一个外部数据源的数据窗口对象里面放一个计算字段computed field把按钮的文字、背景色、边框都通过表达式画出来。然后把数据窗口放在窗口上通过数据窗口的Clicked事件判断点的是哪个区域再触发对应逻辑。这条路的优点是完全不用图片、颜色和文字都能动态改、支持多语言、支持DPI缩放相对友好。缺点是圆角和渐变做起来费劲数据窗口的绘制能力有限圆角得靠RoundRectangle函数在pbm_dwnprint之类的绘制事件里手动画稍微复杂一点。2.3 路线三UserObject 继承 commandbutton 做视觉欺骗这条路的思路是还是用原生的commandbutton但把它藏起来——设置Visible False或者把它移到窗口可视区域外然后在它上面盖一个statictext或者text控件用statictext的BackColor、BorderStyle来模拟按钮外观。用户看到的是statictext但点击事件通过statictext的Clicked转发给隐藏的commandbutton。这条路最省事但最不推荐。因为隐藏的按钮仍然占着Tab顺序键盘操作会乱而且statictext的边框样式非常有限做不出真正的自定义效果。它只适合那种只想改个背景色、别的都不动的极简需求。下面这张表把三条路线的关键维度拉平对比一下维度图片控件方案数据窗口方案隐藏原生按钮方案视觉自由度高依赖图片中高可动态绘制低资源管理成本高多图低无图极低动态改色/改字需换图直接改表达式改属性即可DPI缩放表现一般图会糊较好好键盘可访问性需手动处理需手动处理天然支持实现复杂度低中低推荐场景视觉要求极高的主按钮通用业务按钮临时凑合选型建议很明确如果按钮数量多、需要动态改状态走数据窗口方案如果只是登录页那一两个主按钮要好看走图片方案。隐藏原生按钮那条路除非你只是想把某个按钮的背景从灰色改成浅蓝否则别碰。3. 数据窗口方案的核心机制拆解既然主推数据窗口方案就得把它讲透。很多人对用数据窗口做按钮有误解以为是把数据窗口当画布随便画其实不是。数据窗口的绘制是有明确层次的理解这个层次才能知道哪些效果能做、哪些做不了。3.1 数据窗口的绘制层次与按钮外观的映射关系一个数据窗口的显示区域从底到顶大致分四层背景层、带区band层、列/计算字段层、前景绘制层。做按钮的时候我们主要用的是计算字段层和前景绘制层。计算字段层负责画按钮的静态外观背景色用计算字段的Background.Color属性文字用计算字段的expression边框用Border属性。但计算字段的边框只有Box、ShadowBox、Raised、Lowered这几种做不出圆角。圆角必须靠前景绘制层也就是在数据窗口的pbm_dwnprint或者pbm_dwnpaint事件里用RoundRectangle函数手动画。这里有个关键点数据窗口的绘制事件触发频率很高每次滚动、每次刷新都会触发。如果在绘制事件里做复杂计算会明显拖慢界面。所以我的做法是静态外观尽量用计算字段的属性表达只有圆角这种属性表达不了的才放到绘制事件里。而且绘制事件里的坐标计算要尽量简单能缓存就缓存。3.2 点击热区判定为什么不能用 Clicked 的返回值数据窗口的Clicked事件会返回三个参数row、column、dwo。很多人以为靠dwo就能判断点的是哪个按钮但实际用下来会发现当计算字段设置了Protect 1只读时Clicked事件里的dwo可能是空的因为只读字段不接收焦点。正确的做法是在Clicked事件里用xpos和ypos参数这两个参数是相对于数据窗口左上角的像素坐标结合每个按钮计算字段的X、Y、Width、Height属性自己做矩形包含判断。判断逻辑很简单// 假设按钮计算字段名为 cb_submit位于 dw_1 中 long ll_x, ll_y, ll_w, ll_h ll_x dw_1.Object.cb_submit.X ll_y dw_1.Object.cb_submit.Y ll_w dw_1.Object.cb_submit.Width ll_h dw_1.Object.cb_submit.Height if xpos ll_x and xpos ll_x ll_w and ypos ll_y and ypos ll_y ll_h then // 触发按钮逻辑 parent.TriggerEvent(ue_submit) end if注意xpos和ypos是相对于数据窗口客户区的如果数据窗口有滚动条还得加上滚动偏移量。这个坑我在一个带滚动条的列表页上踩过当时按钮在数据窗口底部用户滚动后点击位置全错排查了半天才发现是没算VScrollPos。3.3 状态管理正常、悬停、按下三态怎么切换按钮的三态切换靠的是数据窗口的MouseMove和LButtonDown/LButtonUp事件。MouseMove事件里判断鼠标是否在按钮区域内如果在就把按钮计算字段的背景色改成悬停色LButtonDown时改成按下色LButtonUp时恢复。这里有个性能问题MouseMove触发极其频繁如果每次都在里面改数据窗口对象的属性界面会卡。我的优化做法是加一个状态变量记录当前按钮处于哪个状态只有状态发生变化时才去改属性// 实例变量i_state0正常1悬停2按下 if li_new_state i_state then i_state li_new_state // 只在状态变化时改属性 dw_1.Object.cb_submit.Background.Color ls_color end if这个优化看起来简单但效果非常明显。在一个有20个按钮的界面上不加这个判断鼠标划过时CPU占用能到15%以上加了之后基本在2%以下。4. 从零搭一个可复用的自定义按钮对象前面讲的是机制这一节讲怎么落地。目标是把整套逻辑封装成一个可复用的UserObject以后新窗口直接拖进去就能用不用每次重写。4.1 建一个继承 datawindow 的 UserObject在PB的UserObject画板里新建一个DataWindow类型的用户对象命名为u_custom_button。数据窗口的样式选FreeForm数据源选External不绑任何表。然后在数据窗口里放一个计算字段命名为cb_main表达式先随便写个按钮。关键属性设置如下cb_main的Border设为None因为边框我们要自己画。cb_main的Background.Color设为一个表达式比如if(i_state 1, RGB(230,240,255), RGB(240,240,240))这样状态一变颜色自动变。cb_main的Protect设为1防止用户点击时进入编辑状态。数据窗口本身的HScrollBar和VScrollBar都设为False避免出现滚动条。4.2 用实例变量承载按钮的配置按钮的文字、颜色、尺寸这些配置不能写死在数据窗口里得通过实例变量传进去。在UserObject里定义这些实例变量string is_text 确定 long il_width 80 long il_height 28 long il_normal_color RGB(240,240,240) long il_hover_color RGB(220,235,255) long il_press_color RGB(200,220,250) long il_text_color RGB(50,50,50) integer ii_state 0然后写一个of_setup函数让外部调用时传入这些配置public function integer of_setup(string as_text, long al_width, long al_height); is_text as_text il_width al_width il_height al_height // 把配置应用到数据窗口对象 this.Object.cb_main.Width al_width this.Object.cb_main.Height al_height this.Object.cb_main.Expression as_text return 1 end function注意Expression的写法字符串要套单引号如果文字里本身有单引号还得做转义。这个细节不注意的话按钮文字带撇号就会报错。4.3 在 MouseMove 里做悬停判定与状态切换MouseMove事件的参数是xpos、ypos跟Clicked一样是相对坐标。判定逻辑跟前面讲的热区判定一样只是这里要处理移入和移出两个方向// MouseMove 事件 long ll_x, ll_y, ll_w, ll_h ll_x this.Object.cb_main.X ll_y this.Object.cb_main.Y ll_w this.Object.cb_main.Width ll_h this.Object.cb_main.Height boolean lb_inside lb_inside (xpos ll_x and xpos ll_x ll_w and ypos ll_y and ypos ll_y ll_h) if lb_inside and ii_state 0 then ii_state 1 this.Object.cb_main.Background.Color il_hover_color elseif not lb_inside and ii_state 1 then ii_state 0 this.Object.cb_main.Background.Color il_normal_color end if这里有个细节如果鼠标快速划过按钮MouseMove可能来不及触发移出事件导致按钮卡在悬停态。解决办法是在数据窗口的LoseFocus或者窗口的MouseMove里做一次兜底重置。我一般是在窗口的MouseMove里判断如果鼠标不在任何按钮上就把所有按钮重置为正常态。4.4 用 LButtonDown/LButtonUp 完成点击闭环按下和抬起的处理要跟悬停状态配合。按下时把状态设为2颜色改成按下色抬起时先判断鼠标是否还在按钮内如果在触发点击逻辑然后恢复悬停态如果不在直接恢复正常态。// LButtonDown 事件 if ii_state 1 then ii_state 2 this.Object.cb_main.Background.Color il_press_color end if // LButtonUp 事件 if ii_state 2 then // 判断抬起时鼠标是否还在按钮内 if xpos ll_x and xpos ll_x ll_w and ypos ll_y and ypos ll_y ll_h then parent.TriggerEvent(ue_clicked) end if ii_state 1 this.Object.cb_main.Background.Color il_hover_color end if这个抬起时判断是否在按钮内的逻辑是模拟原生按钮行为的关键。原生按钮如果按下后把鼠标拖出去再松开是不会触发点击的。很多人做自定义按钮时忽略这一点导致用户体验跟原生不一致。5. 圆角、渐变与图标视觉细节的取舍数据窗口方案最大的短板就是视觉表现力。这一节专门讲怎么在有限的能力下把按钮做得尽量好看以及哪些效果该放弃。5.1 圆角按钮的绘制事件实现圆角必须靠绘制事件。在数据窗口的pbm_dwnprint事件里用RoundRectangle函数画。这个函数的签名是RoundRectangle(x, y, width, height, radiusx, radiusy)前四个是矩形区域后两个是圆角半径。// pbm_dwnprint 事件 long ll_x, ll_y, ll_w, ll_h ll_x this.Object.cb_main.X ll_y this.Object.cb_main.Y ll_w this.Object.cb_main.Width ll_h this.Object.cb_main.Height // 先画一个跟背景同色的矩形盖住计算字段的直角边框 this.SetRedraw(False) RoundRectangle(ll_x, ll_y, ll_w, ll_h, 8, 8) this.SetRedraw(True)但这里有个问题RoundRectangle画出来的圆角是描边不是填充。要填充的话得先用Rectangle画填充再用RoundRectangle画边框而且填充的矩形要稍微小一点避免盖住圆角。这个操作在PB里做起来很别扭因为数据窗口的绘制函数能力有限。我的实际经验是如果圆角半径小于6像素直接用计算字段的Border设为Box视觉上差别不大如果非要大圆角建议改用图片方案。不要为了一个圆角在绘制事件里折腾半天性价比太低。5.2 渐变背景的替代方案数据窗口的计算字段背景色只支持纯色不支持渐变。想要渐变只有两个办法一是用图片二是用绘制事件逐行画。逐行画的做法是在绘制事件里循环每一行用Line函数画一条横线颜色从起始色渐变到结束色。但这样画一个按钮要循环几十次性能很差而且数据窗口刷新时会闪烁。所以我的建议是渐变背景直接用图片方案不要用数据窗口硬扛。如果整个系统统一用数据窗口方案那就放弃渐变用纯色边框阴影来做出层次感。纯色做得好一样能做出专业的视觉效果不一定非要渐变。5.3 图标与文字的排版处理数据窗口里放图标可以用Picture类型的计算字段把Expression设成图片路径。但图片路径如果是相对路径部署时会出问题。我的做法是把图片路径做成一个全局函数根据当前程序运行目录动态拼接// 全局函数 gf_get_icon_path return gs_app_path \icons\ as_icon_name文字和图标的位置关系靠计算字段的X、Y属性控制。图标在左、文字在右是最常见的布局。注意图标和文字之间的间距要留够一般8到12像素比较合适太挤了显得局促。6. 键盘可访问性与焦点管理的补课自定义按钮最容易忽略的就是键盘操作。原生按钮支持Tab聚焦、空格键触发自定义按钮如果不做处理键盘用户根本用不了。这一节讲怎么补上这块。6.1 用 Tab 顺序与焦点事件模拟聚焦态数据窗口本身可以接收焦点但数据窗口里的计算字段不行。所以键盘聚焦要落在数据窗口这一层。做法是把数据窗口的TabOrder设成一个非零值让它能进入Tab序列。然后在数据窗口的GetFocus和LoseFocus事件里切换按钮的聚焦态外观一般是加一个虚线边框或者改变边框颜色。// GetFocus 事件 ii_state 3 // 3表示聚焦态 this.Object.cb_main.Border 5 // 5是虚线边框 // LoseFocus 事件 ii_state 0 this.Object.cb_main.Border 0但这里有个问题一个数据窗口只能有一个焦点如果窗口上有多个自定义按钮每个按钮都是一个独立的数据窗口Tab顺序是没问题的但如果多个按钮放在同一个数据窗口里就没法单独聚焦了。所以我的做法是一个按钮一个数据窗口对象虽然窗口上控件多了点但焦点管理清晰。6.2 空格键与回车键的响应处理键盘触发按钮靠的是数据窗口的Key事件。判断按下的键是不是空格ASCII 32或者回车ASCII 13如果是就触发点击逻辑// Key 事件 if key KeySpace! or key KeyEnter! then parent.TriggerEvent(ue_clicked) return 1 // 阻止默认处理 end if注意return 1很重要不返回的话空格键可能会被数据窗口当成其他操作处理。这个坑我在一个表单页上踩过当时空格键会触发数据窗口的翻页排查了好久才发现是没拦截按键。6.3 焦点在多个自定义按钮间的流转如果窗口上有多个自定义按钮Tab键切换时焦点会在各个数据窗口之间流转。这里要注意数据窗口的TabOrder值要连续中间不要留空否则Tab会跳过去。另外如果按钮旁边还有输入框输入框的TabOrder也要跟按钮的TabOrder统一规划保证Tab顺序符合用户的直觉——一般是从上到下、从左到右。7. 实测中暴露的五个典型问题这套方案我在三个项目里用过踩的坑不少。挑五个最有代表性的讲一下都是文档里不会写的。7.1 数据窗口刷新导致的闪烁数据窗口在刷新时会重绘整个客户区如果按钮的背景色是通过计算字段表达式动态算的每次刷新都会重新计算视觉上就是一闪。解决办法是把按钮计算字段的Background.Color设成固定值状态切换时用Modify去改而不是用表达式。表达式每次刷新都重算Modify改的是属性值不会重算。7.2 高DPI下的坐标偏移在150%缩放的显示器上数据窗口的xpos、ypos返回的是逻辑坐标而计算字段的X、Y属性也是逻辑坐标两者是一致的所以热区判定本身没问题。但图片资源会模糊因为图片是按像素存的缩放后会被拉伸。如果用了图片方案一定要准备2倍图或者用矢量图PB不支持SVG这是个硬伤。7.3 按钮文字过长时的截断处理计算字段的文字如果超过宽度默认是截断不会自动换行。如果按钮文字是动态的比如带数量的提交(12)文字长度会变。我的做法是在of_setup里根据文字长度动态调整按钮宽度用一个简单的估算公式宽度 文字长度 * 字号 * 0.6 内边距。这个系数0.6是经验值中文字体大概在这个范围。7.4 禁用态与只读态的处理按钮有时候需要禁用比如表单没填完时。禁用态的处理不能只是改颜色还要拦截所有鼠标和键盘事件。我的做法是在Clicked、MouseMove、Key事件的开头都加一个判断如果ib_enabled False直接return。同时把背景色改成灰色文字改成浅灰视觉上明确区分。7.5 与原生按钮混用时的视觉一致性如果窗口上既有原生按钮又有自定义按钮视觉上会很不协调。我的建议是要么全用原生要么全用自定义不要混。如果实在要混至少把原生按钮的字体、字号、高度调成跟自定义按钮一致减少违和感。8. 封装成可复用对象后的调用姿势最后讲一下封装好之后怎么用。u_custom_button这个UserObject建好之后在窗口上的使用步骤是这样的在窗口上放一个DataWindow控件把DataObject设成u_custom_button。在窗口的Open事件里调用of_setup传入配置dw_1.of_setup(提交, 100, 32) dw_1.il_normal_color RGB(240,240,240) dw_1.il_hover_color RGB(220,235,255)在窗口上定义一个自定义事件ue_submit写业务逻辑。在dw_1的ue_clicked事件里这个事件在UserObject里定义触发窗口的ue_submit// u_custom_button 的 ue_clicked 事件 parent.TriggerEvent(ue_submit)这样一套下来新增一个按钮只需要拖一个数据窗口控件、写三行配置代码剩下的交互逻辑全部复用。我在一个有两百多个按钮的后台系统里用这套方案后期维护成本比图片方案低了不止一个量级——改个主题色只需要改一个全局变量所有按钮自动跟着变。需要提醒的是这套方案不适合做那种需要复杂动画的按钮比如点击后有个扩散波纹效果。PB的绘制能力做不了这个硬做会非常卡。如果产品经理非要这种效果只能上第三方控件或者换技术栈这不是PB该干的活。另外u_custom_button这个对象建议放在PBL的公共库比如pfc或者自建的common库里不要放在每个窗口自己的PBL里否则改一次要改几十个地方。这个教训是我在一个项目后期重构时用血泪换来的——当时按钮对象散落在十几个PBL里改一个圆角半径改了一下午。