Qt 圆角表格控件实战:setMask + 抗锯齿描边 + 多层阴影

Qt 圆角表格控件实战:setMask + 抗锯齿描边 + 多层阴影

一个 Qt C++ 桌面应用 Demo,实现带圆角、投影的现代化数据表格卡片组件。


项目概览

本项目是一个基于 Qt 6 Widgets 的桌面应用 Demo,核心目标是探索如何在传统 QWidget 体系中实现现代 UI 常见的「圆角卡片 + 投影 + 圆角表格」组合效果。

项目包含两个核心自定义控件:

控件 职责
ShadowWidget 多层投影容器,支持四角独立圆角
RoundedTableView 圆角 QTableView,setMask 裁剪 + 抗锯齿描边

最终效果:一个带投影的白色卡片,内含整洁的数据表格,右上和右下为圆角,左上和左下为直角。


技术架构

控件树嵌套结构

#mermaid-svg-kics0IgLOYqTI2Iq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kics0IgLOYqTI2Iq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kics0IgLOYqTI2Iq .error-icon{fill:#552222;}#mermaid-svg-kics0IgLOYqTI2Iq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kics0IgLOYqTI2Iq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kics0IgLOYqTI2Iq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kics0IgLOYqTI2Iq .marker.cross{stroke:#333333;}#mermaid-svg-kics0IgLOYqTI2Iq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kics0IgLOYqTI2Iq p{margin:0;}#mermaid-svg-kics0IgLOYqTI2Iq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kics0IgLOYqTI2Iq .cluster-label text{fill:#333;}#mermaid-svg-kics0IgLOYqTI2Iq .cluster-label span{color:#333;}#mermaid-svg-kics0IgLOYqTI2Iq .cluster-label span p{background-color:transparent;}#mermaid-svg-kics0IgLOYqTI2Iq .label text,#mermaid-svg-kics0IgLOYqTI2Iq span{fill:#333;color:#333;}#mermaid-svg-kics0IgLOYqTI2Iq .node rect,#mermaid-svg-kics0IgLOYqTI2Iq .node circle,#mermaid-svg-kics0IgLOYqTI2Iq .node ellipse,#mermaid-svg-kics0IgLOYqTI2Iq .node polygon,#mermaid-svg-kics0IgLOYqTI2Iq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kics0IgLOYqTI2Iq .rough-node .label text,#mermaid-svg-kics0IgLOYqTI2Iq .node .label text,#mermaid-svg-kics0IgLOYqTI2Iq .image-shape .label,#mermaid-svg-kics0IgLOYqTI2Iq .icon-shape .label{text-anchor:middle;}#mermaid-svg-kics0IgLOYqTI2Iq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kics0IgLOYqTI2Iq .rough-node .label,#mermaid-svg-kics0IgLOYqTI2Iq .node .label,#mermaid-svg-kics0IgLOYqTI2Iq .image-shape .label,#mermaid-svg-kics0IgLOYqTI2Iq .icon-shape .label{text-align:center;}#mermaid-svg-kics0IgLOYqTI2Iq .node.clickable{cursor:pointer;}#mermaid-svg-kics0IgLOYqTI2Iq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kics0IgLOYqTI2Iq .arrowheadPath{fill:#333333;}#mermaid-svg-kics0IgLOYqTI2Iq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kics0IgLOYqTI2Iq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kics0IgLOYqTI2Iq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kics0IgLOYqTI2Iq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kics0IgLOYqTI2Iq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kics0IgLOYqTI2Iq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kics0IgLOYqTI2Iq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kics0IgLOYqTI2Iq .cluster text{fill:#333;}#mermaid-svg-kics0IgLOYqTI2Iq .cluster span{color:#333;}#mermaid-svg-kics0IgLOYqTI2Iq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kics0IgLOYqTI2Iq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kics0IgLOYqTI2Iq rect.text{fill:none;stroke-width:0;}#mermaid-svg-kics0IgLOYqTI2Iq .icon-shape,#mermaid-svg-kics0IgLOYqTI2Iq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kics0IgLOYqTI2Iq .icon-shape p,#mermaid-svg-kics0IgLOYqTI2Iq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kics0IgLOYqTI2Iq .icon-shape .label rect,#mermaid-svg-kics0IgLOYqTI2Iq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kics0IgLOYqTI2Iq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kics0IgLOYqTI2Iq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kics0IgLOYqTI2Iq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 表格层
投影层
主窗口
study (QWidget)

background: #f0f2f5
ShadowWidget

16 层渐变投影 + 四角独立圆角
🖼️ 阴影绘制

16 层路径, 二次衰减透明度
🖼️ 卡片背景

白色填充 + 可选边框
RoundedTableView (QTableView)
🔧 Layer 1: setMask

多边形裁剪整个 widget 树
🎨 Layer 2: paintEvent

抗锯齿描边遮盖锯齿
QHeaderView

列头
Viewport

单元格内容
QScrollBar

动态 margin 避让圆角

双层渲染流程

