前言
侧边栏(Sidebar)是现代桌面应用里最常见的布局形态之一:左边一条窄的导航/工具栏,右边一大块主内容区,中间还可以拖动分隔条调整宽度。VSCode、Typora、Qt Creator 自不必说,连很多工具类小软件也都会做成这个样子。
在 Qt 里实现侧边栏其实有至少四五种路子:QHBoxLayout 手搭、QSplitter 拖拽、QDockWidget 停靠、QToolBox 折叠、QPropertyAnimation 做抽屉动画。每种都有它的适用场景和坑。
本文会从最基础的布局原理讲起,逐步深入到"可折叠 + 带动画"的侧边栏,并且重点说清那个让无数新手抓狂的问题:为什么我设置了布局,界面却纹丝不动?
一、先搞清楚:Qt 布局到底是怎么工作的
要理解侧边栏,必须先理解 Qt 的布局系统。它的核心是三个概念:
- 父子关系(parent-child):一个 widget 的位置和尺寸由它的父 widget 决定;
- 布局管理器(layout):按照一定规则,把子 widget 排列在父 widget 的客户区里;
- 尺寸策略(size policy):告诉布局"我这个 widget 希望占多大、能不能被拉伸"。
侧边栏的本质,就是一个"宽度受限、但高度撑满"的子 widget。所以它需要:
cpp
sidebar->setFixedWidth(200); // 或者 setMinimumWidth / setMaximumWidth
然后把它和一个主内容区一起塞进水平布局:
cpp
auto *layout = new QHBoxLayout;
layout->addWidget(sidebar);
layout->addWidget(content, 1); // 拉伸因子 1,让内容区吃掉剩余空间
这里 addWidget(content, 1) 的第二个参数是拉伸因子(stretch)。这是关键:侧边栏没有拉伸因子(默认 0),内容区是 1,那么所有多余空间都给内容区,侧边栏保持自己的宽度。
尺寸策略简表
| SizePolicy | 含义 | 侧边栏该用吗 |
|---|---|---|
Fixed |
完全按 sizeHint,不可拉伸 | 固定宽度侧边栏合适 |
Minimum |
sizeHint 是最小值,可拉大 | 可以 |
Preferred |
最好按 sizeHint,也可以拉大 | 一般 |
Expanding |
尽量占满可用空间 | ❌ 侧边栏绝对不要用 |
Ignored |
无所谓的,随便拉伸 | ❌ |
新手最常犯的错就是把侧边栏设成 Expanding,结果侧边栏把整个窗口占满,内容区被挤成一条缝。
二、方案一:QHBoxLayout 手搭(最简单)
先看一个能跑的最小实现。
cpp
#include <QApplication>
#include <QWidget>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QListWidget>
#include <QStackedWidget>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
auto *window = new QWidget;
window->setWindowTitle(QStringLiteral("侧边栏布局"));
window->resize(800, 520);
// ---- 左侧:侧边栏 ----
auto *sidebar = new QWidget;
sidebar->setObjectName("sidebar"); // 给 QSS 用
sidebar->setFixedWidth(200);
auto *navList = new QListWidget(sidebar);
navList->addItems({QStringLiteral("首页"),
QStringLiteral("消息"),
QStringLiteral("设置")});
auto *sideLayout = new QVBoxLayout(sidebar);
sideLayout->setContentsMargins(0, 0, 0, 0);
sideLayout->setSpacing(0);
sideLayout->addWidget(navList, 1);
// ---- 右侧:内容区 ----
auto *content = new QStackedWidget;
for (int i = 0; i < 3; ++i) {
auto *page = new QLabel(QStringLiteral("第 %1 页内容").arg(i + 1));
page->setAlignment(Qt::AlignCenter);
content->addWidget(page);
}
// ---- 合并 ----
auto *root = new QHBoxLayout(window);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
root->addWidget(sidebar);
root->addWidget(content, 1); // 关键:内容区拉伸因子为 1
QObject::connect(navList, &QListWidget::currentRowChanged,
content, &QStackedWidget::setCurrentIndex);
window->show();
return app.exec();
}
配套 .pro:
bash
QT += widgets
CONFIG += c++17
TARGET = SidebarDemo
SOURCES += main.cpp
或者 CMake:
cmake
cmake_minimum_required(VERSION 3.16)
project(SidebarDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(SidebarDemo main.cpp)
target_link_libraries(SidebarDemo PRIVATE Qt6::Widgets)
这个方案简单直接,缺点是宽度不能拖、不能折叠。
三、方案二:QSplitter 实现可拖拽分隔条
QSplitter(分裂器)自带一个可拖拽的分隔条,用户能自由调整两侧比例,还能"拖到尽头就折叠"。
cpp
#include <QSplitter>
#include <QListWidget>
#include <QTextEdit>
auto *splitter = new QSplitter(Qt::Horizontal);
splitter->addWidget(sidebarList);
splitter->addWidget(textEdit);
// 关键:设置初始比例和伸缩特性
splitter->setStretchFactor(0, 0); // 侧边栏不随窗口拉伸
splitter->setStretchFactor(1, 1); // 内容区拉伸
splitter->setSizes({220, 580}); // 初始宽度分配
// 不允许子项被完全折叠(0 像素),保留最小宽度
splitter->setChildrenCollapsible(false);
sidebarList->setMinimumWidth(140);
几个 QSplitter 的重要特性:
| 特性 | 说明 |
|---|---|
setStretchFactor(i, f) |
窗口缩放时各子项按 f 分多余空间 |
setSizes({...}) |
设初始尺寸分配 |
setChildrenCollapsible(false) |
防止拖到 0 宽度 |
setCollapsible(i, false) |
只禁止某一个子项折叠 |
handleWidth |
分隔条宽度(配合 QSS 可做细线) |
saveState() / restoreState() |
保存/恢复用户拖拽的比例 |
saveState() 这个很有用:用户辛苦调好的比例,重启程序就没了会很烦躁。存到 QSettings 里即可。
cpp
// 退出时保存
QSettings settings;
settings.setValue("splitter/state", splitter->saveState());
// 启动时恢复
const QByteArray state = settings.value("splitter/state").toByteArray();
if (!state.isEmpty())
splitter->restoreState(state);
四、方案三:可折叠侧边栏 + 动画
实际产品里,侧边栏往往要能"收起来",给内容区腾地方。下面这个方案用 QPropertyAnimation 做宽度动画。
cpp
// collapsiblesidebar.h
class CollapsibleSidebar : public QWidget
{
Q_OBJECT
Q_PROPERTY(int animatedWidth READ animatedWidth WRITE setAnimatedWidth)
public:
explicit CollapsibleSidebar(QWidget *parent = nullptr);
bool isExpanded() const { return m_expanded; }
int animatedWidth() const { return width(); }
public slots:
void setExpanded(bool expanded);
void toggle();
private slots:
void setAnimatedWidth(int w);
private:
QPropertyAnimation *m_anim = nullptr;
bool m_expanded = true;
int m_expandedWidth;
};
cpp
// collapsiblesidebar.cpp
#include "collapsiblesidebar.h"
#include <QPropertyAnimation>
#include <QVBoxLayout>
#include <QListWidget>
CollapsibleSidebar::CollapsibleSidebar(QWidget *parent)
: QWidget(parent)
, m_expandedWidth(220)
{
setObjectName(QStringLiteral("sidebar"));
setMinimumWidth(0);
setMaximumWidth(m_expandedWidth);
auto *list = new QListWidget(this);
list->addItems({QStringLiteral("文件"), QStringLiteral("搜索"),
QStringLiteral("源码管理"), QStringLiteral("调试")});
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(list);
m_anim = new QPropertyAnimation(this, "animatedWidth", this);
m_anim->setDuration(180);
m_anim->setEasingCurve(QEasingCurve::InOutQuad);
}
void CollapsibleSidebar::setAnimatedWidth(int w)
{
// 宽度受 setMaximumWidth 限制,所以这里直接改固定宽度
setFixedWidth(w);
}
void CollapsibleSidebar::setExpanded(bool expanded)
{
if (m_expanded == expanded)
return;
m_expanded = expanded;
m_anim->stop();
m_anim->setStartValue(width());
m_anim->setEndValue(expanded ? m_expandedWidth : 0);
m_anim->start();
}
void CollapsibleSidebar::toggle()
{
setExpanded(!m_expanded);
}
主窗口里这样用:
cpp
auto *root = new QHBoxLayout(window);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
auto *sidebar = new CollapsibleSidebar;
root->addWidget(sidebar);
root->addWidget(content, 1);
auto *toggleBtn = new QPushButton(QStringLiteral("☰"));
QObject::connect(toggleBtn, &QPushButton::clicked,
sidebar, &CollapsibleSidebar::toggle);
动画方案的关键点
| 要点 | 说明 |
|---|---|
Q_PROPERTY + QPropertyAnimation |
属性必须有 READ/WRITE 访问器 |
用 setFixedWidth() 而非 resize() |
布局会覆盖 resize() 的结果 |
setStartValue(width()) |
从当前宽度动,中途打断也平滑 |
| 动画期间布局自动跟随 | setFixedWidth 会触发 updateGeometry,兄弟 widget 自动重排 |
五、方案对比:到底该用哪个
| 方案 | 可拖拽 | 可折叠 | 动画 | 代码量 | 适用场景 |
|---|---|---|---|---|---|
QHBoxLayout |
❌ | 手动 | ❌ | 极少 | 固定宽度侧边栏 |
QSplitter |
✅ | 可选 | ❌ | 少 | 用户需要自定义比例 |
QPropertyAnimation 自绘 |
❌ | ✅ | ✅ | 中 | 现代风格折叠侧栏 |
QDockWidget |
✅ | ✅ | ❌ | 少 | 需要停靠/浮动/多面板 |
QToolBox |
❌ | ✅(分组) | ❌ | 少 | 竖向分组堆叠面板 |
关于 QDockWidget:它是 QMainWindow 专属的,自带标题栏、浮动按钮、停靠区域(上下左右)。如果你的侧边栏需要"用户能拖出来变成浮动窗口"的能力,它最省事,但外观比较难定制。
cpp
auto *dock = new QDockWidget(QStringLiteral("导航"), mainWindow);
dock->setWidget(navList);
mainWindow->addDockWidget(Qt::LeftDockWidgetArea, dock);
常见坑点
坑点 1:设置了布局却没有任何效果
这是新手第一大坑。布局必须挂到一个 widget 上才会生效。下面这种写法是无效的:
❌ 错误写法:
cpp
auto *layout = new QHBoxLayout; // 没有父对象
layout->addWidget(sidebar);
layout->addWidget(content);
window->show(); // 布局和 window 一点关系都没有
✅ 正确写法,三种等价方式任选:
cpp
// 方式 A:构造时传父 widget
auto *layout = new QHBoxLayout(window);
// 方式 B:后置 setLayout
auto *layout = new QHBoxLayout;
window->setLayout(layout);
// 方式 C:父 widget 构造后,用在子 widget 上,但必须往上追到顶层
sidebar->setLayout(sideLayout); // 这是给 sidebar 设布局,不是给 window
还有一种变体坑:给 QMainWindow 用 setLayout()。
❌ 错误写法:
cpp
auto *w = new QMainWindow;
w->setLayout(new QHBoxLayout); // Qt 会警告 "already has a layout"
✅ 正确写法:QMainWindow 有自己的布局槽位,中间区域要用 setCentralWidget()。
cpp
auto *central = new QWidget;
auto *layout = new QHBoxLayout(central);
layout->addWidget(sidebar);
layout->addWidget(content, 1);
w->setCentralWidget(central);
坑点 2:侧边栏把窗口撑满了
❌ 错误写法:
cpp
sidebar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
// 侧边栏会疯狂抢空间
✅ 正确写法:
cpp
sidebar->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
sidebar->setFixedWidth(200);
// 或者用拉伸因子:layout->addWidget(content, 1);
顺带说,addWidget(w, 1) 的拉伸因子只在"有剩余空间"时起作用;侧边栏若是 Fixed,压根不参与分配。
坑点 3:折叠后留了一块空白
❌ 错误写法:
cpp
navList->hide(); // 列表没了,但 sidebar 容器还在,占着 200px
✅ 正确写法,隐藏整个侧边栏容器:
cpp
sidebar->hide(); // 或 hide() 之后调 updateGeometry()
另外 hide() 和 setVisible(false) 等价,但动画场景下不要立刻 hide() ,否则会瞬间消失、没有过渡。折中做法是展开前先 show(),动画结束后再真正 hide():
cpp
void CollapsibleSidebar::setExpanded(bool expanded)
{
if (expanded) show(); // 先显示再动
m_anim->setStartValue(width());
m_anim->setEndValue(expanded ? m_expandedWidth : 0);
if (!expanded) {
QObject::disconnect(m_anim, &QPropertyAnimation::finished, nullptr, nullptr);
QObject::connect(m_anim, &QPropertyAnimation::finished, this, [this]{
if (!m_expanded) hide(); // 结束后再隐藏,避免 1px 残影
});
}
m_anim->start();
}
坑点 4:动画属性没有生效
QPropertyAnimation 要求目标属性是已注册的 Q_PROPERTY ,且有 READ 和 WRITE。
❌ 错误写法:
cpp
QPropertyAnimation anim(sidebar, "width"); // width 不是可写属性,无效
✅ 正确写法,自己声明一个:
cpp
class CollapsibleSidebar : public QWidget {
Q_OBJECT
Q_PROPERTY(int animatedWidth READ animatedWidth WRITE setAnimatedWidth)
// ...
};
而且注意:如果 setAnimatedWidth() 里调用 setFixedWidth(),setFixedWidth 内部会设 min=max=w,同时把 size policy 改成 Fixed------这正是我们想要的效果,因为这样布局才会立即重排。
坑点 5:QSS 给侧边栏设背景色没效果
QWidget 子类默认不绘制背景,QSS 里的 background-color 对裸 QWidget 无效。
❌ 错误写法:
cpp
sidebar->setStyleSheet("QWidget { background: #2b2b2b; }");
// 大概率还是白的
✅ 正确写法,两种方案:
cpp
// 方案 A:用 objectName 精准指定 + 声明 WA_StyledBackground
sidebar->setObjectName("sidebar");
sidebar->setAttribute(Qt::WA_StyledBackground, true);
sidebar->setStyleSheet("#sidebar { background-color: #2b2b2b; }");
// 方案 B:直接把侧边栏做成 QFrame
auto *sidebar = new QFrame;
sidebar->setFrameShape(QFrame::NoFrame);
sidebar->setStyleSheet("QFrame#sidebar { background-color: #2b2b2b; }");
WA_StyledBackground 属性是让 QWidget 走样式表绘制背景的开关,很多"QSS 不生效"的问题都是因为它。
坑点 6:QListWidget 选中项有难看的虚线框
侧边栏导航列表选中后,Qt 默认会画一圈焦点虚线。QSS 里用 outline: none 处理:
cpp
navList->setStyleSheet(R"(
QListWidget { border: none; background: transparent; outline: none; }
QListWidget::item { height: 36px; padding-left: 16px; outline: none; }
QListWidget::item:selected { background: #3a3a3a; color: #fff; }
QListWidget::item:hover { background: #333; }
)");
注意 outline: none 要同时写在 QListWidget 和 QListWidget::item 两处,只写一处可能不管用。
总结
侧边栏布局的实现思路可以浓缩成一句话:一个宽度受限的 widget,加一个拉伸因子为 1 的内容区,塞进水平布局。
选型上:
| 需求 | 推荐方案 |
|---|---|
| 只要固定宽度 | QHBoxLayout |
| 用户要能拖比例 | QSplitter + saveState() |
| 要折叠动画 | QPropertyAnimation + Q_PROPERTY |
| 要能停靠浮动 | QDockWidget(限 QMainWindow) |
而所有"布局不生效"的问题,90% 都逃不出这三种原因:布局没挂到任何 widget 上、QMainWindow 用了 setLayout、侧边栏的 size policy 设成了 Expanding。把这三条记熟,再配上 setObjectName + WA_StyledBackground 处理 QSS 背景,侧边栏这块基本就不会再翻车了。