PyQt6 風格的 Material Design 3 — 參考應用程式規格
以下為完整規格,可根據行為逐字節重現 assets/app_template.py。
將代理人模擬為來自工作室的介面設計師,該工作室製作 MD3 桌面應用程式:角色調色盤 (primary / on-primary / containers / surface / outline)、狀態圖層、波紋效果、按形狀比例圓角、動畫僅用於解釋發生的事件 — 僅此而已。
主要規則: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 (所有需要的 Widget)。 - 設計標記:
Theme(dataclass),LIGHT/DARK, 半徑/持續時間常數,REPLIES,SVG_EYE/SVG_EYE_SLASH,PAGES。 - 輔助函式:
system_font,rgba,accent_fg,shadow_color,lerp_color,animate_property,draw_checkmark。 - 自訂 Widget 按依賴順序:
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。不得硬編碼「white」/「black」。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;晶片/分段 — full。 EDGE_MARGIN = 12(與根元件的陰影間距一致),邊緣位遮罩EDGE_TOP, EDGE_BOTTOM, EDGE_LEFT, EDGE_RIGHT = 1, 2, 4, 8。DUR_FAST = 140(hover),DUR_PRESS = 90,DUR_RELEASE = 200,DUR_MED = 250(頁面, drawer, 切換開關, 分段),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,body 14,次要文字 12,時間 11。
間距來自尺度 4/8/12/16/24/32。
陰影:視窗/面板 — blurRadius 24, offset (0,4);卡片 (elevation 1) —
blurRadius 8, offset (0,1);顏色 shadow_color(theme)。
狀態圖層 (覆蓋在容器上,顏色為「on-角色」):hover 0.08, press 0.12 (即
0.08*hover + 0.04*press);滑桿 hover 0.10 / press 0.16;核取方塊 0.06 (unchecked)
/ 0.10 (checked)。
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. 自訂 Widget (精確參數)
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)。State layer 顏色為 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));state layer 圓形 40px 圍繞手柄。
MdSlider
QWidget高度 28,valueChanged(int)。軌道 4px radius 2:非活躍surface_container_highest,活躍primary。手柄 — 圓形 20pxprimary+ 輕微 陰影 (黑色 alpha 30, offset y+1)。State layer 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。- Unchecked:邊框 2px
on_surface_variant。Checked:填滿primary+ 勾選標記on_primary(pen 2px RoundCap/RoundJoin),勾選標記「逐步繪製」 (check0..1,兩條線段透過drawPath)。 - State layer 36px:
on_surface0.06 (unchecked) /primary0.10 (checked) * hover。 boxScale(OutBack) 在 press 時壓縮方框;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 中繪製,使用整數座標)。 - 指示器 —
QFrame#SegIndicator(bgsecondary_container, radius 19) 位於按鈕後方,QPropertyAnimation(geometry)DUR_MED;在showEvent中 —lower()+ 幾何無動畫。 - 選取的段:文字
on_secondary_container+ 文字左側的勾選標記 (draw_checkmark);未選取:on_surface。文字顏色動畫 (colorQColor property)。段的 State layer — 藥丸形狀,顏色為 on-角色。
DropGlyph / FileDropArea
- Glyph 38×38:向下箭頭 + 托盤,顏色
lerp(on_surface_variant, primary, level), haloprimaryalpha 0.10*level。 FileDropArea(QFrame):minHeight 150,setAcceptDrops(True)。背景surface_container_lowradius 16;虛線 2pxoutline_variant(dashPattern [4,3.5]); 在 hover/drag 時 — 虛線primary+ overlayprimary_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,整數座標:最小化 — 水平線; 最大化 — 正方形 12×12 (outline),在最大化狀態時 — 兩個重疊的正方形; 關閉 — X 形。 - 字形顏色
lerp(on_surface_variant, on_surface, hover);關閉按鈕在 hover 時 — 背景error(圓形 40px) + 字形on_error。其他按鈕在 hover 時 — state layeron_surface0.08。 - 從右到左的順序:最小化, 最大化, 關閉。
SidebarToggle
QPushButton40×40, state layeron_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,可勾選;圖示 20×20 at x=16 使用QPainter繪製 (Material Symbols outlined):dashboard (4 個正方形), tasks (圓形+勾選), edit (鉛筆), chat (對話框), folder, tune (3 條線 + 旋鈕)。文字透過 QSSpadding-left: 48px。nextCheckState設定 checked = True;取消勾選僅能透過父容器 (NavigationDrawer) 進行。
NavigationDrawer(QWidget) 220px (SIDEBAR_WIDTH):頂部內邊距 12px,標題「功能表」
16/Medium on_surface_variant,spacing 4。按下 NavButton → 在 NavButton 上設定 checked,
發送 page_changed(int),在 MainWindow 中切換 QStackedWidget。
MdButton 和 NavButton 的 paintEvent 實作會在背景上繪製 state layer
(使用 QPainterPath 裁剪) 和/或 ripple (如果正在動畫中),然後
再呼叫 super().paintEvent() 繪製文字/圖示。所有自訂小工具都會重新實作 paintEvent
(以及某些情況下的 mousePressEvent/mouseReleaseEvent/enterEvent/leaveEvent);
如果小工具有 Q_PROPERTY,則也會實作 sizeHint/minimumSizeHint。
6. 切換主題、頁面和字型縮放
MainWindow.set_light_theme()/set_dark_theme():更新主題,重新建置樣式表, 更新_apply_palette,為所有一次性的調整發出theme_changed。透過Qt.FindChildren找到的圖示在主題變更時重新繪製 (update());動畫QPropertyAnimation(themePos, 0→1)在lerp_color中用於背景和所有有Q_PROPERTY顏色的 QSS 屬性。MainWindow.actionLightTheme.emit()/actionDarkTheme.emit()→ 觸發。- 事件過濾器重新繪製所有 children (
child.update()),導致 ~16ms 的閃爍。 - 頁面切換:
MainWindow中的page_changed(int)→QPropertyAnimation在QStackedWidget的currentIndex上 (DUR_MED/EASE)。堆疊中的每個頁面都是QWidget,帶有自訂內容。 - 字型縮放:
QWidget.setFont(…),其中… = system_font(size, weight)。 對所有元素使用eventFilter惰性調整過於複雜;僅參考MainWindow.theme和MainWindow.fontSize。
7. 頁面內容 (精確複製行為)
概覽 (Dashboard)
- 1 個排成兩列的卡片。
RADIUS_CARD、陰影、邊框outline_variant。 - 每個卡片包含:圖示 (20px primary_container / on_primary_container, 藥丸半徑 10) +
標題
on_surface16 Medium + 副標題on_surface_variant14。第三張卡片橫跨整行, 包含「活動」的QListWidget+scrollbar。
任務 (Tasks)
QListWidget包含 5 個可勾選項目 (核取方塊) +MdButton「新增任務」 (tonal, 文字on_secondary_container)。每個項目:QWidget+MdCheckBox。
輸入 (Input)
QLineEdit(radius 4, bgsurface_container_highest, 邊框outline)MdButton「提交」+ 標籤顯示顯示的文字。準確複製。
聊天 (Chat)
- 3 個聊天下一步按鈕;
QScrollArea+QVBoxLayout包含氣泡。 用戶氣泡:bgprimary_container, radius 12,on_primary_container, 右對齊。機器人氣泡:bgsurface_container, radius 12,on_surface, 左對齊。底部輸入區域:QLineEdit+MdButton「傳送」。
檔案 (Files)
FileDropArea,其下是一個帶有QListWidget的卡片。 選擇的檔案路徑以列表形式顯示。
設定 (Settings)
- 3 個帶有
MdSwitch、MdSlider、MdSegmentedControl的區塊。 每個區塊:圖示 + 標籤 + Widget。準確複製。
8. 樣式表 (QSS) 關鍵點
build_stylesheet(theme) 回傳一個大型 f-string,用於 QFrame#Panel 及其內部。
關鍵選擇器:
QMainWindow,QWidget:background: transparent;QFrame#Panel:background: {theme.surface}; border-radius: {RADIUS_WINDOW}px;QPushButton#NavButton:text-align: left; padding-left: 48px; border: none;文字顏色on_surface,選取時on_secondary_container。QPushButton通用:border: none;以防止 Qt 預設邊框。QLineEdit:radius 4, padding 12px, 邊框 1pxoutline, bgsurface。QScrollBar:寬度 6, radius 3, bg transparent, handleoutline_variant。QListWidget:border none, bg transparent, item 內邊距 8px。QFrame#Panel上的#shadow效果:QGraphicsDropShadowEffect在 Python 中, 由 QSS 的background所設定。
9. 硬編碼的陰影和重疊層
QGraphicsDropShadowEffect套用於QFrame#Panel(blurRadius 24, offset (0,4), colorshadow_color(theme))。- 效果透過
panel.setGraphicsEffect(effect)設定,並在主題變更時更新。 - 動畫期間,效果被隱藏 (
effect.setEnabled(False)) 以避免效能問題。 - 重疊層:
QWidget#Overlay(bgon_surfacealpha 0.32) 在 drawer 開啟時顯示。 點擊時關閉 drawer。
10. 網路和額外依賴
QSvgRenderer:如果存在相關的 SVG 檔案,則用於圖示,但NavButton使用QPainter.drawPath自訂繪製。預設情況下,所有圖示都是自訂繪製的。pathlib:用於app_template.py中的檔案路徑。所有資源都相對於腳本目錄。dataclasses:用於Theme。
11. 注意事項 (常見陷阱)
QMainWindow沒有背景:它必須是透明的;陰影位於其子QFrame#Panel上。 對QMainWindow設定背景會破壞陰影。Fusion樣式是強制的:其他樣式 (Windows, macOS) 會忽略邊框半徑和背景。- 所有座標都是整數:
QPainter操作使用int座標以避免子像素偽影。 - 切換開關/核取方塊的
sizeHint:必須回傳實際尺寸 (52, 24) 以便佈局正確計算。 5.**Q_PROPERTY名稱必須完全匹配**:hover,press,ripple,knobPos,boxScale,themePos。 - 動畫後清理:動畫物件必須保留為屬性 (例如
self._ripple_anim),否則會被垃圾回收。 QEventFilter中的MouseMove:必須檢查event->buttons() == Qt.NoButton以僅在未按下按鈕時更新游標。- 主題變更:觸發
actionLightTheme.emit()或actionDarkTheme.emit()以重新建置 樣式表。
12. 參考資源
assets/app_template.py— 精確的 1:1 實作。Material Design 3文件:角色、形狀、狀態。PyQt6文件:QPropertyAnimation,QPainter,QSS。
規格結束。所有值 (顏色、半徑、持續時間) 必須與此文件完全相符。