🖥️ 屏幕 paintEvent (Layer 2) setMask (Layer 1) 🖥️ 窗口系统 🖥️ 屏幕 paintEvent (Layer 2) setMask (Layer 1) 🖥️ 窗口系统 #mermaid-svg-DA6vjKFTLnAMy5wJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DA6vjKFTLnAMy5wJ .error-icon{fill:#552222;}#mermaid-svg-DA6vjKFTLnAMy5wJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DA6vjKFTLnAMy5wJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DA6vjKFTLnAMy5wJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DA6vjKFTLnAMy5wJ .marker.cross{stroke:#333333;}#mermaid-svg-DA6vjKFTLnAMy5wJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DA6vjKFTLnAMy5wJ p{margin:0;}#mermaid-svg-DA6vjKFTLnAMy5wJ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DA6vjKFTLnAMy5wJ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DA6vjKFTLnAMy5wJ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-DA6vjKFTLnAMy5wJ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-DA6vjKFTLnAMy5wJ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-DA6vjKFTLnAMy5wJ .sequenceNumber{fill:white;}#mermaid-svg-DA6vjKFTLnAMy5wJ #sequencenumber{fill:#333;}#mermaid-svg-DA6vjKFTLnAMy5wJ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-DA6vjKFTLnAMy5wJ .messageText{fill:#333;stroke:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DA6vjKFTLnAMy5wJ .labelText,#mermaid-svg-DA6vjKFTLnAMy5wJ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .loopText,#mermaid-svg-DA6vjKFTLnAMy5wJ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DA6vjKFTLnAMy5wJ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-DA6vjKFTLnAMy5wJ .noteText,#mermaid-svg-DA6vjKFTLnAMy5wJ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-DA6vjKFTLnAMy5wJ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DA6vjKFTLnAMy5wJ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DA6vjKFTLnAMy5wJ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DA6vjKFTLnAMy5wJ .actorPopupMenu{position:absolute;}#mermaid-svg-DA6vjKFTLnAMy5wJ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-DA6vjKFTLnAMy5wJ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DA6vjKFTLnAMy5wJ .actor-man circle,#mermaid-svg-DA6vjKFTLnAMy5wJ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-DA6vjKFTLnAMy5wJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 窗口 resize / 首次 show 时触发 ⚠️ 边缘存在锯齿 ✅ 平滑边缘覆盖锯齿 resizeEvent → updateMask()buildPath(rect()) 构建圆角路径path.toFillPolygon()setMask(polygon) 设置像素级裁剪区域触发 paintEvent① fillPath(borderPath,② strokePath(borderPath,③ QTableView::paintEvent(event)委托基类绘制表格内容

投影绘制算法

#mermaid-svg-83YETP7JZUIk1OxB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-83YETP7JZUIk1OxB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-83YETP7JZUIk1OxB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-83YETP7JZUIk1OxB .error-icon{fill:#552222;}#mermaid-svg-83YETP7JZUIk1OxB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-83YETP7JZUIk1OxB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-83YETP7JZUIk1OxB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-83YETP7JZUIk1OxB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-83YETP7JZUIk1OxB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-83YETP7JZUIk1OxB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-83YETP7JZUIk1OxB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-83YETP7JZUIk1OxB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-83YETP7JZUIk1OxB .marker.cross{stroke:#333333;}#mermaid-svg-83YETP7JZUIk1OxB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-83YETP7JZUIk1OxB p{margin:0;}#mermaid-svg-83YETP7JZUIk1OxB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-83YETP7JZUIk1OxB .cluster-label text{fill:#333;}#mermaid-svg-83YETP7JZUIk1OxB .cluster-label span{color:#333;}#mermaid-svg-83YETP7JZUIk1OxB .cluster-label span p{background-color:transparent;}#mermaid-svg-83YETP7JZUIk1OxB .label text,#mermaid-svg-83YETP7JZUIk1OxB span{fill:#333;color:#333;}#mermaid-svg-83YETP7JZUIk1OxB .node rect,#mermaid-svg-83YETP7JZUIk1OxB .node circle,#mermaid-svg-83YETP7JZUIk1OxB .node ellipse,#mermaid-svg-83YETP7JZUIk1OxB .node polygon,#mermaid-svg-83YETP7JZUIk1OxB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-83YETP7JZUIk1OxB .rough-node .label text,#mermaid-svg-83YETP7JZUIk1OxB .node .label text,#mermaid-svg-83YETP7JZUIk1OxB .image-shape .label,#mermaid-svg-83YETP7JZUIk1OxB .icon-shape .label{text-anchor:middle;}#mermaid-svg-83YETP7JZUIk1OxB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-83YETP7JZUIk1OxB .rough-node .label,#mermaid-svg-83YETP7JZUIk1OxB .node .label,#mermaid-svg-83YETP7JZUIk1OxB .image-shape .label,#mermaid-svg-83YETP7JZUIk1OxB .icon-shape .label{text-align:center;}#mermaid-svg-83YETP7JZUIk1OxB .node.clickable{cursor:pointer;}#mermaid-svg-83YETP7JZUIk1OxB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-83YETP7JZUIk1OxB .arrowheadPath{fill:#333333;}#mermaid-svg-83YETP7JZUIk1OxB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-83YETP7JZUIk1OxB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-83YETP7JZUIk1OxB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-83YETP7JZUIk1OxB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-83YETP7JZUIk1OxB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-83YETP7JZUIk1OxB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-83YETP7JZUIk1OxB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-83YETP7JZUIk1OxB .cluster text{fill:#333;}#mermaid-svg-83YETP7JZUIk1OxB .cluster span{color:#333;}#mermaid-svg-83YETP7JZUIk1OxB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-83YETP7JZUIk1OxB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-83YETP7JZUIk1OxB rect.text{fill:none;stroke-width:0;}#mermaid-svg-83YETP7JZUIk1OxB .icon-shape,#mermaid-svg-83YETP7JZUIk1OxB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-83YETP7JZUIk1OxB .icon-shape p,#mermaid-svg-83YETP7JZUIk1OxB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-83YETP7JZUIk1OxB .icon-shape .label rect,#mermaid-svg-83YETP7JZUIk1OxB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-83YETP7JZUIk1OxB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-83YETP7JZUIk1OxB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-83YETP7JZUIk1OxB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 全部 16 层完成后
📥 输入参数

