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)。
  • 用 QGraphicsDropShadowEffect 或 QGraphicsEffect 裁剪会导致子控件绘制异常。

解决方案:双层渲染

复制代码
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);
	}
}
相关推荐
传奇开心果编程11 小时前
【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程16 小时前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程16 小时前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer
扶尔魔ocy17 小时前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy19 小时前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
尘客-追梦19 小时前
qmake / jom / 影子构建:把编译管起来
开发语言·c++·qt
qeen8720 小时前
【QT】 信号与槽初始
开发语言·笔记·qt·学习
传奇开心果编程1 天前
【SwiftUI提高练中学】第8课 网络层架构:拦截器、重试、缓存与离线优先
学习·macos·ui·ios·swiftui
传奇开心果编程1 天前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
大圣编蚕1 天前
Rancher证书轮换过期导致不能访问UI问题处理
ui·rancher