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 存在两个痛点:
- 性能开销大 ------ 它在每帧都重新计算并渲染阴影,对包含子控件的复杂 widget 尤其明显。
- 裁剪问题 ------ 它作用于整个 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);
}
}