blur=20, dx=0, dy=6

color=rgba(0,0,0,50)
layers = min(blur, 16)

👉 共 16 层
🔁 对每一层 i = 0..15
t = i / 16
expand = t × 20px
alpha = 0.2 × (1-t)²
layerRect = 卡片矩形 ± expand
buildCardPath(layerRect, radius+expand)
painter.drawPath(path)
✨ 柔和投影效果

每层变化一览:

层 i t expand alpha 说明
0 0.00 0.0px 0.200 最内层,最深色
4 0.25 5.0px 0.113
8 0.50 10.0px 0.050
12 0.75 15.0px 0.013
15 0.94 18.8px 0.001 最外层,几乎透明

类关系总览

#mermaid-svg-20RvphA3OerSQurA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-20RvphA3OerSQurA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-20RvphA3OerSQurA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-20RvphA3OerSQurA .error-icon{fill:#552222;}#mermaid-svg-20RvphA3OerSQurA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-20RvphA3OerSQurA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-20RvphA3OerSQurA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-20RvphA3OerSQurA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-20RvphA3OerSQurA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-20RvphA3OerSQurA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-20RvphA3OerSQurA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-20RvphA3OerSQurA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-20RvphA3OerSQurA .marker.cross{stroke:#333333;}#mermaid-svg-20RvphA3OerSQurA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-20RvphA3OerSQurA p{margin:0;}#mermaid-svg-20RvphA3OerSQurA g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-20RvphA3OerSQurA g.classGroup text .title{font-weight:bolder;}#mermaid-svg-20RvphA3OerSQurA .cluster-label text{fill:#333;}#mermaid-svg-20RvphA3OerSQurA .cluster-label span{color:#333;}#mermaid-svg-20RvphA3OerSQurA .cluster-label span p{background-color:transparent;}#mermaid-svg-20RvphA3OerSQurA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-20RvphA3OerSQurA .cluster text{fill:#333;}#mermaid-svg-20RvphA3OerSQurA .cluster span{color:#333;}#mermaid-svg-20RvphA3OerSQurA .nodeLabel,#mermaid-svg-20RvphA3OerSQurA .edgeLabel{color:#131300;}#mermaid-svg-20RvphA3OerSQurA .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-20RvphA3OerSQurA .label text{fill:#131300;}#mermaid-svg-20RvphA3OerSQurA .labelBkg{background:#ECECFF;}#mermaid-svg-20RvphA3OerSQurA .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-20RvphA3OerSQurA .classTitle{font-weight:bolder;}#mermaid-svg-20RvphA3OerSQurA .node rect,#mermaid-svg-20RvphA3OerSQurA .node circle,#mermaid-svg-20RvphA3OerSQurA .node ellipse,#mermaid-svg-20RvphA3OerSQurA .node polygon,#mermaid-svg-20RvphA3OerSQurA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-20RvphA3OerSQurA .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA g.clickable{cursor:pointer;}#mermaid-svg-20RvphA3OerSQurA g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-20RvphA3OerSQurA g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-20RvphA3OerSQurA .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-20RvphA3OerSQurA .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-20RvphA3OerSQurA .dashed-line{stroke-dasharray:3;}#mermaid-svg-20RvphA3OerSQurA .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-20RvphA3OerSQurA #compositionStart,#mermaid-svg-20RvphA3OerSQurA .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #compositionEnd,#mermaid-svg-20RvphA3OerSQurA .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #dependencyStart,#mermaid-svg-20RvphA3OerSQurA .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #dependencyStart,#mermaid-svg-20RvphA3OerSQurA .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #extensionStart,#mermaid-svg-20RvphA3OerSQurA .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #extensionEnd,#mermaid-svg-20RvphA3OerSQurA .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #aggregationStart,#mermaid-svg-20RvphA3OerSQurA .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #aggregationEnd,#mermaid-svg-20RvphA3OerSQurA .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #lollipopStart,#mermaid-svg-20RvphA3OerSQurA .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA #lollipopEnd,#mermaid-svg-20RvphA3OerSQurA .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-20RvphA3OerSQurA .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-20RvphA3OerSQurA .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-20RvphA3OerSQurA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-20RvphA3OerSQurA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-20RvphA3OerSQurA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "m_central"
"card"
"m_tableView"
"m_model"
QWidget
ShadowWidget
-QWidget* m_central
-QColor m_shadowColor
-int m_blur
-int m_dx
-int m_dy
-int m_radius
-QColor m_cardColor
-QColor m_borderColor
-qreal m_borderWidth
-bool m_roundTL
-bool m_roundTR
-bool m_roundBR
-bool m_roundBL
+setCentralWidget(QWidget*)
+setShadowColor(QColor)
+setShadowBlur(int)
+setShadowOffset(int, int)
+setCornerRadius(int)
+setRoundedCorners(bool,bool,bool,bool)
+setCardColor(QColor)
+setBorderColor(QColor)
+setBorderWidth(qreal)
+shadowMargin() : int
-updateChildGeometry()
-drawShadow(QPainter&, QRectF)
-drawCard(QPainter&, QRectF)
-buildCardPath(QRectF, qreal) : QPainterPath
QTableView
RoundedTableView
-int m_radius
-bool m_roundTL
-bool m_roundTR
-bool m_roundBR
-bool m_roundBL
+setCornerRadius(int)
+cornerRadius() : int
+setRoundedCorners(bool,bool,bool,bool)
-updateMask()
-buildPath(QRectF) : QPainterPath
study
-Ui::studyClass ui
-RoundedTableView* m_tableView
-QStandardItemModel* m_model
-setupTableView()
-fillSampleData()
QStandardItemModel


