资讯动态

MFC滚动条自绘:彩色滚动条原理与CColorScrollBar实现

发布时间:2026/8/30 18:13:15 来源:尧图企业网站定制
简介在Windows界面开发中控件自绘是提升UI质感与交互体验的关键技术。默认的滚动条样式往往与自定义主题格格不入尤其在上位机、工业控制软件中灰色滚动条成为视觉短板。理解滚动条自绘原理掌握CScrollBar的绘制消息与子类化技巧能够实现任意颜色的轨道、滑块和箭头。本文从GDI绘制、双缓冲防闪烁、滑块尺寸计算等核心概念出发结合WM_CTLCOLORSCROLLBAR消息与完整CColorScrollBar类代码展示如何在MFC中打造高可定制的彩色滚动条并解决高DPI缩放、拖拽通知等实际工程问题让控件外观与功能同步升级。 MFC里做彩色滚动条这件事我当年第一次接到需求的时候觉得挺简单的不就是改个颜色吗结果一上手才发现Windows的滚动条压根就不是个省油的灯。默认的滚动条长得灰头土脸你要是不做任何处理它在任何配色方案下都显得特别突兀尤其是做上位机、做工业控制软件、做自定义皮肤界面的时候那个默认样式的滚动条简直是整个窗体的颜值黑洞。我在几个实际项目里都踩过滚动条的坑这次就把彩色滚动条这件事从原理到实操完整拆一遍。这篇文章适合正在用VS2019/VS2022写MFC程序、被滚动条样式折磨过的朋友也适合想搞懂Windows控件自绘机制、但不想一上来就啃《Windows程序设计》大部头的初学者。1. 内容整体设计与思路拆解1.1 为什么默认滚动条这么难改先说结论滚动条不是一个普通的静态控件它是一个由系统负责绘制、并且在绘制过程中会发出大量通知消息的特殊窗口。很多人第一次尝试改滚动条颜色第一反应是找背景色属性或者尝试在OnCtlColor里处理滚动条的WM_CTLCOLOR消息结果发现根本没用。原因在于滚动条分为两大类标准滚动条Standard Scroll Bar和滚动条控件Scroll Bar Control。标准滚动条是附属于窗口的非客户区元素由系统绘制滚动条控件则是独立的自定义窗口但默认情况下它仍然使用系统主题绘制。两者默认都不响应WM_CTLCOLOR**这类针对静态控件和对话框控件的颜色通知。如果你非要改滚动条颜色本质上有三条路第一自绘Owner Draw。把滚动条改成自绘风格自己接管所有绘制逻辑。第二子类化Subclassing。拦截WM_PAINT、WM_ERASEBKGND等消息在默认绘制前后叠加自己的绘制。第三换控件。放弃CScrollBar改用CSliderCtrl滑块控件之类的替代品。大多数人一开始都会想用第三条路觉得滑块控件本身就能换颜色不用自绘那么麻烦。但实际项目中滑动条和滚动条在交互习惯上差别很大——滚动条支持点击轨道翻页PageUp/PageDown、支持在滑块上按住拖动、支持拖拽时实时反馈滑块控件虽然也能模拟但总有一些细节对不上。尤其是一些老旧的业务代码里已经传入了滚动条句柄硬要换成滑块控件改动量反而更大。所以正儿八经的做法还是自绘。MFC里处理自绘滚动条有成熟套路核心就一句话拦截滚动条控件的绘制消息用GDI/GDI画出自己想要的样子。1.2 彩色滚动条到底要解决什么问题有人说彩色滚动条不就是图个好看吗这话只对了一半。在实际工程里彩色滚动条有三个非常实在的价值。一是视觉反馈。工业软件和设备上位机里滚动条往往代表一个进度状态或者数据范围。比如电机运行进度、温度范围、料位变化如果滚动条的滑块颜色能随数值变化绿色到红色渐变操作员扫一眼就能知道当前状态不需要读旁边的小字。这比单纯好看的装饰性强得多。二是品牌统一。很多商业软件要给客户做定制UI整体配色是深色主题滚动条却保留系统浅灰色放在深色对话框里又刺眼又廉价。把滚动条改成和整体主题匹配的颜色是UI完整性的基本要求。三是可用性提升。默认滚动条在高DPI缩放下有时会出现滑块过小、难以点击的问题。自绘滚动条可以人为控制滑块的最小尺寸保证在任何缩放比例下都方便操作。这一点在工控触摸屏上特别重要默认的窄滑块在触摸屏上就是灾难。1.3 方案选型自绘、子类化还是重绘我梳理了三种主流实现路径直接先给结论如果你只是想把滚动条整体换个颜色且不要求滑块有特殊形状用WM_CTLCOLORSCROLLBAR消息配合CScrollBar的虚函数处理最简单代码量最少十几行搞定。如果你想要滑块有渐变、按钮有定制图标、轨道有纹理那必须走自绘路线派生一个CColorScrollBar类处理WM_PAINT消息。如果你不仅想要彩色还想要全新的交互逻辑比如自定义翻页大小、拖拽时显示气泡提示那就得彻底接管所有鼠标消息和键盘消息在自绘基础上扩展。三种方案对比如下方案实现难度可定制程度适用场景WM_CTLCOLORSCROLLBAR消息处理低低只改背景和滑块颜色CColorScrollBar派生类WM_PAINT自绘中高深度定制外观、渐变、贴图完全重写滚动条控件高极高特殊交互、触摸屏优化、风格彻底统一根据我的经验绝大多数项目做到第二种就够了。第三种方案通常出现在一些做大型UI框架的项目里比如自研皮肤引擎、跨平台界面库普通业务程序没必要这么折腾。2. 核心细节解析与实操要点2.1 标准滚动条的结构解剖不管用哪种方案你都得先搞清楚滚动条在屏幕上到底由哪几部分组成。一个垂直滚动条从上到下依次是上箭头按钮、滑块Thumb、轨道Track、下箭头按钮。水平滚动条则是从左到右左箭头、滑块、轨道、右箭头。这里面有几个关键窗口消息和通知码需要门儿清WM_PAINT控件的绘制入口自绘主要在这里做文章。WM_ERASEBKGND背景擦除消息处理不当会出现闪烁。WM_LBUTTONDOWN/WM_LBUTTONUP/WM_MOUSEMOVE鼠标交互基础。WM_HSCROLL/WM_VSCROLL滚动通知由父窗口接收。这个要特别强调滚动条控件本身不处理业务逻辑它只是把滚动事件发给父窗口由父窗口决定如何响应。SIF_ALL、SIF_PAGE、SIF_RANGE等标志位获取/设置滚动条参数时用的控制信息结构体是SCROLLINFO。如果你打算自绘滑块还需要手动计算滑块的位置和尺寸。MFC提供了GetThumbRect()函数可以直接拿到滑块矩形但这个函数返回的是基于整个控件客户区的坐标如果你自己处理了边框、内边距需要在拿到矩形后手动调整。另外一个特别容易被忽略的点滚动条的默认宽度。在经典主题下垂直滚动条的默认宽度是系统指标SM_CXVSCROLL通常是17像素。如果你要做自绘且想加宽滚动条必须在创建控件时用Create指定宽度而不是依赖默认尺寸。我实测过直接创建一个宽30像素的滚动条控件系统默认绘制时还是按17像素画只有自绘才能完全控住这个宽度。2.2 自绘滚动条的三大难点自绘滚动条看起来不难但真正做的时候有三个地方特别容易翻车。第一个难点滑块尺寸计算。滑块的长度不是随便定的它应该反映当前滚动条视口与内容长度的比例。也就是说内容越长滑块越短视口越大滑块越长。Windows提供了SIF_PAGE参数来告知系统视口大小当你调用SetScrollInfo时如果正确传入了SIF_PAGE系统自绘时的滑块尺寸就是正确的。但自绘时如果你自己算的不对滑块要么长到占满整个轨道要么短到点都点不中。这里给出一个参考计算方式int nMin, nMax; GetScrollRange(nMin, nMax); // 获取范围 SCROLLINFO si { sizeof(SCROLLINFO) }; si.fMask SIF_ALL; GetScrollInfo(si); int nPage si.nPage; // 视口页大小 int nRange si.nMax - si.nMin 1; // 内容总范围 int nTrackHeight rcTrack.Height(); // 轨道高度 int nThumbHeight (int)(nTrackHeight * (double)nPage / nRange); if (nThumbHeight 20) nThumbHeight 20; // 最小滑块高度防止太小点不到这个最小20像素的兜底逻辑是我在实际项目里加的。默认系统滚动条在内容特别长、视口特别小的时候滑块可能只有几个像素高鼠标很难精准命中。自绘最大的好处就是可以加这个兜底。第二个难点热区判断。鼠标点在上箭头、下箭头、滑块上方轨道、滑块下方轨道行为完全不同。你要计算好各区域的矩形在鼠标消息里做命中测试。代码层面就是一个大if-else或者switch分支分别调用不同的滚动逻辑。第三个难点闪烁控制。如果你直接处理WM_PAINT在WM_ERASEBKGND里又返回TRUE表示已擦除背景然后OnPaint里再画一遍反复拖动滚动条时界面会闪得厉害尤其在高DPI显示器上闪烁更明显。解决办法是双缓冲绘制——先在内存DC里画好整个滚动条再一次BitBlt到屏幕上这样就不闪了。2.3 消息机制的深入理解自绘滚动条最核心的一点是要理解WM_PAINT的触发时机和绘制上下文。系统在滚动条需要重绘时会给滚动条窗口发送WM_PAINT消息你的OnPaint函数会在此时被调用。关键在于OnPaint里拿到的CPaintDC只包含无效区域Invalid Region也就是说系统可能只让你重绘滑块那一小块区域而不是整个滚动条。这就导致一个经典bug你明明在OnPaint里画了箭头、轨道、滑块但拖动滚动条时只有滑块移动了轨道和箭头区域是旧内容显示上就花掉了。解决这个问题的思路有两条第一条在绘制前调用Invalidate()强制整个客户区无效让WM_PAINT每次都是全量绘制。简单粗暴但有效缺点是可能会带来轻微的性能损耗。第二条设置裁剪区域在OnPaint里自己用GetClipBox判断当前需要重绘的区域然后分块绘制。实现复杂但高效。我个人的实践是如果滚动条控件本身不大宽度几十像素、高度几百像素直接全量重绘即可性能完全不是问题反而代码简单、逻辑清晰不容易出奇葩bug。只有当滚动条出现在列表控件内部、被频繁触发重绘的时候才需要考虑局部重绘优化。另外还有一点滚动条控件在收到WM_SIZE消息后需要重新计算内部布局。如果你在OnPaint里硬编码了箭头尺寸、滑块位置窗口大小一变绘制就乱了。因此建议在OnSize里重新计算所有子矩形并把计算结果缓存为成员变量OnPaint只负责按缓存值绘制。这样既清晰又高效。3. 实操过程与核心环节实现3.1 方案一简易改色WM_CTLCOLORSCROLLBAR先来一个最简单的方案适合快速改色、做原型验证。MFC提供了WM_CTLCOLORSCROLLBAR消息专门用于给滚动条控件提供画刷和背景。虽然它不能完全自定义每个部件但能让滚动条背景、滑块底色变得跟主题匹配。在对话框类中重写OnCtlColorHBRUSH CMyDialog::OnCtlColor(CDC* pDC, CWnd* pWnd, UINT nCtlColor) { HBRUSH hbr CDialogEx::OnCtlColor(pDC, pWnd, nCtlColor); if (nCtlColor CTLCOLOR_SCROLLBAR) { // 设置滚动条背景为深色 pDC-SetBkColor(RGB(45, 45, 48)); if (m_brScrollBack.GetSafeHandle() NULL) { m_brScrollBack.CreateSolidBrush(RGB(45, 45, 48)); } return m_brScrollBack; } return hbr; }注意这里有个坑OnCtlColor返回的画刷会用于滚动条的背景填充但滑块和箭头的颜色仍然由系统主题决定。所以你只会得到一个深色底浅灰滑块的滚动条看起来还是有点违和。而且这个方法只对滚动条控件生效对窗口自带的非客户区滚动条比如CScrollView、CEdit里的滚动条无效。这个方案的定位是“应急”。如果项目马上就要演示了UI能看就行那用这个先顶一下没问题。但如果要做成产品级的UI必须走下面的自绘方案。3.2 方案二CColorScrollBar派生类与完整自绘好下面进入重头戏完整的自绘滚动条实现。这个方案我前后用在了三个实际项目里一次是设备参数配方的长列表滚动一次是数据曲线时间轴的横向滚动还有一次是工控触摸屏上的日志浏览整体效果和稳定性都经住了考验。先定义类// CColorScrollBar.h class CColorScrollBar : public CScrollBar { DECLARE_DYNAMIC(CColorScrollBar) public: CColorScrollBar(); virtual ~CColorScrollBar(); // 颜色配置接口 void SetBackColor(COLORREF clrBack); void SetThumbColor(COLORREF clrThumb); void SetThumbHoverColor(COLORREF clrThumbHover); void SetArrowColor(COLORREF clrArrow); void SetTrackColor(COLORREF clrTrack); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnSize(UINT nType, int cx, int cy); afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); DECLARE_MESSAGE_MAP() private: void DrawScrollBar(CDC* pDC); void DrawVerticalScrollBar(CDC* pDC); void DrawHorizontalScrollBar(CDC* pDC); void CalcLayout(); bool IsVertical(); COLORREF m_clrBack; COLORREF m_clrThumb; COLORREF m_clrThumbHover; COLORREF m_clrArrow; COLORREF m_clrTrack; CRect m_rcUp; // 上箭头区域 CRect m_rcDown; // 下箭头区域 CRect m_rcTrack; // 轨道区域不含箭头 CRect m_rcThumb; // 滑块区域 bool m_bHoverOnThumb; };在构造函数里初始化颜色为默认深色主题配色CColorScrollBar::CColorScrollBar() : m_clrBack(RGB(30, 30, 30)) , m_clrThumb(RGB(80, 80, 85)) , m_clrThumbHover(RGB(100, 100, 110)) , m_clrArrow(RGB(200, 200, 200)) , m_clrTrack(RGB(45, 45, 50)) , m_bHoverOnThumb(FALSE) { }CalcLayout是核心它负责计算各个部件的位置。这里我假定垂直滚动条——水平滚动条完全对称处理即可void CColorScrollBar::CalcLayout() { CRect rcClient; GetClientRect(rcClient); int nArrowSize GetSystemMetrics(SM_CXVSCROLL); if (nArrowSize 16) nArrowSize 16; // 高DPI下兜底 m_rcUp rcClient; m_rcUp.bottom m_rcUp.top nArrowSize; m_rcDown rcClient; m_rcDown.top m_rcDown.bottom - nArrowSize; m_rcTrack rcClient; m_rcTrack.top nArrowSize; m_rcTrack.bottom - nArrowSize; // 计算滑块位置 SCROLLINFO si { sizeof(SCROLLINFO) }; si.fMask SIF_ALL; GetScrollInfo(si); int nRange si.nMax - si.nMin 1; if (nRange 0 si.nPage 0) { int nTrackHeight m_rcTrack.Height(); int nThumbHeight (int)(nTrackHeight * (double)si.nPage / nRange); if (nThumbHeight 20) nThumbHeight 20; if (nThumbHeight nTrackHeight) nThumbHeight nTrackHeight; int nScrollPos si.nPos; int nScrollableRange si.nMax - si.nMin - si.nPage 1; if (nScrollableRange 0) { nScrollableRange 1; } int nThumbTop m_rcTrack.top (int)((nTrackHeight - nThumbHeight) * (double)(nScrollPos - si.nMin) / nScrollableRange); m_rcThumb.SetRect(m_rcTrack.left, nThumbTop, m_rcTrack.right, nThumbTop nThumbHeight); } else { m_rcThumb.SetRectEmpty(); } }这里面有几个细节必须说清楚。第一滑块位置的计算是按“比例换算”来的滚动位置在可滚动范围内的占比对应滑块在轨道空余区域中的占比。第二nPage是视口大小需要用SetScrollInfo设置过如果你从来没有设置过SIF_PAGE系统默认按0处理这样算出来的滑块是一整条轨道也不对。第三滑块最小尺寸必须兜底不然长内容场景下滑块小到没法点。然后是OnPaint和双缓冲绘制void CColorScrollBar::OnPaint() { CPaintDC dc(this); CMemDC memDC(dc, this); DrawScrollBar(memDC); } void CColorScrollBar::DrawScrollBar(CDC* pDC) { if (IsVertical()) { DrawVerticalScrollBar(pDC); } else { DrawHorizontalScrollBar(pDC); } }CMemDC是MFC的辅助类内部自动完成内存DC的创建和最后BitBlt贴回屏幕。用了它之后基本告别闪烁问题。如果你用的是旧版MFCVS2010以前没有CMemDC可以自己手动CreateCompatibleDCCreateCompatibleBitmap包一层C RAII类即可。垂直滚动条绘制函数void CColorScrollBar::DrawVerticalScrollBar(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); // 1. 背景 pDC-FillSolidRect(rcClient, m_clrBack); // 2. 轨道 pDC-FillSolidRect(m_rcTrack, m_clrTrack); // 3. 滑块 if (!m_rcThumb.IsRectEmpty()) { COLORREF clrThumbDraw m_bHoverOnThumb ? m_clrThumbHover : m_clrThumb; pDC-FillSolidRect(m_rcThumb, clrThumbDraw); // 加两条高光边让滑块有立体感 CRect rcHighlight m_rcThumb; rcHighlight.right rcHighlight.left 1; pDC-FillSolidRect(rcHighlight, RGB(clrThumbDraw m_clrThumbHover ? 130 : 110, clrThumbDraw m_clrThumbHover ? 130 : 110, 140)); CRect rcShadow m_rcThumb; rcShadow.left rcShadow.right - 1; pDC-FillSolidRect(rcShadow, RGB(50, 50, 55)); } // 4. 箭头用GDI画三角形 DrawArrow(pDC, m_rcUp, TRUE); DrawArrow(pDC, m_rcDown, FALSE); }绘制上箭头和下箭头的三角形时用GDI的Polygon函数就行void CColorScrollBar::DrawArrow(CDC* pDC, CRect rcArrow, BOOL bUp) { POINT ptTriangle[3]; if (bUp) { ptTriangle[0].x rcArrow.CenterPoint().x; ptTriangle[0].y rcArrow.top rcArrow.Height() / 3; ptTriangle[1].x rcArrow.left rcArrow.Width() / 4; ptTriangle[1].y rcArrow.bottom - rcArrow.Height() / 3; ptTriangle[2].x rcArrow.right - rcArrow.Width() / 4; ptTriangle[2].y ptTriangle[1].y; } else { ptTriangle[0].x rcArrow.CenterPoint().x; ptTriangle[0].y rcArrow.bottom - rcArrow.Height() / 3; ptTriangle[1].x rcArrow.left rcArrow.Width() / 4; ptTriangle[1].y rcArrow.top rcArrow.Height() / 3; ptTriangle[2].x rcArrow.right - rcArrow.Width() / 4; ptTriangle[2].y ptTriangle[1].y; } CBrush br(m_clrArrow); CBrush* pOldBrush pDC-SelectObject(br); CPen pen(PS_SOLID, 1, m_clrArrow); CPen* pOldPen pDC-SelectObject(pen); pDC-Polygon(ptTriangle, 3); pDC-SelectObject(pOldBrush); pDC-SelectObject(pOldPen); }接着是鼠标交互。滚动条有三个交互区域箭头区域、轨道区域、滑块区域。它们的点击行为分别是单步滚动SB_LINEUP/SB_LINEDOWN、翻页滚动SB_PAGEUP/SB_PAGEDOWN、拖拽滑块滚动SB_THUMBTRACK/SB_THUMBPOSITION。OnLButtonDown的实现void CColorScrollBar::OnLButtonDown(UINT nFlags, CPoint point) { if (m_rcThumb.PtInRect(point)) { // 开始拖拽滑块 SetCapture(); m_bDragging TRUE; m_nDragOffset IsVertical() ? (point.y - m_rcThumb.top) : (point.x - m_rcThumb.left); // 通知父窗口状态变为按下 GetParent()-SendMessage(WM_VSCROLL, MAKEWPARAM(SB_THUMBTRACK, GetScrollPos()), (LPARAM)GetSafeHwnd()); return; } if (m_rcUp.PtInRect(point)) { GetParent()-SendMessage(WM_VSCROLL, MAKEWPARAM(SB_LINEUP, 0), (LPARAM)GetSafeHwnd()); return; } if (m_rcDown.PtInRect(point)) { GetParent()-SendMessage(WM_VSCROLL, MAKEWPARAM(SB_LINEDOWN, 0), (LPARAM)GetSafeHwnd()); return; } // 轨道区域翻页 if (point.y m_rcThumb.top) { GetParent()-SendMessage(WM_VSCROLL, MAKEWPARAM(SB_PAGEUP, 0), (LPARAM)GetSafeHwnd()); } else if (point.y m_rcThumb.bottom) { GetParent()-SendMessage(WM_VSCROLL, MAKEWPARAM(SB_PAGEDOWN, 0), (LPARAM)GetSafeHwnd()); } }这里我是直接把WM_VSCROLL消息通过SendMessage发给了父窗口。但实际上在MFC里CScrollBar控件自己也会在合适的时机发送通知给父窗口。为了避免重复通知你需要把消息发送和控件自身的行为捋清楚。一种更干净的做法是不手动发送WM_VSCROLL而是调整滚动位置后直接调用SetScrollInfo更新位置再主动通知父窗口。我实际项目里的写法是在鼠标消息里只更新滑块位置然后封装一个NotifyParent(UINT nSBCode)函数统一发WM_VSCROLL/WM_HSCROLL给父窗口。这样做的好处在于逻辑清晰、可控性强不会出现父窗口收到两次通知导致滚动位置跳跃的问题。拖拽滑块的鼠标移动和释放void CColorScrollBar::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging (nFlags MK_LBUTTON)) { SCROLLINFO si { sizeof(SCROLLINFO) }; si.fMask SIF_ALL; GetScrollInfo(si); int nTrackHeight m_rcTrack.Height(); int nThumbHeight m_rcThumb.Height(); int nMaxTop m_rcTrack.bottom - nThumbHeight; int nNewTop IsVertical() ? (point.y - m_nDragOffset) : (point.x - m_nDragOffset); if (nNewTop m_rcTrack.top) nNewTop m_rcTrack.top; if (nNewTop nMaxTop) nNewTop nMaxTop; // 把像素位置换算成滚动位置 int nScrollableRange si.nMax - si.nMin - si.nPage 1; if (nScrollableRange 1) nScrollableRange 1; int nTrackScrollable nMaxTop - m_rcTrack.top; int nNewPos si.nMin (int)((double)(nNewTop - m_rcTrack.top) / nTrackScrollable * nScrollableRange); SetScrollInfo(SIF_POS, nNewPos); NotifyParent(SB_THUMBTRACK, nNewPos); Invalidate(); } else { BOOL bHover m_rcThumb.PtInRect(point); if (bHover ! m_bHoverOnThumb) { m_bHoverOnThumb bHover; Invalidate(); } } CScrollBar::OnMouseMove(nFlags, point); } void CColorScrollBar::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging FALSE; ReleaseCapture(); } CScrollBar::OnLButtonUp(nFlags, point); }拖拽过程中滑块跟随鼠标移动同时实时通知父窗口。这里有一个细节要特别说明拖拽时通知父窗口用SB_THUMBTRACK而不是SB_THUMBPOSITION。SB_THUMBTRACK表示拖动过程中持续通知父窗口可以实时刷新内容SB_THUMBPOSITION只在释放鼠标时通知一次。如果父窗口的OnVScroll里没有处理SB_THUMBTRACK而只处理了SB_THUMBPOSITION拖拽过程中内容不会跟着动体验很差。你可以在父窗口里两个都处理也可以像我一样在拖拽中发SB_THUMBTRACK释放时发SB_THUMBPOSITION。那么父窗口那边怎么接收通知在对话框类里添加消息处理void CMyDialog::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar) { if (pScrollBar ! NULL pScrollBar-GetSafeHwnd() m_wndColorScrollBar.GetSafeHwnd()) { int nCurPos m_wndColorScrollBar.GetScrollPos(); switch (nSBCode) { case SB_LINEUP: nCurPos - 1; break; case SB_LINEDOWN: nCurPos 1; break; case SB_PAGEUP: nCurPos - 10; break; case SB_PAGEDOWN: nCurPos 10; break; case SB_THUMBTRACK: case SB_THUMBPOSITION: nCurPos nPos; break; default: nCurPos GetScrollPos(); break; } m_wndColorScrollBar.SetScrollPos(nCurPos); UpdateData(TRUE); // 这里根据滚动位置刷新你的实际内容 RefreshListContent(nCurPos); return; } CDialogEx::OnVScroll(nSBCode, nPos, pScrollBar); }注意SB_THUMBTRACK的nPos参数在滚动条控件的通知里nPos有效但如果滚动条不是由用户拖动而是代码SetScrollPos触发nPos可能为0。所以我在父窗口里用GetScrollPos获取当前值再按增量做加减比直接信任nPos更保险。3.3 消息映射与SubclassDlgItem在对话框里给CScrollBar类型的控件“升级”成CColorScrollBar有两种方式。第一种是动态创建适用于运行时动态生成滚动条的场合m_wndColorScrollBar.Create(WS_CHILD | WS_VISIBLE | SBS_VERT, CRect(0, 0, 30, 200), this, IDC_COLOR_SCROLLBAR);第二种是对话框模板里先放一个普通CScrollBar控件然后在OnInitDialog里子类化BOOL CMyDialog::OnInitDialog() { CDialogEx::OnInitDialog(); // 这里把对话框上的IDC_SCROLLBAR1控件替换成自绘滚动条 m_wndColorScrollBar.SubclassDlgItem(IDC_SCROLLBAR1, this); m_wndColorScrollBar.SetBackColor(RGB(30, 30, 30)); m_wndColorScrollBar.SetThumbColor(RGB(0, 120, 215)); // 主题蓝滑块 m_wndColorScrollBar.SetThumbHoverColor(RGB(0, 140, 235)); m_wndColorScrollBar.SetArrowColor(RGB(200, 200, 200)); m_wndColorScrollBar.SetTrackColor(RGB(50, 50, 55)); // 必须设置滚动范围和视口大小 SCROLLINFO si { sizeof(SCROLLINFO) }; si.fMask SIF_RANGE | SIF_PAGE | SIF_POS; si.nMin 0; si.nMax 1000; si.nPage 50; si.nPos 0; m_wndColorScrollBar.SetScrollInfo(si, TRUE); return TRUE; }这里要强调一下SetScrollInfo的重要性。很多人子类化之后发现滑块不显示或者显示异常大概率就是忘了设置nPage视口大小。我在前面反复提过滑块长度由nPage和nMax的相对关系决定如果你不设置nPage系统认为视口大小是0滑块长度就按最小长度画看起来就像一个小方块卡在顶部。另外如果你改了控件宽度必须在OnSize里重新CalcLayout不然滑块位置和轨道范围全是旧值。消息映射部分这样写BEGIN_MESSAGE_MAP(CColorScrollBar, CScrollBar) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_WM_LBUTTONDOWN() ON_WM_LBUTTONUP() ON_WM_MOUSEMOVE() ON_WM_SIZE() ON_WM_SETCURSOR() END_MESSAGE_MAP()3.4 关于WM_ERASEBKGND的一个关键决策我建议在OnEraseBkgnd里直接返回TRUE表示背景已经被擦除避免系统用默认灰色背景刷一遍然后再叠加你的绘制内容造成闪烁BOOL CColorScrollBar::OnEraseBkgnd(CDC* pDC) { // 所有绘制都在OnPaint里完成这里不擦除防止闪烁 return TRUE; }配合OnPaint里的CMemDC双缓冲整个滚动条在拖动时的刷新是非常流畅的。我在2K分辨率屏幕上拖动验证过不会有撕裂感和闪烁。3.5 水平滚动条怎么处理水平滚动条的处理逻辑完全对称。计算布局时把箭头区域放在左右两侧轨道区域中间滑块横向计算。绘制时箭头三角形方向朝左右。鼠标消息里的翻页判断改为判断point.x与滑块左右边的位置关系。为了减少重复代码我在类里加了一个bool IsVertical()方法通过控件样式SBS_VERT判断方向bool CColorScrollBar::IsVertical() { return (GetStyle() SBS_VERT) ! 0; }然后在CalcLayout和DrawScrollBar里分别走垂直和水平两套分支。这样同一个类既能当垂直滚动条用也能当水平滚动条用不需要写两个类。4. 常见问题与排查技巧实录4.1 滑块不显示或者显示位置不对这个问题的排查顺序我总结成三步几乎能覆盖90%的情况第一检查SetScrollInfo是否设置了SIF_PAGE。如果nPage为0滑块尺寸计算会失败。另外nMax要大于nMinnPage否则可滚动范围是负数滑块位置计算也会出错。第二检查CalcLayout是否在OnSize里被调用。如果你的滚动条宽度变了或者系统主题改变了滚动条默认尺寸旧的布局缓存会让滑块画错位置。第三检查OnPaint里是否用了GetClientRect获得的最新客户区尺寸。如果你把客户区矩形缓存在成员变量里但OnSize没有及时更新也会出现绘制错位。4.2 拖动滑块时内容不跟随这个问题基本都出在通知码的处理上。父窗口的OnVScroll里只处理了SB_THUMBPOSITION和SB_LINEDOWN/SB_LINEUP没有处理SB_THUMBTRACK。按下滑块拖动时控件持续发SB_THUMBTRACK但父窗口直接走了default分支内容自然不动。解决办法在父窗口OnVScroll里显式处理SB_THUMBTRACK并在更新滚动位置后立即刷新内容。另外拖拽过程中我建议把滑块位置的换算逻辑放在滚动条控件内部避免父窗口二次计算产生误差。4.3 视觉效果闪烁闪烁的根本原因是绘制顺序和频率问题。如果你在OnEraseBkgnd里做了实质性的填充又在OnPaint里叠一层两个绘制上下文交替就会闪。再加上拖动时频繁触发WM_PAINT、WM_MOUSEMOVE闪烁会更加明显。最直接有效的解决组合OnEraseBkgnd返回TRUE不做任何真实擦除。OnPaint里使用CMemDC或手动创建内存DC双缓冲绘制。在OnMouseMove里用Invalidate(FALSE)而不是Invalidate(TRUE)禁止擦除背景。4.4 在高DPI下滚动条太小这是触摸屏和4K高分屏项目里的高频问题。默认滚动条控件宽度按系统指标SM_CXVSCROLL来在125%、150%缩放下虽然系统会放大但视觉上还是偏小。自绘方案完全可控你可以在Create时直接指定30-40像素的宽度也可以用GetDpiForWindow动态计算UINT uDpi GetDpiForWindow(GetSafeHwnd()); int nScrollWidth MulDiv(30, uDpi, 96);同时箭头区域尺寸、滑块最小尺寸也按DPI做缩放。这样不管用户把缩放比例调成多少滚动条都是好用且协调的。4.5 与CScrollView、CListView等自带滚动条的控件集成问题自绘CColorScrollBar只能用于独立的滚动条控件。像CScrollView、CEdit、CListBox这类控件它们自带滚动条这些滚动条属于控件内部实现不能直接用SubclassDlgItem替换。要让它们也用上彩色滚动条有几个办法用子类化拦截WM_NCPAINT和WM_NCCALCSIZE自己绘制非客户区滚动条难度高。给这些控件设置WS_EX_CLIENTEDGE等样式后用系统视觉样式美化但无法做完全自定义颜色。在控件外侧手动放置CColorScrollBar并隐藏内部自带滚动条。我实际项目里处理CScrollView时采用了第三种方案在ScrollView的父对话框上放置CColorScrollBar屏蔽CScrollView的自动滚动条显示。逻辑虽然绕一圈但效果可控而且不用去碰非客户区绘制。4.6 常见问题速查表症状可能原因解决办法滑块不显示未设置SIF_PAGE或范围异常设置正确的nMin/nMax/nPage滑块跳到奇怪位置CalcLayout未在OnSize中调用在OnSize里调用CalcLayout并Invalidate拖动时闪烁OnEraseBkgnd和OnPaint冲突双缓冲OnEraseBkgnd返回TRUE内容不跟随拖拽父窗口未处理SB_THUMBTRACKOnVScroll里增加SB_THUMBTRACK分支高DPI下太小固定像素尺寸用GetDpiForWindow动态计算尺寸点击轨道不翻页命中测试未包含轨道区域在OnLButtonDown里对轨道区域做PtInRect判断5. 从彩色滚动条到自绘控件的扩展思路做完彩色滚动条之后我对MFC自绘控件的理解上了一个台阶。过去遇到不好看的控件第一反应是到处找有没有第三方界面库、SkinMagic之类的换肤工具折腾一圈发现要么兼容性差要么授权费高。自绘滚动条这个项目让我意识到MFC的控件自绘机制其实远比想象中灵活。同理你可以把这个套路扩展到其他标准控件自绘按钮用BS_OWNERDRAW自绘组合框CComboBox的DrawItem自绘列表项CListCtrl的NM_CUSTOMDRAW。这些控件的自绘消息和滚动条不完全一样——比如按钮是DrawItem列表是NM_CUSTOMDRAW——但底层思路一脉相承理解系统默认绘制流程找到你要介入的消息时机用GDI或者GDI接管绘制最后加上双缓冲防闪烁一个好看且稳定运行的自绘控件就成了。我后来在这个滚动条基础上继续扩展做了一个支持渐变滑块、圆角轨道的皮肤版滚动条还加上了鼠标滚轮支持。整个代码量控制在六七百行以内比起引入一个动辄几MB的界面库这个自绘方案显得格外清爽也更好维护。如果你在项目里也遇到了滚动条颜色不协调的问题我建议你直接复制上面的CColorScrollBar类改改颜色配置就能用。第一次跑通的时候你可能觉得“就这”但真正放到深色主题界面里看效果时你会明显感觉到整个窗体的质感完全不一样了。这就是自绘控件的价值——小改动大提升。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价