
简介针对 Visual C 中需要展示大量数据或图形、窗口无法一次显示全部内容的开发场景这份示例工程演示了如何在 CScrollView 中实现垂直与水平滚动条的分页浏览。工程面向熟悉 MFC 视图架构、想要掌握滚动视图分页技巧的开发人员提供了可运行可参考的完整源码。压缩包共22个文件以头文件和实现文件为主配合资源文件、工程配置文件与图标位图整体约20KB。已有220人学习下载。示例代码重点展示了 SetScrollSizes 按页面宽高设置视口、重写 OnScroll 处理滚动事件、根据当前可视区域刷新页面内容等关键写法还保留了 ex04c 完整工程结构便于读者直接打开、编译并迁移到自身项目中。1. CScrollView 滚动条分页先搞清楚滚动和分页不是一回事在 Visual C 的 MFC 框架里CScrollView 是最常用的滚动视图基类但很多人把 SetScrollSizes 一行代码调通后就以为滚动条已经能配合分页逻辑工作了。实际上滚动条按像素滚动分页按内容块组织两者的步进单位完全不同。垂直滚动条和水平滚动条同时存在时页号计算、滚动范围换算、滚动到底能否精确停在页边界都需要在 OnScrollBy 和 OnDraw 里单独处理。这套方案覆盖坐标映射、按页绘制、动态刷新和边界排查适合正在用 MFC 维护报表工具或文档预览程序的一线开发。2. CScrollView 的坐标系统与滚动范围分页计算的前提2.1 SetScrollSizes 的四个参数决定滚动条行为CScrollView 的滚动能力完全由 SetScrollSizes 控制函数签名如下void SetScrollSizes( int nMapMode, // 映射模式分页场景用 MM_TEXT SIZE sizeTotal, // 内容总逻辑尺寸 const SIZE sizePage sizeDefault, // 滚动条滑块对应的页尺寸 const SIZE sizeLine sizeDefault // 点一次箭头的行步长 );这里最容易出问题的是 sizePage。很多人把 sizePage 理解成“内容里实际的一页”于是直接传入报表每页的高度结果滚动条滑块变得异常小拖动时一次跳一整个内容块完全没法细看。sizePage 在 MFC 里的语义是“单击滚动条空白区域时的滚动量”通常应设为视图窗口客户区的大小也就是一屏能看到的逻辑尺寸。真正意义上的分页需要在 OnScrollBy 里自己对齐不能指望 SetScrollSizes 替你做。参数单位也要说清楚在 MM_TEXT 映射模式下逻辑单位就是像素sizeTotal 传入 2000 就代表 2000 像素。如果你的内容高度是动态计算的每次内容变更后都要重新调用 SetScrollSizes否则滚动条范围不会自动更新。常见做法是维护一个 m_pageSize 成员变量专门保存“每页的逻辑尺寸”绘制和滚动对齐都拿它做基准后续改每页大小时只需改一处。2.2 逻辑坐标与设备坐标GetScrollPosition 返回的是什么分页代码里最常踩的坑是把 GetScrollPosition 的返回值当成设备坐标直接用。实际上CScrollView::GetScrollPosition 返回的是当前滚动位置对应的逻辑坐标原点也就是视图左上角在内容坐标系中的位置。画图时用这个偏移去平移 DC内容就能正确显示。函数返回值含义坐标类型GetScrollPosition当前视图左上角的内容坐标逻辑坐标MM_TEXT 下即像素GetDeviceScrollPosition当前滚动偏移量设备坐标GetTotalSize内容总尺寸逻辑尺寸来自 SetScrollSizesGetClientRect窗口客户区尺寸设备尺寸在 MM_TEXT 模式下两者数值相同但如果改用 MM_ISOTROPIC 等映射模式数值就会分叉。分页计算建议统一用 GetScrollPosition 配合总尺寸做除法不要混用两类坐标。如果项目里已经有用 SCROLLINFO 去读滚动条范围的习惯也要注意 CScrollView 的滚动范围是内部管理的直接 GetScrollInfo 拿到的可能是基类换算后的值应以 GetTotalSize 为准。3. CScrollView 垂直水平分页实现代码层面的落地步骤3.1 在 OnInitialUpdate 中初始化总尺寸与页尺寸一个典型的分页初始化代码void CMyScrollView::OnInitialUpdate() { CScrollView::OnInitialUpdate(); // 每页逻辑尺寸宽 800高 600 m_pageSize CSize(800, 600); // 总尺寸10 页内容水平方向每行放 3 页 int nPageCount 10; int nCols 3; m_totalSize CSize( m_pageSize.cx * nCols, m_pageSize.cy * ((nPageCount nCols - 1) / nCols) ); // sizePage 传客户区尺寸sizeLine 传 50 像素 CRect rcClient; GetClientRect(rcClient); SetScrollSizes(MM_TEXT, m_totalSize, CSize(rcClient.Width(), rcClient.Height()), CSize(50, 50)); }逻辑说明m_totalSize 是内容的总逻辑尺寸垂直方向按页数乘以每页高度算出水平方向按列数乘每页宽度。SetScrollSizes 的第三参数传客户区宽高让单击滚动条空白处的行为符合“翻一屏”的直觉而不是翻一整个逻辑页。参数说明nCols 为 3 表示水平方向按 3 页宽度排列页数不足时(nPageCount nCols - 1) / nCols向上取整避免 m_totalSize.cy 算少导致最后一页被截断。sizeLine 取 50 像素比较适中箭头点击一次滚动约一行内容。注意 OnInitialUpdate 在视图首次创建时会被调用多次如果这里有耗时计算要加标志位防重入。3.2 OnDraw 按页绘制只画可见区域分页视图的 OnDraw 核心思路是先由滚动位置算出当前可见的页范围只对这个范围内的页执行绘制页坐标转逻辑坐标再平移 DC。void CMyScrollView::OnDraw(CDC* pDC) { CPoint pos GetScrollPosition(); // 计算可见页范围含水平方向 int startRow pos.y / m_pageSize.cy; int startCol pos.x / m_pageSize.cx; CRect rcClient; GetClientRect(rcClient); int endRow (pos.y rcClient.Height()) / m_pageSize.cy; int endCol (pos.x rcClient.Width()) / m_pageSize.cx; // 平移设备上下文的原点使绘制代码可直接用内容坐标 pDC-SetViewportOrg(-pos.x, -pos.y); for (int row startRow; row endRow; row) { for (int col startCol; col endCol; col) { int pageIndex row * m_cols col; // 页号按行主序排列 if (pageIndex m_nTotalPages) continue; CRect pageRect( col * m_pageSize.cx, row * m_pageSize.cy, (col 1) * m_pageSize.cx, (row 1) * m_pageSize.cy); // 绘制边框与页内容 pDC-Rectangle(pageRect); DrawPageContent(pDC, pageIndex, pageRect); } } }逻辑说明先用 pos 除以每页尺寸得到左上角页的行列号再按客户区尺寸算出右下角页的行列号只遍历这个区间。SetViewportOrg 将 DC 原点平移到滚动位置的相反方向之后所有画图都用内容坐标书写不需要在每个绘制函数里减去 pos。参数说明m_cols 是水平摆放的页列数必须与 SetScrollSizes 时算 totalSize 用的列数一致否则页号 index 会错位。continue 防止最后一行的空格索引越界。Rectangle 画页框只是为了可视化分页边界实际项目里常替换成按 pageIndex 查数据再绘制的回调逻辑。m_nTotalPages 是总页数在初始化时赋值与 m_totalSize 的换算关系要保持一致。3.3 重写 OnScrollBy 实现翻页吸附滚动条默认按像素自由滚动所以滚动位置可能停在任意位置导致两页各显示一部分。要做“吸附到页边界”常见做法是重写 OnScrollBy在滚动前把目标位置修正到最近的页边界。交互路径触发入口分页处理位置拖动滚动条滑块基类内部处理 WM_VSCROLL / WM_HSCROLLSetScrollSizes 的范围约束点击滚动条空白区同上基类按 sizePage 滚动sizePage 传客户区尺寸即可鼠标滚轮虚拟函数 OnMouseWheel构造整页增量后走 OnScrollBy键盘 PageUp / PageDownWM_KEYDOWN 默认路由到 OnScrollBy在 OnScrollBy 里吸附BOOL CMyScrollView::OnScrollBy(CSize sizeScroll, BOOL bDoScroll) { if (m_bSnapToPage) { CPoint pos GetScrollPosition(); CSize newPos CSize(pos.x sizeScroll.cx, pos.y sizeScroll.cy); // 垂直方向对齐到最近的页边界 if (sizeScroll.cy ! 0) { int row (newPos.cy m_pageSize.cy / 2) / m_pageSize.cy; row max(0, min(row, (m_totalSize.cy / m_pageSize.cy) - 1)); newPos.cy row * m_pageSize.cy; } // 水平方向对齐到最近的页边界 if (sizeScroll.cx ! 0) { int col (newPos.cx m_pageSize.cx / 2) / m_pageSize.cx; col max(0, min(col, (m_totalSize.cx / m_pageSize.cx) - 1)); newPos.cx col * m_pageSize.cx; } // 重新构造实际滚动增量并交给基类 sizeScroll.cx newPos.cx - pos.x; sizeScroll.cy newPos.cy - pos.y; } return CScrollView::OnScrollBy(sizeScroll, bDoScroll); }逻辑说明先算出滚动后的目标位置用加半页再取整的办法四舍五入到最近的页边界clamp 保证不会滚出范围。然后把 sizeScroll 修正为“从当前位置到目标页边界的差值”再调用基类执行真正的滚动。bDoScroll 为 FALSE 时基类只做查询不实际滚动这常用于命中测试。参数说明m_bSnapToPage 是开关控制是否启用吸附。某些场景比如文本对比工具不希望吸附保留自由滚动更利于精细定位这个开关让两种模式共存。注意 OnScrollBy 会被鼠标滚轮、键盘、滚动条拖动三种路径调用所以在这里做吸附能覆盖所有输入方式不需要在每个事件里单独处理。4. 内容动态变化时的分页刷新避免滚动条跳位4.1 内容增加后重新 SetScrollSizes 并保持当前位置实际项目里内容不是静态的。比如报表追加了行、文档插入了节分页总尺寸变了如果直接重新 SetScrollSizes滚动条会回到顶部体验很差。需要在重建范围后恢复原来的滚动位置。void CMyScrollView::RebuildPages(int nNewTotalWidth, int nNewTotalHeight) { // 记住滚动位置对应的内容坐标 CPoint oldPos GetScrollPosition(); // 根据新内容尺寸更新总尺寸 m_totalSize CSize(nNewTotalWidth, nNewTotalHeight); CRect rcClient; GetClientRect(rcClient); // 重建滚动范围 SetScrollSizes(MM_TEXT, m_totalSize, CSize(rcClient.Width(), rcClient.Height()), CSize(50, 50)); // 恢复到原位置并做越界截断 CPoint newPos( min(oldPos.x, m_totalSize.cx - rcClient.Width()), min(oldPos.y, m_totalSize.cy - rcClient.Height())); newPos.x max(0, newPos.x); newPos.y max(0, newPos.y); ScrollToPosition(newPos); // 只重绘不重建窗口 Invalidate(FALSE); }逻辑说明SetScrollSizes 内部会重置滚动位置所以必须先 GetScrollPosition 存档。新内容尺寸变小比如筛选后行数减少时原滚动位置可能超出范围必须与“总尺寸减客户区尺寸”做 min 截断否则 ScrollToPosition 传入非法值会触发 MFC 断言。Invalidate(FALSE) 不擦除背景能减少重新绘制时的闪屏。参数说明nNewTotalWidth 和 nNewTotalHeight 传入的是新的内容总尺寸调用方负责把“新增页数 × 每页尺寸”的乘法算好。如果内容只是局部微调、尺寸没变就不用调用这个函数直接 Invalidate 即可。这里顺带说明一个常见误用有人为保持滚动位置先 ScrollToPosition 再 SetScrollSizes顺序反了刚设完又被重置回顶部。4.2 窗口尺寸变化后的页宽自适应窗口被拉大时如果分页宽度固定为 800客户区超过 800 后右侧会留白客户区小于 800 时又出现横向滚动。通用做法是监听 WM_SIZE在宽度变化时动态调整“每页宽度”让一页宽度尽量等于客户区宽度减掉滚动条宽度。void CMyScrollView::OnSize(UINT nType, int cx, int cy) { CScrollView::OnSize(nType, cx, cy); if (nType SIZE_RESTORED || nType SIZE_MAXIMIZED) { // 保留垂直滚动条宽度取系统值通常约 20 像素 int nSbWidth GetSystemMetrics(SM_CXVSCROLL); int nPageWidth max(cx - nSbWidth, 300); if (m_bFitPageWidth nPageWidth ! m_pageSize.cx) { // 记录旧的垂直位置 int oldPosY GetScrollPosition().y; m_pageSize.cx nPageWidth; m_totalSize.cx nPageWidth * m_cols; CRect rcClient; GetClientRect(rcClient); SetScrollSizes(MM_TEXT, m_totalSize, CSize(rcClient.Width(), rcClient.Height()), CSize(50, 50)); ScrollToPosition(CPoint(0, oldPosY)); } } }逻辑说明窗口每次改变大小时如果启用了页宽自适应就用客户区宽度减去垂直滚动条宽度作为新的页宽。这里把水平方向还原到第 0 列垂直位置按原值恢复避免用户看一半内容时被拉回顶部。max(cx - nSbWidth, 300) 设置 300 像素的下限防止窗口被拖到极窄时页宽变成负数。参数说明m_bFitPageWidth 是“页宽跟随窗口”的开关。注意 OnSize 在窗口初始化时也会触发此时 m_pageSize 可能还未赋值需要在 OnInitialUpdate 之前给成员变量设默认值或者加一个 m_bInitialized 标记跳过首次调用。4.3 分页模式下鼠标滚轮的步进处理Windows 鼠标滚轮默认只滚动几行在分页场景里感觉太细。常见做法是拦截 OnMouseWheel每次滚轮事件滚动一页。但如果直接调 ScrollToPosition会绕过 OnScrollBy 的吸附逻辑所以要构造滚动增量再走 OnScrollBy。BOOL CMyScrollView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { if (m_bWheelScrollPage) { CSize delta(0, 0); if (zDelta 0) delta.cy m_pageSize.cy; // 向后翻 else delta.cy -m_pageSize.cy; // 向前翻 OnScrollBy(delta, TRUE); return TRUE; } return CScrollView::OnMouseWheel(nFlags, zDelta, pt); }逻辑说明zDelta 是滚轮增量负值表示向下滚动所以向下翻页时 delta.cy 取正值滚动条向下 内容坐标增大。这里没有直接 ScrollToPosition而是把 delta 交给 OnScrollBy这样吸附逻辑仍然生效——虽然传入的 delta 本身就是整页吸附不会改变值但保持单一入口更稳妥。参数说明m_bWheelScrollPage 开关控制滚轮按页还是按行。zDelta 有时会是 ±120 的倍数高精度触控板会拆成小增量如果发现翻页速度过快可以加一个累加器累计超过 120 才滚一次。4.4 刷新策略与场景对照变更类型处理策略注意点内容尺寸不变内容变化Invalidate(FALSE) 重绘不需要动滚动范围内容变多需要滚更多RebuildPages 更新 totalSize先存档滚动位置内容变少可能越界RebuildPages min 截断防止 ScrollToPosition 断言窗口尺寸变化OnSize 中触发页宽自适应首次 OnSize 要跳过缩放比例变化如 200% DPI把 DPI 系数乘进 m_pageSize不要写死 800、6005. 分页滚动条的排错技巧边界条件与验证方法先看一个高频问题滚动不到最后一行。原因是 SetScrollSizes 的 sizePage 参数大于 totalSize 时CScrollView 会直接认为不需要滚动把范围设为 0。验证方法是读 GetTotalSize 确认总尺寸再看客户区尺寸两者差值就是可滚动范围。如果内容高度只比客户区大几十像素滚动条会出现但可拖动范围极小这是正常现象不是 bug。第二个坑是 OnDraw 里画的内容错位一截。原因通常是 GetScrollPosition 与 SetViewportOrg 用了不同的参考点。GetScrollPosition 返回的是 CPoint而 SetViewportOrg 接受的是 POINT如果中间做了 CSize/CPoint 之间的隐式转换负号容易丢。调试时用 OutputDebugString 打出 pos 和最终 DC 原点两边数值必须成相反数。也可以用 GetDeviceScrollPosition 交叉验证MM_TEXT 下两者应该一致。第三个坑是水平滚动时垂直位置跳动。原因是 OnScrollBy 里只做了单方向的边界 clamp处理水平滚动时垂直方向的吸附逻辑也被触发。建议把吸附抽成独立函数两个方向分开处理int SnapToPage(int nPos, int nDelta, int nPageSize, int nTotalSize) { int nTarget nPos nDelta; int nPage (nTarget nPageSize / 2) / nPageSize; nPage max(0, min(nPage, (nTotalSize / nPageSize) - 1)); return nPage * nPageSize - nPos; // 返回修正后的增量 }验证分页是否正确我会在 OnDraw 里临时画一个页号文本滚动到每一页的边界停下来看页号是否连续。再配合一个诊断函数在滚动结束后输出当前页坐标void CMyScrollView::DumpScrollState() { CPoint pos GetScrollPosition(); TRACE(_T(scroll pos(%d,%d) page(%d,%d)\n), pos.x, pos.y, pos.x / m_pageSize.cx, pos.y / m_pageSize.cy); }另一种定位技巧是临时关掉 m_bSnapToPage如果关闭后滚动流畅但开启后跳变问题一定在吸附算法里如果关闭后依然错位问题就在坐标换算或 OnDraw 绘制上。拿这个边界把问题一分为二十分钟内基本能锁定范围。本文还有配套的精品资源点击获取