转载几篇别人写的皮肤类控件的技术文章
原连接:http://blog.sina.com.cn/s/blog_4c3538470100ezhu.html
实现控件的透明背景
很多情况下,我们需要控件 的背景是透明的,就是要求直接看到控件父窗口的背景颜色、背景位图,比如标签控件、单选Radio控件、复选Check控件,通常都要求在父窗口的背景上 进行绘制。然而要求控件的画布透明,这个技术在GDI的文档中没有看到Microsoft作任何说明,当然还是有别的办法。
其一: 如果程序支持桌面主题服务的话,则可调用主题服务的API来实现背景。我们先看看这个API:
HRESULT DrawThemeParentBackground(HWND hwnd , HDC hdc , RECT *prc );
这个函数就是专门用来绘制父窗口的背景的,其中的hwnd参数是子窗口的句柄,hdc也时子窗口的画布句柄,prc是子窗口需绘制的区域,这个函数实际是把父窗口的背景拷贝到子窗口上来,以这种方法达到透明。
其二: 如果程序不支持桌面主题服务,则不能使用上面的方法,比如程序运行在Windows2000上。这时我们可以向父窗口发送WM_PAINT消息,不过此消息所附带的wParam参数是一个画布句柄:
HDC dc = GetDC(NULL);
HWND cWnd= ...;//子窗口句柄
HWND pWnd= ...;//父窗口句柄
RECTcRect;
GetClientRect(cWnd,&cRect);
HBITMAPbitmap = CreateCompatibleBitmap(dc, cRect.right,cRect.bottom);
ReleaseDC(dc);
HDCmemDC = CreateCompatibleDC(NULL);
HGDIOBJoldBitmap = SelectObject(memDC, bitmap);
//此处可以调用SetClipRect()等函数来限制绘制范围
SendMessage(pWnd, WM_ERASEBKGND, (WPARAM)memDC,0);
SendMessage(pWnd, WM_PAINT, (WPARAM)memDC,0);
//至此memDC上已经保存了父窗口的背景内容
//用户可以调用BitBlt(...)等函数拷贝memDC的内容到子窗口的某个区域,这样就达到了透明效果;
SelectObject(memDC, oldBitmap);
DeleteDC(memDC);
DeleteObject(bitmap);
上面的办法当然有限制,因为不是所有的父窗口都可以接受这种特殊的WM_PAINT消息功能,不过MSDN中提到大多数控件都有这个功能,大家要注意读它的文档内容。
其三: 如果上面的办法都不行的话,就剩下最笨的办法了,用GDI函数涂刷子窗口的背景,但你事先就要知道父窗口的背景颜色、背景位图等信息。比如拿父窗口的颜色来填充子窗口的背景,可以调用FillRect()等:
//-------------------父窗口的背景是颜色的情况
COLORREFpColor = ...;//父窗口的颜色
HDC cDC =...;//子窗口的画布句柄
RECTcRect = ...;//子窗口需刷新的区域
HBRUSHbrush = CreateSolidBrush(pColor);
FillRect(cDC, &cRect,brush);
DeleteObject(brush);
//-------------------父窗口的背景是位图的情况
HBITMAPpBitmap = ...;父窗口的背景位图
HDC cDC =...;//子窗口的画布句柄
RECTcRect = ...;//子窗口需刷新的区域
POINTrp;
SetBurshOrgEx(cDC, x, y,&rp);如果是位图刷子,则还需要调整画布的刷子原点偏移确保无缝
HBRUSHbrush = CreatePatternBrush(pBitmap);
FillRect(cDC, &cRect,brush);
DeleteObject(brush);
SetBurshOrgEx(cDC, rp.x, rp.y,NULL);还原画布的刷子偏移
揭秘换肤技术:定制控件背景颜色与背景位图
本文讲述如何定制控件的背景颜色和背景位图的技巧。
首先要说一下控件的绘制过程:当控件的某个区域需要重绘时,都会触发WM_ERASEBKGND和WM_PAINT消息。比如控件的某个区域被另一个程序的窗口挡住了,而后那个窗口又被移走了,这时控件被挡住的内容就需要重新绘制了。
第一步:系统向控件发送WM_ERASEBKGND消息以实现背景的擦除工作(有时不发送,比如用户可能调用InvalidateRect(),其参数却指定不擦除背景,这样就没有这个消息);
第二步:系统向控件的窗口过程发送WM_PAINT消息,控件执行处理这个WM_PAINT消息时会有选择地触发后面三个步骤的动作;
第三步:对于有些标准控件,如Button、Edit、ListBox、 ScrollBar、Static控件,它还会向父窗口发送WM_CTLCOLORxxx(WM_CTLCOLORBUTTON、 WM_CTLCOLOREDIT、WM_CTLCOLORSTATIC、WM_CTLCOLORLISTBOX、 WM_CTLCOLORSCROLLBAR等)的消息,这些消息返回一个刷子句柄,系统拿这个刷子句柄进一步涂刷自己的背景。另外还发现Trackbar 也会向父窗口发送WM_CTLCOLORSTATIC消息,TreeView在某些情况下也有,不过我没有看到微软在什么地方对这一点作说明;我常常发现 很多人处理这个消息时,喜欢给系统返回一个NULL_BRUSH的空刷子,以为这样系统就不会把前面步骤画好的背景覆盖掉,其实不一定的,有些控件不覆 盖,有些就有问题,像Trackbar就是如此,要小心。
第四步:对于菜单和许多标准控件,如Button、Edit、ListBox、 Static、ComboBox它可能会向父窗口发送WM_MEASUREITEM和WM_DRAWITEM消息,另外通用控件Tab、 StatusBar、ListView、Header也可能会有WM_DRAWITEM消息;但对于多数通用控件,如TreeView、 ListView、Rebar、Trackbar、Toolbar等,它会向父窗口发送许多其ID为NM_CUSTOMDRAW的WM_NOTIFY消 息。对于这两种消息,实际要求用户在已经涂刷好的背景之上再执行自己的绘制工作;
第五步:当控件的WM_DRAWITEM或者WM_NOTIFY消息没有被用户处理时,系统会亲自执行自己的默认绘制工作,把控件画出来,这一步没有办法重载。
知道了这些步骤,大概我们心中已经了然,知道如何定制控件的背景颜色和背景位图了。一般情况 下我们定制第一步、第三步实现自己的特殊背景,定制第四部实现控件本身的特殊绘制。甚至我们可以整个重载控件第一步的WM_ERASEBKGND消息和第 二步的WM_PAINT消息,控件背景和控件绘制全部自己搞定,没人说这样做不行。不过要注意,当自己实现WM_PAINT消息的重载处理时,后面3个步 骤就都不发生了。
定制颜色倒是很简单,根据控件的类型处理WM_ERASEBKGND、 WM_CTLCOLORxxx、WM_DRAWITEM、WM_NOTIFY消息了,一般情况下,定制WM_ERASEBKGND和 WM_CTLCOLORxxx就可以了;对于背景位图很多控件却特别麻烦,像ListBox,你把背景位图涂刷好,结果因为用户操作滚动条或鼠标滚轮或按 方向键,背景位图也发生滚动,这就不得不重绘位图,对于背景颜色就没有这个问题,不管怎么滚动,颜色还是那个颜色,位图就不行,需要自己重载发生滚动操作 的各种消息以实现位图重绘。微软似乎并不假设你会修改控件的背景位图,它没有对这个情况作准备,总是毫不犹豫的对控件画布执行Scroll操作。真正实现 背景位图的方法常常迫使我们要拦截那些导致窗口内容发生滚动的各种操作,因此位图背景的功能实现也总让人觉得不怎么规范、不那么可信。
最后要说明的是:如果父控件还包含背景透明的子控件,你应当重载父控件的WM_ERASEBKGND消息,否则那些透明背景的子控件可能就没有正确的背景内容。
上面这些步骤是我个人的理解,不一定对哦!仅供参考。
揭秘换肤技术:实现系统滚动条换肤功能
对于Windows系统中各种控件换肤功能,要数滚动条的换肤最难实现了,尤其是控件自带的系统滚动条,如Edit、ListBox、ListView、TreeView等自带的系统滚动条,要想实现其自定义的皮肤功能,用常规办法似乎都无法实现。
对于常规的皮肤定制一般都是通过定制WM_PAINT、WM_ERASEBKGND、 WM_CTLCOLORxxx、NM_CUSTOMDRAW来实现。然而系统滚动条的绘制,常规的、很阳光的方法行不通,微软把一条康庄大道堵死了。根据 我的观察测试,系统滚动条有许多的消息都对其执行了绘制,这包括WM_NCPAINT、WM_NCMOUSEMOVE、WM_NCMOUSELEAVE、 WM_HSCROLL、WM_VSCROLL、WM_KEYDOWN、WM_MOUSEWHEEL等等,这些消息中有些可以定制,但有些没法定制。比如 WM_HSCROLL就无法定制,如果我们处理这个消息,就必须自己控制滚动条的范围、位置、翻页值,而这些值我们通常无法得到,微软根本没有告诉我们, 对于不同的控件它操控滚动条到底采用何种策略,不晓得。假若我们不去定制这个WM_HSCROLL,而默认的处理它又执行滚动条的绘制,这真是进退无路, 叫人束手无策。当然不是完全没有办法,死路一条。为了克服障碍,翻越障碍,每个人有不同的策略,有攀岩翻越的,安全性不高;也有走小路绕行的,比较费力。
在网上反复搜索,自己也仔细研究,基本有两种办法来实现系统滚动条换肤,一种方法是HOOKAPI,也就是拦截API的办法,还有一种是模拟法。
拦截API,实际上是修改操作系统的API入口。因为系统绘制滚动条是通过各种绘制函数来实 现的,比如SetScrollInfo(),基本系统通过这一类函数实现滚动条的绘制,对这个API进行拦截,也就是我们自己写一个 SetScrollInfo(),来亲自实现滚动条的绘制,以便由此实现滚动条的自定义绘制,实现我们想要的各种风格的皮肤外观。API的拦截有两种:一 种是修改系统所装入内存可执行模块的导入地址,替换成我们所写的伪API的地址,使API调用能够自动跳转到这个伪API上;还有一种是直接修改API函 数首地址处的若干机器指令,保存现场,写入跳转指令,使系统在执行到这个API时能自动跳转到我们所写的伪API上。我要说的是,这个拦截办法还真是有些 邪门,有安全隐患,病毒就常干这种事情。对操作系统进行这类暴力破解式的修改,很容易引起系统防火墙或反病毒软件报错,Windows原则上不允许干这种 事;其二,一旦使用此法的程序因异常而崩溃,原本对操作系统的修改没有还原,这可能会伤害到系统里的所有进程的稳定性,导致死机或运行异常。对于商业性的 工程开发,往往很忌讳这一点,都倾向于追求稳定,通常是宁用拙法,不玩巧技;其三,此法有线程同步问题:因为正当你修改程序指令同时,若其他进程里的线程 恰好执行到这里时候问题就会爆发出来,单CPU可能没啥问题,系统内存和CPU缓冲可以做到同步,但多核系统就难说了;另外这个方法还有移植性问题:在一 种版本的系统中,通过拦截API有效,在另一种版本的系统中,就不见得还有效,毕竟微软实现滚动条的绘制,它没规定一定就用哪个函数来实现,也许新系统中 它另有高招,API拦截也就不灵了。
下面我要详细讲的是模拟法了,这个办法不用拦截API,所有的技术实现都约束在系统所允许的范围内,咱一丝不苟的当遵纪守法的良民。
所谓模拟法就是在系统滚动条的区域放置一个模拟窗口,这个窗口专门用于绘制系统滚动条。当然我们也要拦截带滚动条控件的若干消息,以确保模拟窗口的绘制正确。下面只以ListView控件的水平滚动条为例,进行说明,垂直滚动条换肤可以列推。
首先我们要在滚动条的区域上创建一个模拟窗口,恰好覆盖滚动条:
HWND hListView =...;//ListView窗口的句柄
HWND pWnd = ::GetParent(hListView);
HWND hBuddy =::CreateWindowEx(WS_EX_NOACTIVATE, "Buddy_Window", "",WS_CLIPSIBLINGS|WS_DISABLED|WS_CHILD, 0, 0, 0, 0, pWnd, NULL,gModule, NULL);
Buddy_Window是你注册的模拟窗口类。注意,窗口一定要有WS_DISABLED 风格,这是个关键,这个风格能够确保鼠标操作能够透过模拟窗口,而直接操控到所覆盖的滚动条,模拟窗口本身不接受任何鼠标键盘的输入。另外读取滚动条的矩 形以及它的各个元素的可视、可用、压下等状态可通过GetScrollBarInfo()这个API来完成,不过要说明一点,这个API有些Bug,大家 可去下载FreeCL2.03 版源码,里头修正了这个问题。
完成创建模拟窗口之后,你要给ListView安装一个你写的窗口过程,以拦截各种导致滚动条属性改变的种种消息:
LRESULT CALLBACK MyListViewProc(HWNDhwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
case WM_LBUTTONDOWN:
......
case WM_LBUTTONDBLCLK:
......
case WM_NCMOUSEMOVE:
......
case WM_NCMOUSELEAVE:
......
}
......
gOldListViewProc =(WNNPROC)::GetWindowLong(hListView, GWL_WNDPROC);
::SetWindowLong(hListView, GWL_WNDPROC,LONG(&MyListViewProc));//安装窗口过程
消息处理 :
首先我们要拦截WM_NCLBUTTONDOWN 和WM_NCLBUTTONDBLCLK这两个消息,当你在滚动条上按下鼠标时,就立刻触发WM_NCLBUTTONDOWN,如果连续快速按两下鼠标就 还有WM_NCLBUTTONDBLCLK。注意双击时只有一个WM_NCLBUTTONDOWN消息,而不是两个。第二次按鼠标会出现一个 WM_NCLBUTTONDBLCLK。实际上这两个消息我们完全可以等而视之,做相同的处理。系统在处理这个两个消息时,都会在其内部处理中触发许多其 他消息,其中有若干个WM_HSCROLL、WM_CAPTURECHANGED、WM_NCMOUSELEAVE。我们主要是要处理 WM_HSCROLL,因为它最有价值。在你松开鼠标后,系统发送并处理完最后一个WM_HSCROLL之后,这才从WM_NCLBUTTONDOWN或 WM_NCLBUTTONDBLCLK中返回:
if(msg == WM_NCLBUTTONDOWN || msg== WM_NCLBUTTONDBLCLK)
{
//注意默认处理会有N个WM_HSCROLL消息出现,要等你的按下拖拽操作完成后,这个调用才返回
//在这个调用内部,我估计系统会进入一种消息循环,因为按住左键之后,WM_NCMOUSEMOVE
//和WM_NCLBUTTONUP都不再触发了。其内部估计是捕捉了WM_NCMOUSEMOVE消息,因之反复刷新滚动
//盒的位置,若有必要你可安装鼠标钩子,以捕捉鼠标移动消息,以及时刷新模拟窗口中滚动盒的
//的位置。若只是响应WM_HSCROLL消息,你可能觉得滚动盒的拖拽比较滞,不平滑。
//SetWindowsHookEx(...);
LRESUTL code= ::CallWindowProc(gOldListViewProc, hListView, msg, wParam,lParam);
//UnhookWindowsHookEx(...);
returncode;
}
我建议大家去微软的网站下载ControlSpy2.0 这个小工具,它用来监视控件的消息,这东西很有用。
下面我们再看WM_HSCROLL消息,这个消息一般是系统处理WM_NCLBUTTONDOWN或者WM_NCLBUTTONDBLCLK时产生的,但有时也可能是程序刻意发送的,和滚动条操作没有关系。
if(msg == WM_HSCROLL)
{
::CallWindowProc(gOldListViewProc, hListView, msg, wParam,lParam);
//1)读取滚动条的数据
//2)再在模拟窗口上绘制滚动条
//......
return0;
}
另外还有许多其他的消息可能导致滚动条属性的变化,如用户的键盘操作、鼠标滚轮操作可能导致 滚动条的Thumb位置发生改变,这些操作分别导致WM_KEYDOWN和WM_MOUSEWHEEL,而系统又在这两个消息中执行滚动条的绘制,因此你 必须截获它们,但处理方法同WM_HSCROLL,这里不再啰嗦了。
当我们没有按下鼠标左键时,只是简单地在滚动条移动鼠标时,我们会发现滚动条的按钮和 Thumb都会自动高亮,其实这些变化都是系统在WM_NCMOUSEMOVE和WM_NCMOUSELEAVE中绘制完成的。比如当鼠标进入到 Thumb上时,它就高亮了,当鼠标离开它,它又变灰色了,你要分别处理WM_NCMOUSEMOVE和WM_NCMOUSELEAVE这两个消息。注意 只有ListView应用主题风格之后才可能有WM_NCMOUSELEAVE消息,传统风格的ListView就没有这个消息,只有 WM_NCMOUSEMOVE消息,因此处理高亮还真有些麻烦。正是因为主题风格和传统风格的差异,因此我建议你也处理WM_THEMECHANGED消 息,以识别不同的主题模式,实现不同高亮处理。看上面那个图,TreeView的滚动条是主题风格的,但ListView的滚动条是传统风格的,但我加了 换肤功能。
另外,我们还应当拦截处理ListView的WM_NCPAINT,将滚动条的区域抠掉,不让它绘制滚动条。尽管滚动条被模拟窗口遮住了,但还是有必要这样做,以防止出现意外情况:
if(msg == WM_NCPAINT)
{
HRGN wRgn =NULL;
RECTsbRect = GetScrollBarRect();//读取滚动条矩形的一个函数
HRGNsRgn = ::CreateRectRgn(sbRect.left, sbRect.top, sbRect.right,sbRect.bottom
if(wParam ==1)
{
RECT wRect;
::GetWindowRect(hListView, &wRect);
wRgn = ::CreateRectRgn(wRect.left, wRect.top, wRect.right,wRect.bottom););
wRgn = ::CombineRgn(wRng, wRgn, sRgn, RGN_DIFF);
}
else
{
wRgn= (HRGN)wParam;
wRgn = ::CombineRgn(wRng, wRgn, sRgn, RGN_DIFF);
}
::DeleteObject(sRgn);
::CallWindowProc(gOldListViewProc, hListView, WM_NCPAINT,WPARAM(wRgn), 0);
if(wParam ==1)
::DeleteObject(wRgn);
return0;
}
另外,还要拦截WM_WINDOWPOSCHANGING消息,因为当ListView的位 置、尺寸、Z秩序发生改变时要及时调整模拟窗口的位置、尺寸、Z秩序。为何要在WM_WINDOWPOSCHANGING中进行,而不选择在 WM_WINDOWPOSCHANGED或WM_MOVE或WM_SIZE中进行呢?因为在WM_WINDOWPOSCHANGING中处理,可让模拟窗 口先于ListView调整自己的位置、尺寸和Z,可避免一些绘制问题。实际我在FreeCL中既用了WM_WINDOWPOSCHANGING,也用到 WM_WINDOWPOSCHANGED两个消息。在处理WM_WINDOWPOSCHANGING时调整位置、尺寸、Z秩序,在处理 WM_WINDOWPOSCHANGED时,强制重绘模拟窗口。
末了还要说明一点的是,系统可能因为用户改变控件尺寸导致其滚动条自动消失或自动显示,或者 调整滚动条的可用状态,如你拉宽支持多行显示的Edit控件,会导致滚动条箭头按钮变灰,Thumb消失。这些动作通常都是在 WM_WINDOWPOSCHANGED中完成的,因此这个消息也需要拦截处理:
if(msg == WM_WINDOWPOSCHANGED)
{
LRESULTlReturn = ::CallWindowProc(gOldListViewProc, hListView,WM_WINDOWPOSCHANGED,
wParam, lParam);
if(::GetNextWindow(hBuddy, GW_HWNDNEXT ) !=hListView)
{ //调整模拟窗口的Z-Order,确保其紧贴ListView之上
HWND hAbove = ::GetNextWindow(hListView, GW_HWNDPREV);
UINT flag =SWP_NOACTIVATE|SWP_NOREDRAW|SWP_NOMOVE|SWP_NOSIZE|SWP_NOSENDCHANGING;
::SetWindowPos(hBuddy, hAbove?hAbove:HWND_TOP, 0, 0, 0, 0,flag);
}
//测试滚动条的显隐状态是否改变,并因之调整模拟窗口的显隐状态
//如果滚动条仍旧显示或滚动条某些元素的显示状态有变化或控件位置、尺寸可能改变,
//强制重绘模拟窗口
//......
returnlReturn;
}
最后要处理WM_SHOWWINDOW和WM_DESTROY,当ListView隐藏时让模拟窗口也随之消失;当它显示时,让模拟窗口也随之显示。当ListView销毁时会触发WM_DESTROY,这时也需要销毁模拟窗口,这很重要。
总的来讲,模拟法是蛮麻烦的,但比较安全,可靠性高。对开发者来讲,都是对消息进行特定处 理,是常规手段,对最终用户来讲,用此法实现的滚动条自绘,完全能够满足要求,具有充分的自由度,甚至可以对滚动条添加更多特定的功能,比如可以在上面添 加其他按钮,就算是加动画、加广告也都是可以的。