跳转到内容
新建笔记

Qt 布局、Spacer 与控件移除

Qt 布局负责计算控件的位置和尺寸,控件对象本身通常由宿主窗口的对象树管理。把“由布局排列”和“由谁删除”分开,就能解释为什么 removeWidget() 以后控件还活着,甚至仍然显示在原来的位置。

一个宿主只安装一个顶层布局

跳转到“一个宿主只安装一个顶层布局”

new QHBoxLayout(this) 会把布局安装到 this 上,无须紧接着重复 setLayout()。另一种合法写法是先创建没有宿主的布局,最后调用宿主的 setLayout()。同一个 QWidget 不能同时安装两个顶层布局;需要复杂结构时,把没有宿主的子布局通过 addLayout() 加到顶层布局中。

子布局中的控件仍以实际宿主 QWidget 为父控件,不以 QLayout 为父控件。布局本身是 QObject 与 QLayoutItem 的组合;普通控件、嵌套布局、Spacer 分别通过不同的布局项参与计算。窗口销毁时会处理自己的布局和子控件;单独销毁布局并不等于删除它排列的控件。Qt:布局管理

Designer 项目还要分清 ui 和窗口:生成的 Ui::Form 通常只是普通辅助类,没有 layout() 方法。访问实际窗口的 layout(),或者访问 ui->horizontalLayout 等生成成员;QMainWindow 的内容布局通常装在 centralWidget() 上。不能在 setupUi() 之后不加检查地给已有布局的宿主再安装一个顶层布局。参见 [[10-knowledge/computing/software-development/application-development/desktop/qt/core/designer-generated-classes|Designer 生成类与所有权]]。

removeWidget 不等于隐藏或删除

跳转到“removeWidget 不等于隐藏或删除”

对于这里使用的 QHBoxLayout、QVBoxLayout:

操作布局管理可见性对象与父对象
layout->removeWidget(widget)从该布局移除不替你隐藏对象还在,父对象保持不变
widget->hide()仍在布局中时,通常不再占用布局空间隐藏对象还在,父对象保持不变
重新 addWidget(widget)重新参与该布局若曾显式隐藏,按需 show()添加过程会根据宿主处理父控件关系
delete widgetQt 会处理该控件的布局关联不再显示对象立即销毁,裸指针随即失效
widget->deleteLater()销毁执行前对象仍可能存在不保证立即隐藏交给相应事件循环延后销毁

removeWidget() 只对目标布局中的条目操作,不负责递归搜索所有子布局。移除后,应明确选择重新安排几何尺寸、放入另一布局、隐藏或销毁。hide() 的占位行为还可能被 QSizePolicy::retainSizeWhenHidden 改变,不能把它当作所有布局的无条件规则。堆叠容器有额外的页面隐藏语义,见 [[10-knowledge/computing/software-development/application-development/desktop/qt/widgets/stacked-pages|QStackedLayout 与 QStackedWidget]]。Qt:removeWidget、Qt:隐藏时保留尺寸

原来的图表场景是在 640×480 窗口内显示 A、B、C、D、E 五组值 1、2、3、4、5,纵轴范围 0~10,随后移除图表控件。图表的数据和坐标轴与移除语义是两层问题;原片段没有提供自定义 BalancingBarChart 的定义,无法单独编译。下面改用标准按钮和标签演示相同的布局行为;实际项目只需把 upper 换成已经构造好的图表指针,并使用真正包含它的布局。

可运行例子:左右布局与控件重新加入

跳转到“可运行例子:左右布局与控件重新加入”

左侧按钮与右侧上下标签分别使用伸缩系数 1 和 3。它们表达剩余空间分配的相对倾向,不保证所有窗口尺寸下实际像素宽度恰好为 1:3,还要满足控件最小尺寸、尺寸策略、间距等约束。