一、ShadowWidget ------ 多层投影容器

1.1 设计思路

Qt 自带的 QGraphicsDropShadowEffect 存在两个痛点:

  1. 性能开销大 ------ 它在每帧都重新计算并渲染阴影,对包含子控件的复杂 widget 尤其明显。
  2. 裁剪问题 ------ 它作用于整个 widget 的矩形边界,无法与自定义形状(如圆角路径)精确配合。

因此我们手动实现了一个轻量级投影方案:在 paintEvent 中直接绘制多层半透明路径,从内向外逐层扩大并降低透明度。

1.2 核心实现

cpp 复制代码
void ShadowWidget::drawShadow(QPainter &painter, const QRectF &cardRect)
{
    if (m_blur <= 0 || m_shadowColor.alpha() == 0)
        return;

    painter.save();

    const int layers = qMin(m_blur, 16);          // 最多 16 层
    const qreal baseAlpha = m_shadowColor.alphaF();

    qreal sx = cardRect.x() + m_dx;               // 水平偏移
    qreal sy = cardRect.y() + m_dy;               // 垂直偏移
    qreal sw = cardRect.width();
    qreal sh = cardRect.height();

    for (int i = 0; i < layers; ++i) {
        qreal t = qreal(i) / layers;
        qreal expand = t * m_blur;                // 当前层向外扩展距离
        qreal alpha = baseAlpha * (1.0 - t) * (1.0 - t);  // 二次衰减

        QColor c = m_shadowColor;
        c.setAlphaF(qBound(0.0, alpha, 1.0));

        painter.setPen(Qt::NoPen);
        painter.setBrush(c);

        QRectF layerRect(sx - expand, sy - expand,
                         sw + 2.0 * expand, sh + 2.0 * expand);
        QPainterPath path = buildCardPath(layerRect, m_radius + expand);
        painter.drawPath(path);
    }

    painter.restore();
}

关键技术点:

  • 层数上限 16:平衡视觉效果和渲染性能,模糊半径再大也不超过 16 层。
  • 二次衰减 (1-t)²:让透明度从中心向外快速衰减,模拟真实阴影的柔和过渡。
  • 每层使用相同的圆角路径(radius 随扩展同步增大),保证阴影形状与卡片一致。

1.3 四角独立圆角

buildCardPath() 支持四个角各自独立设置是否圆角,这对于实现「上直下圆」或「左直右圆」等卡片设计非常有用:

cpp 复制代码
QPainterPath ShadowWidget::buildCardPath(const QRectF &r, qreal rad) const
{
    qreal x = r.x(), y = r.y(), w = r.width(), h = r.height();

    QPainterPath path;
    path.moveTo(x, y);   // 从左上角开始

    // 左上 ------ 可选圆角
    if (m_roundTL) {
        path.lineTo(x + rad, y);
        path.arcTo(x, y, 2 * rad, 2 * rad, 90, 90);
    }
    // 右上 ------ 可选圆角
    if (m_roundTR) {
        path.lineTo(x + w - rad, y);
        path.arcTo(x + w - 2 * rad, y, 2 * rad, 2 * rad, 90, -90);
    } else {
        path.lineTo(x + w, y);
    }
    // 右下、左下同理...
    path.closeSubpath();
    return path;
}

1.4 投影边距计算

由于阴影向外扩展,ShadowWidget 需要额外的边距来容纳阴影区域:

cpp 复制代码
int ShadowWidget::shadowMargin() const
{
    if (m_blur <= 0) return 0;
    return m_blur + qMax(qAbs(m_dx), qAbs(m_dy));
}

子控件 centralWidget 会被放置在这个边距之内,阴影则绘制在边距区域中。


二、RoundedTableView ------ 圆角表格

2.1 核心挑战

QTableView 变成圆角,直接的思路是给 viewport 加圆角样式,但这行不通:

  • QTableView 的 viewport 只是一个内部子控件,修改它不影响 header 和 scrollbar。
  • CSS border-radius 在 QTableView 上不生效(它属于 QAbstractScrollArea)。
  • QGraphicsDropShadowEffectQGraphicsEffect 裁剪会导致子控件绘制异常。

解决方案:双层渲染

复制代码
Layer 1 (系统层): setMask → 多边形裁剪整个 widget 树
Layer 2 (绘制层): paintEvent → 抗锯齿描边遮盖锯齿边缘

2.2 Layer 1: setMask 裁剪

