PyQt6 风格 Material Design 3 — 参考应用规格说明
以下是完整规格,可通过行为逐字节复现 assets/app_template.py。模拟一个来自工作室的界面设计代理,该工作室制作 MD3 桌面应用:角色调色板(主色 / on-primary / 容器 / 表面 / 轮廓)、状态层、涟漪、按形状比例圆角、仅在需要解释发生了什么时才有的动画——仅此而已。
主要规则:Qt 中的“MD3 风格”并非原生 API,而是手绘再造的美学:无边框窗口 + 自定义 QSS + QPropertyAnimation 实现流畅度。这样应用在 Windows、Linux 和 macOS 上看起来一致。Qt 原生样式不提供圆角、半透明、状态层和动画。
参考实现——assets/app_template.py(一个文件)。代理的任务:要么复制并调整它,要么根据本文档从头重建。
1. 快速开始
pip install PyQt6
python app_template.py
为用户构建:pyinstaller --windowed --icon=app.ico app_template.py。
应用启动方式:
def main() -> None:
app = QApplication(sys.argv)
app.setStyle("Fusion") # 唯一尊重所有地方 QSS 的样式
app.setFont(system_font(14, QFont.Weight.Normal))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
2. 文件 app_template.py 结构(为了可读性,顺序重要)
- 导入:
math,sys,time,dataclasses,pathlib.Path; QtCore (Qt,QEvent,QPropertyAnimation,QEasingCurve,QRect(F),QPointF,QParallelAnimationGroup,QVariantAnimation,QTimer,pyqtProperty,pyqtSignal); QtGui (QColor,QFont,QPainter,QPainterPath,QFontDatabase,QFontMetricsF,QPen,QPixmap,QIcon,QPalette); QtSvg (QSvgRenderer); QtWidgets (所有需要的控件)。 - 设计令牌:
Theme(dataclass),LIGHT/DARK, 半径/持续时间常量,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES。 - 辅助函数:
system_font,rgba,accent_fg,shadow_color,lerp_color,animate_property,draw_checkmark。 - 自定义控件按依赖顺序:
MdButton,MdSwitch,MdSlider,MdCheckBox,MdSegmentButton/MdSegmentedControl,DropGlyph/FileDropArea,WindowButton,SidebarToggle,TopAppBar,NavButton/NavigationDrawer。 build_stylesheet(theme)。MainWindow+main()。
3. 设计令牌(精确值,MD3 角色)
Theme — frozen dataclass,所有颜色为 hex 字符串,MD3 角色:primary, on_primary, primary_container, on_primary_container, secondary_container, on_secondary_container, surface, surface_container_low, surface_container, surface_container_high, surface_container_highest, on_surface, on_surface_variant, outline, outline_variant, error, on_error, error_container, on_error_container, shadow_alpha (int 0..255,阴影始终为黑色)。
LIGHT = Theme(primary="#6750A4", on_primary="#FFFFFF",
primary_container="#EADDFF", on_primary_container="#21005D",
secondary_container="#E8DEF8", on_secondary_container="#1D192B",
surface="#FEF7FF", surface_container_low="#F7F2FA",
surface_container="#F3EDF7", surface_container_high="#ECE6F0",
surface_container_highest="#E6E0E9", on_surface="#1D1B20",
on_surface_variant="#49454F", outline="#79747E", outline_variant="#CAC4D0",
error="#B3261E", on_error="#FFFFFF",
error_container="#F9DEDC", on_error_container="#410E0B", shadow_alpha=45)
DARK = Theme(primary="#D0BCFF", on_primary="#381E72",
primary_container="#4F378B", on_primary_container="#EADDFF",
secondary_container="#4A4458", on_secondary_container="#E8DEF8",
surface="#141218", surface_container_low="#1D1B20",
surface_container="#211F26", surface_container_high="#2B2930",
surface_container_highest="#36343B", on_surface="#E6E0E9",
on_surface_variant="#CAC4D0", outline="#938F99", outline_variant="#49454F",
error="#F2B8B5", on_error="#601410",
error_container="#8C1D18", on_error_container="#F9DEDC", shadow_alpha=120)
“角色 / on-角色”配对是强制性的:primary 上的文本始终为 on_primary,secondary_container 上的为 on_secondary_container,primary_container 上的为 on_primary_container。不能硬编码“白色”/“黑色”。accent_fg(theme) 作为兼容辅助函数保留,返回 theme.on_primary。
def rgba(hex_color: str, alpha_0_255: int) -> str: # QSS 需要 alpha 0..255,而不是 0..1
c = QColor(hex_color)
return f"rgba({c.red()}, {c.green()}, {c.blue()}, {alpha_0_255})"
def shadow_color(theme: Theme) -> QColor:
c = QColor(0, 0, 0); c.setAlpha(theme.shadow_alpha); return c
常量:
- 形状:
RADIUS_WINDOW = 16,RADIUS_CARD = 12,RADIUS_MENU = 8,RADIUS_FIELD = 4,SIDEBAR_WIDTH = 220。按钮/药丸——radius = height/2;芯片/分段——完整。 EDGE_MARGIN = 12(与根边距下的阴影一致),边缘位掩码EDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8。DUR_FAST = 140(悬停),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(页面,抽屉,开关,分段),DUR_RIPPLE = 450;EASE = OutCubic,EASE_PRESS = OutQuad。PAGES = ["概览", "任务", "输入", "聊天", "文件", "设置"]。REPLIES— 5 条简短机器人回复。SVG_EYE/SVG_EYE_SLASH— Material Symbolsvisibility/visibility_off(viewBox 0 0 24 24,fill="currentColor")。
排版:system_font(point_size=14, weight) — 链式 Roboto → Google Sans → Noto Sans → Segoe UI,setStyleStrategy(PreferAntialias)。尺寸:页面标题 24,卡片标题 16/Medium,按钮文本 14/Medium,正文 14,次要 12,时间 11。边距来自 4/8/12/16/24/32 缩放范围。
阴影:窗口/面板 — blurRadius 24,offset (0,4);卡片(高程 1)— blurRadius 8,offset (0,1);颜色 shadow_color(theme)。
状态层(在容器上方,使用“on-角色”颜色):悬停 0.08,按下 0.12(即 0.08*hover + 0.04*press);滑块悬停 0.10 / 按下 0.16;复选框 0.06(未选中)/ 0.10(选中)。
4. 窗口框架
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setMinimumSize(760, 480)
self.resize(960, 600)
self.setMouseTracking(True)
QApplication.instance().installEventFilter(self) # 用于边缘调整大小
树(_build_ui):QMainWindow → root (QWidget, margins 12) → panel (QFrame#Panel, drop-shadow) → TopAppBar (56px) + body (QHBoxLayout) → NavigationDrawer (220px) + separator (1px, outline_variant) + QStackedWidget。
面板接收 build_stylesheet(theme) — QSS 向下继承。此外,_apply_palette(theme) 设置 QPalette 角色(Window/Base=surface, Text=on_surface, Highlight=primary, HighlightedText=on_primary, PlaceholderText=on_surface_variant, ToolTipBase=surface_container_high…)用于原生绘制的部分。
阴影和圆角位于子 QFrame#Panel 上,而不是窗口本身:窗口透明并提供 12px 边距用于阴影。不能将效果放在 QMainWindow 上。
拖动和调整大小
移动窗口 — Qt6 原生机制(在 TopAppBar.mousePressEvent 中):
handle = self._window.windowHandle()
if handle is not None:
handle.startSystemMove()
双击 TopAppBar — 最大化/正常。
通过边缘改变大小 — 原生 QWindow.startSystemResize。MainWindow 中安装了事件过滤器到 QApplication:在 MouseButtonPress(Left) 且位于 ≤ EDGE_MARGIN 区域时调用 _start_system_resize(edges) 并返回 True;在 MouseMove 无按钮时更新光标;在 Leave 时重置光标。
_resize_edges_for(pos) → 边缘位掩码(仅当窗口可见且未最大化时)。_cursor_for_edges:中心 → Arrow;左/右 → SizeHor;上/下 → SizeVer;上+左 或 下+右 → SizeFDiag;否则 → SizeBDiag。
PyQt6 陷阱: Qt.Edge 不能转换为 int,也不能用 | 与 int 混合。边缘数学使用自己的位常量 EDGE_*,标志构建如下:
flag = Qt.Edge(0)
if edges & EDGE_TOP: flag |= Qt.Edge.TopEdge
if edges & EDGE_BOTTOM: flag |= Qt.Edge.BottomEdge
if edges & EDGE_LEFT: flag |= Qt.Edge.LeftEdge
if edges & EDGE_RIGHT: flag |= Qt.Edge.RightEdge
handle.startSystemResize(flag)
5. 自定义控件(精确参数)
MdButton (filled / tonal, state layer + ripple)
QPushButton,高度 40,药丸半径 20,字体 14/Medium,padding 0 24px,minWidth = text + 48。kind="filled"→ bgprimary/fgon_primary;kind="tonal"→ bgsecondary_container/fgon_secondary_container。- QSS:
background: transparent; color: {fg}— 背景手动绘制,文本通过super().paintEvent()。 Q_PROPERTY hover/press/ripple(float)。状态层颜色为 fg:alphaF = 0.08*hover + 0.04*press,剪辑为圆角矩形。- Ripple:
mousePressEvent记录点(_ripple_origin),pressed启动_ripple_anim0→1 (DUR_RIPPLE);在 paint 中 — 从该点开始的椭圆,半径为max_r*ripple(max_r = 到最远角的距离 + 4),alphaF = 0.18*(1-ripple),剪辑为药丸。动画引用保存在属性中。
MdSwitch (MD3 开关)
QWidget52×32,信号toggled(bool)仅通过点击触发;setChecked静默动画。Q_PROPERTY knobPos(0..1)DUR_MED/EASE,加上 hover/press。- Off:轨道
surface_container_highest+ 轮廓 2pxoutline;旋钮 16px 颜色outline,中心 x=16。On:轨道primary无轮廓;旋钮 24pxon_primary,中心 x=36,内部勾号颜色primary(draw_checkmark, pen 2px)。Press:旋钮 28px。 - 轨道/旋钮通过
lerp_color按 knobPos 插值;轮廓在 t→1 时消失(alpha = 255*(1-t));旋钮周围状态层圆 40px。
MdSlider
QWidget高度 28,valueChanged(int)。轨道 4px radius 2:非活动surface_container_highest,活动primary。旋钮 — 圆 20pxprimary+ 轻微阴影(黑色 alpha 30,offset y+1)。状态层 40pxprimaryalphaF = 0.10*hover + 0.06*press。- 来自 x 的值:
pad = 10(旋钮半径),ratio = (x-pad)/(width-2*pad);拖动时 — 无惯性,否则fill动画DUR_FAST。
MdCheckBox
QWidget高度 24,字体 14,minWidth = 34 + text。盒子 18×18 radius 2 at x=3。- 未选中:轮廓 2px
on_surface_variant。选中:填充primary+ 勾号on_primary(pen 2px RoundCap/RoundJoin),勾号逐步“绘制”(check0..1,两条分段线通过drawPath)。 - 状态层 36px:
on_surface0.06 (unchecked) /primary0.10 (checked) * hover。 boxScale(OutBack) 在按下时压缩盒子;setChecked(checked, animate)发射toggled;在mouseReleaseEvent中点击仅当光标在矩形内时触发。
MdSegmentedControl / MdSegmentButton
- 容器高度 40,药丸半径 20,轮廓 1px
outline(在 paintEvent 中绘制)。 - 分段等宽(
addWidget(btn, 1)),高度 38,字体 14/Medium;margins (1,1,1,1),spacing 0。分隔线 1pxoutline位于未选中的相邻分段之间(在 paintEvent 中绘制,int 坐标)。 - 指示器 —
QFrame#SegIndicator(bgsecondary_container, radius 19) 位于按钮后方,QPropertyAnimation(geometry)DUR_MED;在showEvent中 —lower()+ 无动画的几何体。 - 选中的分段:文本
on_secondary_container+ 文本左侧的勾号 (draw_checkmark);未选中的:on_surface。文本颜色动画 (colorQColor property)。分段状态层 — 使用 on-角色颜色的药丸。
DropGlyph / FileDropArea
- Glyph 38×38:向下箭头 + 托盘,颜色
lerp(on_surface_variant, primary, level),光晕primaryalpha 0.10*level。 FileDropArea(QFrame):minHeight 150,setAcceptDrops(True)。背景surface_container_lowradius 16;虚线 2pxoutline_variant(dashPattern [4,3.5]);悬停/拖拽时 — 虚线primary+ 覆盖层primary_containeralpha ≈ 60glow+20press。dragEnterEvent(hasUrls)/dragLeaveEvent/dropEvent(仅isLocalFile()) →dropped(list[str]);点击 →QFileDialog.getOpenFileNames。所有信号逻辑不变。
WindowButton (窗口按钮)
QPushButton40×40,radius 20,Q_PROPERTY hover/press,set_maximized(bool)。- 字形
QPen1.8 RoundCap/RoundJoin,int 坐标:最小化 — 水平线;最大化 — 正方形 12×12 (outline),展开时 — 两个重叠正方形;关闭 — 叉号 X。 - 字形颜色
lerp(on_surface_variant, on_surface, hover);关闭时悬停 — 背景error(圆 40px) + 字形on_error。其他悬停时 — 状态层on_surface0.08。 - 右侧顺序:最小化,最大化,关闭。
SidebarToggle
QPushButton40×40,状态层on_surface0.08,字形lerp(on_surface_variant, on_surface, hover)。- 字形:边框 16×16 radius 2 + 分隔线;面板打开 — 分隔线在左侧 + 尖括号
<(panel-left-close);折叠 — 分隔线在右侧 (panel-left-open)。 set_collapsed(bool);同步MainWindow.toggle_sidebar。
TopAppBar
QWidget#TopAppBar高度 56,在 Panel 上方透明;margins (8,0,8,0),spacing 4。- 左侧
SidebarToggle,然后标题“我的项目” 18/Mediumon_surface,stretch,右侧WindowButton最小化/最大化/关闭。_max_btn+_sync_max_state通过 eventFilter 在WindowStateChange上。startSystemMove+ 双击最大化。
NavButton / NavigationDrawer
NavButton(QPushButton#NavButton)高度 48,字体 14,checkable;图标 20×20 at x=16 由QPainter绘制(Material Symbols outlined):dashboard (4 squares),tasks (circle+check),edit (pencil),chat (bubble),folder,tune (3 lines + knobs)。文本通过 QSSpadding-left: 48px。nextCheckState