#include <QApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QPointer>
#include <QPushButton>
#include <QVBoxLayout>
#include <QWidget>
#include <cassert>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QWidget host;
auto *outer = new QHBoxLayout(&host);
auto *right = new QVBoxLayout; // 嵌套布局不要再安装到 host
auto *button = new QPushButton("Left");
auto *upper = new QLabel("Upper");
auto *lower = new QLabel("Lower");
outer->addWidget(button, 1);
outer->addLayout(right, 3);
right->addWidget(upper);
right->addWidget(lower);
host.resize(640, 480);
host.show();
app.processEvents();
assert(host.layout() == outer);
assert(right->parentWidget() == &host);
assert(upper->parentWidget() == &host);
assert(!upper->isHidden());
QPointer<QLabel> observed = upper;
right->removeWidget(upper);
assert(right->indexOf(upper) == -1);
assert(observed && upper->parentWidget() == &host);
assert(!upper->isHidden()); // 移除布局没有替我们调用 hide
upper->hide();
assert(upper->isHidden());
right->insertWidget(0, upper);
upper->show();
assert(right->indexOf(upper) == 0);
if (app.arguments().contains("--check")) return 0;
return app.exec();
}

不要在清空布局时一律 delete item->widget() 后忘记 delete item。takeAt() 交还的是布局项,它可能包含控件、子布局或者 Spacer;递归清空需要明确每一种对象是否仍要保留。仅从某个布局取出布局项,不会自动把其内部的所有对象全部销毁。Qt:takeAt

Spacer 的方向、策略与 sizeHint

跳转到“Spacer 的方向、策略与 sizeHint”

Spacer 是不绘制内容的 QSpacerItem,不是透明的 QWidget,也没有控件那套父对象关系。Designer 中的水平、垂直 Spacer 表达希望在哪个方向安排空白;实际留出的范围仍由所在布局和两个方向的尺寸策略共同决定。

把 sizeHint 设为 40×20,是在表达期望宽高,不是锁定几何尺寸,也不表示窗口缩放时这个提示值会自动改变。geometry() 才是布局本次分配的矩形。下表按单个方向解释常见策略,实际结果还要结合最小/最大尺寸等约束:

策略对该方向的主要含义
Fixed以提示尺寸为固定目标,不主动伸缩
Minimum提示尺寸作为下限,可以变大
Maximum提示尺寸作为上限,可以变小
Preferred偏好提示尺寸,允许变大或变小
MinimumExpanding不低于提示尺寸,并希望取得额外空间
Expanding可变大或变小,并希望取得额外空间
Ignored布局计算忽略这一方向的提示尺寸;不代表其他尺寸约束也失效

Expanding 与 MinimumExpanding 更适合希望吸收多余空间的方向;要恒定间距可使用 addSpacing(),要伸缩空白可使用 addStretch()。直接操作 QSpacerItem 时,加入布局后的 changeSize() 要配合布局 invalidate(),让新参数参与下一次计算。Qt:QSizePolicy、Qt:QSpacerItem

下面的第二个完整程序演示修改提示值。断言比较提示值和已知约束,不把某个桌面样式下的最终像素宽度当作跨平台常量。

#include <QApplication>
#include <QHBoxLayout>
#include <QPushButton>
#include <QSizePolicy>
#include <QSpacerItem>
#include <QWidget>
#include <cassert>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QWidget host;
auto *row = new QHBoxLayout(&host);
row->addWidget(new QPushButton("Left"));
auto *gap = new QSpacerItem(40, 20, QSizePolicy::Expanding,
QSizePolicy::Minimum);
row->addItem(gap); // 布局管理这个布局项
row->addWidget(new QPushButton("Right"));
host.resize(480, 120);
host.show();
app.processEvents();
assert(gap->sizeHint() == QSize(40, 20));
assert(gap->expandingDirections().testFlag(Qt::Horizontal));
gap->changeSize(60, 20, QSizePolicy::Fixed, QSizePolicy::Minimum);
row->invalidate();
row->activate();
assert(gap->sizeHint() == QSize(60, 20));
assert(gap->minimumSize().width() == 60);
assert(gap->maximumSize().width() == 60);
if (app.arguments().contains("--check")) return 0;
return app.exec();
}

两个程序分别保存成各自项目中的 main.cpp,不要把两个 main 放进同一个目标。两者都可使用下面的构建文件;用 Debug 构建后传入 --check 会在断言完成后退出。

cmake_minimum_required(VERSION 3.20)
project(LayoutExample LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(layout_example main.cpp)
target_link_libraries(layout_example PRIVATE Qt6::Widgets)