cpp 复制代码
void RoundedTableView::updateMask()
{
    QPainterPath path = buildPath(QRectF(rect()));
    setMask(path.toFillPolygon().toPolygon());
}

setMask() 是 QWidget 提供的窗口系统级裁剪------它会将 widget 的绘制区域限制在指定多边形内。关键优势:它作用于整个 widget 树,包括 viewport、header、scrollbar 等所有子控件,无需逐个处理。

代价setMask 使用像素级位图裁剪,边缘会有明显的锯齿。

2.3 Layer 2: paintEvent 抗锯齿描边

这就是双层渲染的精妙之处------在 paintEvent 中用抗锯齿绘制同一条路径,用平滑的边缘「盖住」setMask 的锯齿:

cpp 复制代码
void RoundedTableView::paintEvent(QPaintEvent* event)
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 内缩 0.5px,让 1px 描边完全落在 widget 内部
    const qreal inset = 0.5;
    QRectF borderRect = QRectF(rect()).adjusted(inset, inset, -inset, -inset);
    QPainterPath borderPath = buildPath(borderRect);

    // ① 填充白色背景
    painter.setPen(Qt::NoPen);
    painter.setBrush(QColor("#ffffff"));
    painter.drawPath(borderPath);

    // ② 绘制 1px 抗锯齿边框 ------ 遮盖 setMask 锯齿
    painter.setBrush(Qt::NoBrush);
    painter.setPen(QPen(QColor("#d0d0d0"), 1));
    painter.drawPath(borderPath);

    // ③ 交给基类绘制表格内容
    QTableView::paintEvent(event);
}

为什么 0.5px inset? 如果不内缩,1px 宽的描边会有半像素落在 widget 外部,导致边框模糊。内缩 0.5px 后描边完全在 widget 内部,线条清晰锐利。

2.4 动态滚动条边距

当右上角或右下角是圆角时,滚动条如果紧贴边缘,会延伸到圆角区域,破坏视觉效果。解决方案:动态设置滚动条的 margin

cpp 复制代码
int halfR = tv->cornerRadius() / 2;
int topMar = tv->hasRoundTR() ? halfR : 0;
int botMar = tv->hasRoundBR() ? halfR : 0;

// 在 stylesheet 中:
// QScrollBar:vertical { margin: topMar px 2px botMar px 0px; }

这样滚动条的上端和下端会自动避开圆角区域,保持在卡片的直线部分内。


三、组装 ------ study 主窗口

cpp 复制代码
study::study(QWidget* parent) : QWidget(parent)
{
    ui.setupUi(this);
    this->resize(720, 500);
    this->setStyleSheet("study { background-color: #f0f2f5; }");

    // 1. 创建投影卡片
    auto* card = new ShadowWidget(this);
    card->setCornerRadius(8);
    card->setShadowBlur(20);
    card->setShadowOffset(0, 6);
    card->setShadowColor(QColor(0, 0, 0, 50));
    card->setBorderWidth(0);
    card->setRoundedCorners(false, true, true, false);

    // 2. 创建圆角表格
    m_tableView = new RoundedTableView();
    m_model = new QStandardItemModel(this);
    m_tableView->setCornerRadius(card->cornerRadius());
    m_tableView->setRoundedCorners(false, true, true, false);

    setupTableView();
    fillSampleData();

    // 3. 组装
    card->setCentralWidget(m_tableView);

    int pad = card->shadowMargin() + 30;
    auto* layout = new QVBoxLayout(this);
    layout->setContentsMargins(pad, pad, pad, pad);
    layout->addWidget(card);
}

布局边距 = shadowMargin() + 30,确保阴影区域不被父容器裁剪,同时保留 30px 的视觉留白。


四、表格样式

表格使用 QSS(Qt Style Sheets)进行精细化定制:

元素 样式
Header 浅灰背景 #fafafa,13px 加粗字体,底部分割线
普通行 白色背景,底部分割线 #f5f5f5
交替行 #fafbfc 浅灰蓝
选中行 #e6f7ff 浅蓝背景 + #1890ff 蓝色文字
悬停行 #f0f8ff 极浅蓝背景
滚动条 8px 宽,4px 圆角,#c8c8c8 灰色

五、关键技术总结

技术 用途 要点
QWidget::setMask() 裁剪整个 widget 树为圆角 作用于所有子控件,但有锯齿
QPainter::drawPath() + Antialiasing 抗锯齿描边覆盖锯齿 与 setMask 使用同一条路径
0.5px inset 保证 1px 描边清晰 让描边完全落在 widget 内部
16 层二次衰减 柔和投影 平衡效果与性能
动态 QSS margin 滚动条避让圆角 根据圆角配置动态计算

文件

shadow_widget.cpp

cpp 复制代码
#include "shadow_widget.h"
#include <QPainter>
#include <QPainterPath>
#include <QResizeEvent>
#include <QtMath>

// ============================================================
//  ShadowWidget
// ============================================================

ShadowWidget::ShadowWidget(QWidget *parent)
    : QWidget(parent)
{
    setAttribute(Qt::WA_StyledBackground, false);
}

void ShadowWidget::setShadowBlur(int blur)
{
    m_blur = qMax(0, blur);
    updateGeometry();
    updateChildGeometry();
    update();
}

int ShadowWidget::shadowMargin() const
{
    if (m_blur <= 0)
        return 0;
    return m_blur + qMax(qAbs(m_dx), qAbs(m_dy));
}

void ShadowWidget::setCornerRadius(int r)
{
    m_radius = r;
    update();
}

void ShadowWidget::setRoundedCorners(bool tl, bool tr, bool br, bool bl)
{
    m_roundTL = tl; m_roundTR = tr; m_roundBR = br; m_roundBL = bl;
    update();
}

void ShadowWidget::setCentralWidget(QWidget *w)
{
    if (m_central)
        m_central->deleteLater();

    m_central = w;
    w->setParent(this);
    w->show();
    updateChildGeometry();
}

// ============================================================
//  Layout
// ============================================================

QSize ShadowWidget::sizeHint() const
{
    int m = shadowMargin();
    QSize base = m_central ? m_central->sizeHint() : QSize(100, 50);
    return base + QSize(2 * m, 2 * m);
}

void ShadowWidget::resizeEvent(QResizeEvent *event)
{
    QWidget::resizeEvent(event);
    updateChildGeometry();
}

void ShadowWidget::updateChildGeometry()
{
    if (!m_central) return;
    int m = shadowMargin();
    m_central->setGeometry(m, m,
                           width()  - 2 * m,
                           height() - 2 * m);
}

// ============================================================
//  Per-corner path builder
// ============================================================

QPainterPath ShadowWidget::buildCardPath(const QRectF &r, qreal rad) const
{
    qreal x = r.x(), y = r.y(), w = r.width(), h = r.height();

    QPainterPath path;
    path.moveTo(x, y);

    if (m_roundTL) {
        path.lineTo(x + rad, y);
        path.arcTo(x, y, 2 * rad, 2 * rad, 90, 90);
    }
    if (m_roundTR) {
        path.lineTo(x + w - rad, y);
        path.arcTo(x + w - 2 * rad, y, 2 * rad, 2 * rad, 90, -90);
    } else {
        path.lineTo(x + w, y);
    }
    if (m_roundBR) {
        path.lineTo(x + w, y + h - rad);
        path.arcTo(x + w - 2 * rad, y + h - 2 * rad, 2 * rad, 2 * rad, 0, -90);
    } else {
        path.lineTo(x + w, y + h);
    }
    if (m_roundBL) {
        path.lineTo(x + rad, y + h);
        path.arcTo(x, y + h - 2 * rad, 2 * rad, 2 * rad, 180, -90);
    } else {
        path.lineTo(x, y + h);
    }

    path.closeSubpath();
    return path;
}

// ============================================================
//  Paint
// ============================================================

void ShadowWidget::paintEvent(QPaintEvent * /*event*/)
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    int m = shadowMargin();
    QRectF cardRect(m, m, width() - 2.0 * m, height() - 2.0 * m);

    drawShadow(painter, cardRect);
    drawCard(painter, cardRect);
}

void ShadowWidget::drawShadow(QPainter &painter, const QRectF &cardRect)
{
    if (m_blur <= 0 || m_shadowColor.alpha() == 0)
        return;

    painter.save();

    const int layers = qMin(m_blur, 16);
    const qreal baseAlpha = m_shadowColor.alphaF();

    qreal sx = cardRect.x() + m_dx;
    qreal sy = cardRect.y() + m_dy;
    qreal sw = cardRect.width();
    qreal sh = cardRect.height();

    for (int i = 0; i < layers; ++i) {
        qreal t = qreal(i) / layers;
        qreal expand = t * m_blur;
        qreal alpha = baseAlpha * (1.0 - t) * (1.0 - t);

        QColor c = m_shadowColor;
        c.setAlphaF(qBound(0.0, alpha, 1.0));

        painter.setPen(Qt::NoPen);
        painter.setBrush(c);

        QRectF layerRect(sx - expand, sy - expand,
                         sw + 2.0 * expand, sh + 2.0 * expand);
        QPainterPath path = buildCardPath(layerRect, m_radius + expand);
        painter.drawPath(path);
    }

    painter.restore();
}

void ShadowWidget::drawCard(QPainter &painter, const QRectF &cardRect)
{
    painter.save();

    QPainterPath path = buildCardPath(cardRect, m_radius);

    painter.setPen(Qt::NoPen);
    painter.setBrush(m_cardColor);
    painter.drawPath(path);

    if (m_borderWidth > 0 && m_borderColor.alpha() > 0) {
        painter.setBrush(Qt::NoBrush);
        QPen pen(m_borderColor, m_borderWidth);
        painter.setPen(pen);
        painter.drawPath(path);
    }

    painter.restore();
}

study.cpp

cpp 复制代码
#include "study.h"
#include <QHeaderView>
#include <QVBoxLayout>
#include <QScrollBar>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QResizeEvent>

// ============================================================
//  RoundedTableView
//
//  Two layers work together:
//    Layer 1 (window system): setMask → clips entire widget tree
//    Layer 2 (painter):       drawPath → antialiased border
//
//  The border covers the jagged edges from setMask.
//  Both use the SAME QPainterPath shape.
// ============================================================

RoundedTableView::RoundedTableView(QWidget* parent)
	: QTableView(parent)
{
	setFrameShape(QFrame::NoFrame);
	// Don't auto-fill --- we paint the background ourselves
	setAutoFillBackground(false);
}

void RoundedTableView::setCornerRadius(int r)
{
	m_radius = r;
	updateMask();
	update();
}

void RoundedTableView::setRoundedCorners(bool tl, bool tr, bool br, bool bl)
{
	m_roundTL = tl;
	m_roundTR = tr;
	m_roundBR = br;
	m_roundBL = bl;
	updateMask();
	update();
}

// ============================================================
//  Build the rounded path with per-corner control
// ============================================================
QPainterPath RoundedTableView::buildPath(const QRectF& r) const
{
	qreal x = r.x(), y = r.y(), w = r.width(), h = r.height();
	qreal rad = m_radius;

	QPainterPath path;
	path.moveTo(x, y);

	if (m_roundTL) {
		path.lineTo(x + rad, y);
		path.arcTo(x, y, 2 * rad, 2 * rad, 90, 90);
	}
	if (m_roundTR) {
		path.lineTo(x + w - rad, y);
		path.arcTo(x + w - 2 * rad, y, 2 * rad, 2 * rad, 90, -90);
	}
	else {
		path.lineTo(x + w, y);
	}
	if (m_roundBR) {
		path.lineTo(x + w, y + h - rad);
		path.arcTo(x + w - 2 * rad, y + h - 2 * rad, 2 * rad, 2 * rad, 0, -90);
	}
	else {
		path.lineTo(x + w, y + h);
	}
	if (m_roundBL) {
		path.lineTo(x + rad, y + h);
		path.arcTo(x, y + h - 2 * rad, 2 * rad, 2 * rad, 180, -90);
	}
	else {
		path.lineTo(x, y + h);
	}

	path.closeSubpath();
	return path;
}

// ============================================================
//  setMask on the FULL QTableView (including scrollbar + header)
// ============================================================
void RoundedTableView::updateMask()
{
	// Mask path uses the full widget rect (no inset) so it clips
	// at the exact widget boundary.
	QPainterPath path = buildPath(QRectF(rect()));
	setMask(path.toFillPolygon().toPolygon());
}

void RoundedTableView::resizeEvent(QResizeEvent* event)
{
	QTableView::resizeEvent(event);
	updateMask();
}

// ============================================================
//  paintEvent:
//    1. Fill background
//    2. Draw antialiased border along the SAME path
//       (this covers the jagged mask edges)
//    3. Let QTableView paint children (viewport, header, scrollbar)
// ============================================================
void RoundedTableView::paintEvent(QPaintEvent* event)
{
	QPainter painter(this);
	painter.setRenderHint(QPainter::Antialiasing);

	// ---- Build the border path ----
	// Inset by 0.5px so the 1px border sits fully inside the widget.
	// This makes the line crisp instead of blurry.
	const qreal inset = 0.5;
	QRectF borderRect = QRectF(rect()).adjusted(inset, inset, -inset, -inset);
	QPainterPath borderPath = buildPath(borderRect);

	// ---- ① Fill background ----
	painter.setPen(Qt::NoPen);
	painter.setBrush(QColor("#ffffff"));
	painter.drawPath(borderPath);

	// ---- ② Draw 1px antialiased border ----
	// This is the key: the antialiased stroke sits on top of
	// setMask's jagged edge and visually smooths it out.
	painter.setBrush(Qt::NoBrush);
	painter.setPen(QPen(QColor("#d0d0d0"), 1));
	painter.drawPath(borderPath);

	// ---- ③ Let base class paint the children ----
	// QTableView::paintEvent triggers item painting on the viewport.
	// Children (viewport, header, scrollbar) are constrained by
	// setMask, so they can't paint outside the rounded shape.
	QTableView::paintEvent(event);
}

// ============================================================
//  study
// ============================================================

study::study(QWidget* parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	this->resize(720, 500);
	this->setStyleSheet("study { background-color: #f0f2f5; }");

	// ---- ShadowWidget ----
	auto* card = new ShadowWidget(this);
	card->setCornerRadius(8);
	card->setShadowBlur(20);
	card->setShadowOffset(0, 6);
	card->setShadowColor(QColor(0, 0, 0, 50));
	card->setBorderWidth(0);  // border is drawn by RoundedTableView::paintEvent
	card->setRoundedCorners(
		false,  // top-left:    square
		true,   // top-right:   rounded 8px
		true,   // bottom-right: rounded 8px
		false   // bottom-left:  square
	);

	// ---- RoundedTableView (setMask + antialiased border) ----
	m_tableView = new RoundedTableView();
	m_model = new QStandardItemModel(this);

	// Set corner config BEFORE setupTableView so scrollbar margins
	// can be computed dynamically
	m_tableView->setCornerRadius(card->cornerRadius());
	m_tableView->setRoundedCorners(
		false, true, true, false
	);

	setupTableView();
	fillSampleData();

	card->setCentralWidget(m_tableView);

	int pad = card->shadowMargin() + 30;
	auto* layout = new QVBoxLayout(this);
	layout->setContentsMargins(pad, pad, pad, pad);
	layout->addWidget(card);
}

study::~study() {}

void study::setupTableView()
{
	auto* tv = m_tableView;

	tv->setModel(m_model);

	tv->verticalHeader()->setVisible(false);
	auto* hh = tv->horizontalHeader();
	hh->setSectionResizeMode(QHeaderView::Stretch);
	hh->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
	hh->setHighlightSections(false);

	tv->setSelectionBehavior(QAbstractItemView::SelectRows);
	tv->setSelectionMode(QAbstractItemView::SingleSelection);
	tv->setEditTriggers(QAbstractItemView::NoEditTriggers);

	tv->setShowGrid(false);
	tv->setAlternatingRowColors(true);

	tv->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
	tv->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);

	// Scrollbar margins: when a right-side corner is rounded,
	// leave a gap equal to half the corner radius so the
	// scrollbar stays within the straight part of the card.
	int halfR = tv->cornerRadius() / 2;
	int topMar = tv->hasRoundTR() ? halfR : 0;
	int botMar = tv->hasRoundBR() ? halfR : 0;

	tv->setStyleSheet(QString(R"(
        QTableView {
            background: transparent;
            padding: 0px;
        }
        QHeaderView::section {
            background: #fafafa;
            color: #333;
            font-size: 13px;
            font-weight: bold;
            padding: 10px 12px;
            border: none;
            border-bottom: 1px solid #e8e8e8;
            border-right: 1px solid #f0f0f0;
        }
        QHeaderView::section:last {
            border-right: none;
        }
        QTableView::item {
            background: white;
            color: #333;
            font-size: 13px;
            padding: 8px 12px;
            border-bottom: 1px solid #f5f5f5;
        }
        QTableView::item:alternate {
            background: #fafbfc;
        }
        QTableView::item:selected {
            background: #e6f7ff;
            color: #1890ff;
        }
        QTableView::item:hover {
            background: #f0f8ff;
        }
        QScrollBar:vertical {
            width: 8px;
            background: transparent;
            margin: %1px 2px %2px 0px;
        }
        QScrollBar::handle:vertical {
            background: #c8c8c8;
            border-radius: 4px;
            min-height: 30px;
        }
        QScrollBar::handle:vertical:hover {
            background: #a0a0a0;
        }
        QScrollBar::add-line:vertical,
        QScrollBar::sub-line:vertical {
            height: 0px;
            border: none;
        }
        QScrollBar::add-page:vertical,
        QScrollBar::sub-page:vertical {
            background: none;
        }
    )").arg(topMar).arg(botMar));
}

void study::fillSampleData()
{
	QStringList headers = { "Name", "Department", "Title", "City", "Status" };
	m_model->setHorizontalHeaderLabels(headers);

	struct R { QString name, dept, title, city, status; };
	QList<R> data = {
		{ "Zhang San", "R&D",      "Senior Engineer", "Beijing",  "Active" },
		{ "Li Si",     "Product",  "Product Manager", "Shanghai", "Active" },
		{ "Wang Wu",   "Design",   "UI Designer",     "Shenzhen", "Active" },
		{ "Zhao Liu",  "R&D",      "Junior Engineer", "Hangzhou", "Probation" },
		{ "Sun Qi",    "Marketing","Marketing Dir.",   "Guangzhou","Active" },
		{ "Zhou Ba",   "Ops",      "Ops Engineer",    "Chengdu",  "Active" },
		{ "Wu Jiu",    "Finance",  "Accountant",      "Nanjing",  "Active" },
		{ "Zheng Shi", "R&D",      "Tech Lead",       "Wuhan",    "Resigned" },
		{ "Chen Yiyi", "HR",       "HR Manager",      "Xian",     "Active" },
		{ "Lin Erer",  "Legal",    "Legal Counsel",   "Tianjin",  "Active" },
		{ "Huang San", "R&D",      "Architect",       "Xiamen",   "Active" },
		{ "Liu Sisi",  "Product",  "Data Analyst",    "Changsha", "Probation" },
	};

	for (const auto& d : data) {
		QList<QStandardItem*> items;
		items << new QStandardItem(d.name)
			<< new QStandardItem(d.dept)
			<< new QStandardItem(d.title)
			<< new QStandardItem(d.city)
			<< new QStandardItem(d.status);

		items[4]->setTextAlignment(Qt::AlignCenter);

		if (d.status == "Resigned")
			items[4]->setForeground(QColor("#ff4d4f"));
		else if (d.status == "Probation")
			items[4]->setForeground(QColor("#fa8c16"));

		m_model->appendRow(items);
	}
}
相关推荐
天天进步20151 小时前
UI-TARS 源码解析 #7:Thought + Action 输出格式:GUI Agent 的最小决策闭环
ui
Scott9999HH3 小时前
2026 避坑实录:国产品牌压力变送器什么牌子好?从硬件抗扰到 C++ Qt 实时曲线绘制源码剖析
开发语言·c++·qt
皓悦编程记21 小时前
【YOLO26 系列】基于YOLO26的垃圾分类检测系统【python源码+Pyqt5界面/WEB+数据集+训练代码】
python·qt·分类
曹牧1 天前
C#:关闭UI,清理线程
开发语言·ui·c#
十五年专注C++开发1 天前
qobject_cast转换失败原因分析
c++·qt·dynamic_cast·qobject_cast
春卷同学1 天前
HarmonyOS掌上记账APP开发实践第29篇:条件渲染与 ForEach — 动态 UI 的构建策略
ui·华为·harmonyos
sycmancia1 天前
Qt——线程的生命期问题
开发语言·qt
小小测试开发1 天前
Midscene.js:14K Stars 视驱 UI 自动化框架,用截图替代 DOM 选择器写测试
javascript·ui·自动化
不吃辣4901 天前
从0-1学习导航页面布局优化和搭建
ui·平面·前端框架