Qt 如何实现子控件的自适应左上角按行排列

Posted on Tue, 23 Dec 2025 09:35:48 +0800 by LiangMingJian


需求

在 Qt 编程时,有一个长方形 Widget,现在计划在这个 Widget 中添加不同尺寸的子 Widget,这些子 Widget 高度固定,宽度随机。

在添加子 Widget 时,要求子 Widget 从父控件的左上角开始排列,从左到右,从上到下。同时,可以根据子 Widget 的宽度,自动统计每一行的子 Widget 的数量,保证每一行所展示的子 Widget 都不会因为过长而溢出画面。

最终结果如下图所示:

界面实现

创建父控件

创建一个主窗口应用,在主窗口应用中添加父控件。

为了在子控件向下溢出时可以滚动查看,因此父控件需要使用 QScrollArea,在 QtDesigner 中,选择下图所示的组件加入:

加入后,将主窗口布局设置为水平布局,将 QScrollArea 的布局设置为栅栏(网格)布局。

在 QtDesigner 中,布局的设置可以通过右键对象来实现。

需要注意,在 QtDesigner 中,QScrollArea 在没有子组件的时候无法设置布局,因此可以先加入一个按钮,然后再设置布局,最后删除这个按钮,恢复原状。

最终,父控件与主窗口的结构关系如下图所示。

创建子控件

创建一个 QFrame 组件,子控件将在这里设置。

子控件要求进行信息展示,因此直接添加一个 QLabel 控件即可,布局推荐使用水平。

同时,子控件要求高度固定,宽度随机,因此还需要限定控件的最大最小尺寸。在这里,我们可以直接限定 QFrame 这个父组件的最大最小。

注意,这里只需要限定高度。

特别的,为了美观,可以为子控件设置 QCSS。

QLabel {
    background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 #ffffff, stop: 1 #f0f9ff);
    border: 1px solid #000000;
}

QLabel:hover {
    background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 #f8f9fa, stop: 1 #e6f7ff);
    border: 2px solid #000000;
}

逻辑实现

在上面的父控件 QScrollArea 中,我们使用了栅栏布局,我们以一个 Button 充当子控件,先看看栅栏布局的效果满不满足我们的需求。

不难从上面看出,栅栏布局将整个主窗口分为了 3 行 2 列,每个按钮子控件都均匀的放置对应的中心位置。

但这个显然不满足我们的需求,我们要的是子控件从左上角开始,左往右,从上到下的排列,而不是这样的平均分布。

那怎么实现呢?我们可以使用间隔 Spacers,将空白位置填充,让组件不平均分布。

在 QtDesigner 中,我们将一个水平间隔(HorizontalSpacer)和垂直间隔(VerticalSpacer)分别放置在子控件的左边和下边,如下图所示。

可以看见,现在的子控件就是从左上角开始,左到右,从上到下排列了。

但这样处理还是不能满足我们的需求,因为当按钮尺寸不一致时,每行的宽度还是固定的,不能根据每行的内容自适应宽度展示。

那怎么样才能避免这个问题?

不难想象,如果我们把每行的按钮都通过一个水平布局(Horizontal Layout)进行包裹,然后在水平布局最后添加一个水平间隔(HorizontalSpacer)填补空白,那不就避免网格布局多行宽度一致的问题了吗。

此时,我们只用到网格布局的第一列,然后因为每行都单独使用一个水平布局,因此每行插入的按钮数量都是独立的,可以多也可以少,相互间不会影响各自的宽度。

这个界面的对象结构大致如下:

当然,你应该可以发现,父控件 QScrollArea 在这里不使用网格布局也是可以的,因为只用到第一列,那这个结构其实就是垂直布局,因此,这里的网格布局替换为垂直布局也是没问题的。

经过上述的研究,我们可以梳理出——子控件自适应从左上角按行排列的实现逻辑:

  • 第一步:构建一个父控件 QScrollArea(支持溢出后滚动),布局选择网格布局或垂直布局(组件按行排列)。
  • 第二步:在父控件 QScrollArea 的第 1 行(如果是网格布局,则这里的位置是 0,0,第 1 行 1 列)添加一个水平布局组件(Horizontal Layout)。
  • 第三步:统计计算每个待插入子控件的宽度,然后计算每一行至多可以插入多少个子控件不会导致父控件 QScrollArea 水平溢出。
  • 第四步:向水平布局组件(Horizontal Layout)插入上面计算出的子控件数量。
  • 第五步:重复操作三和四,直到所有的子控件都插入完毕。
  • 第六步:在有插入子控件的最后一行下一行插入一个垂直间隔,填补多余的空白位置。

通过代码实现上述六步操作,即可完成目标需求。

代码实现

父控件的初始化代码这个不进行给出,通过 QtDesigner 直接生成即可,无需什么额外配置。

父控件通过以下代码进行调用:

# 父控件 QScrollArea
self.ui.scroll_area

# 父控件 QScrollArea 的网格布局
self.ui.cards_layout

子控件因为宽度随机(一般是随字体数量而变化),因此需要额外进行初始化。

子控件通过以下代码进行初始化:

class WordCard(QFrame):  
  
    def __init__(self, data, max_width=500, parent=None):  
        # 构建时支持传入 data(展示的文字内容),max_width(最大宽度)
        super().__init__(parent)  
        self.ui = Ui_WordCard()  
        self.ui.setupUi(self)  
  
        # 将传入数据转换为类属性 
        self.word_data = data  
        self.text = f'{self.word_data["word"]} | {self.word_data["translation"]}'  、
  
        # 计算控件的宽度,然后写入类属性 width
        # 获取控件的字体属性 
        font_metrics = QFontMetrics(self.ui.word_label.font())  
        # 计算这个字体下文字内容的宽度,加上 30 单位的边距宽度
        font_width = font_metrics.horizontalAdvance(self.text) + 30  
        # 如果上述计算的宽度小于输入的最大宽度,则将计算宽度写入类属性 width
        self.width = font_width if font_width <= max_width else max_width  
        
        # 将控件大小设置为计算宽度 
        self.setMaximumWidth(self.width)  
        # 将控件内容设置为文字内容
        self.ui.word_label.setText(self.text)

在主窗口中,构建以下方法函数,实现上文中的三,四,五步,以实现最终需求。

def refresh_cards(self):
    """刷新卡片显示"""
    # 获取父控件的最大宽度
    scroll_width = self.ui.scroll_area.width()
    # 设置父控件当前剩余的可用宽度
    current_width = scroll_width
    # 设置当前使用的行数
    current_layout = 0
    # 水平布局管理器,管理需要插入的布局(需要使用数组管理,通过行数提取不同行的布局,然后插入子控件)
    horizontal_layouts = [QHBoxLayout()]
    # 在父控件的网格布局中插入第一行的水平布局,位置(0, 0),1 行 1 列
    self.ui.cards_layout.addLayout(horizontal_layouts[current_layout], 0, 0)
    # 从数据字典 self.data 中获取数据
    for key, value in self.data.items():
        # 将数据字典的数据传入子控件构建函数,scroll_width - 50 将最大宽度设置为父控件宽度减去 50 单位的空余
        card = WordCard(value, scroll_width - 50)
        # 将当前的可用宽度减去待添加的子控件宽度,+6 边距
        current_width -= (card.width + 6)
        # 当可用宽度大于 0 时,说明可以插入这一个元素
        if current_width > 0:
            # 可以插入时,在当前使用的水平布局中插入这个元素
            horizontal_layouts[current_layout].addWidget(card)
        else:
            # 不可以插入时,在当前使用的水平布局中插入一个水平间隔(HorizontalSpacer)填补空白
            horizontal_layouts[current_layout].addSpacerItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))  # noqa  
            # 将当前行数加 1
            current_layout += 1
            # 在水平布局管理数组中添加一个新的水平布局
            horizontal_layouts.append(QHBoxLayout()) 
            # 将新的水平布局插入父控件的网格布局中,位置(current_layout, 0),current_layout 行 1 列(下一行,但列数不变) 
            self.ui.cards_layout.addLayout(horizontal_layouts[current_layout], current_layout, 0)
            # 在新一行的水平布局中插入子控件
            horizontal_layouts[current_layout].addWidget(card)  
            # 重置当前可用宽度为父控件最大宽度,然后再减去当前插入元素的宽度
            current_width = scroll_width - (card.width + 6)
    # 在所有元素都插入完成后,在最后一行插入一个水平间隔(HorizontalSpacer)填补空白
    horizontal_layouts[current_layout].addSpacerItem(QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))  # noqa
    # 构建一个垂直间隔(VerticalSpacer)
    vertical_spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)  # noqa
    # 在插入元素的最后一行的下一行,插入上面创建的垂直间隔,填补剩余的空白
    self.ui.cards_layout.addItem(vertical_spacer, current_row+1, 0)  

在主窗口中,调用上述函数方法,既可以在目标父控件中,自动的排列所需要的数据,无需考虑数据的长度问题,也不用担心数据全部都垂直居中,完成目标需求。

拓展阅读

清理父控件

一般来说,上面的实现代码不可能只执行一次,因此在每次执行前,一般都建议先清理父控件内的所有控件,保证父控件下没有任何内容后才继续执行。

清理的代码一般推荐这样编写:

# 清理代码
def clean_cards(self, widget):
    # 遍历目标控件的所有子控件
    while widget.count():
        # 获取第一个子控件
        child = widget.takeAt(0)
        if child.widget():
            # 如果是控件,则删除控件
            child.widget().deleteLater()
        elif child.layout():
            # 如果是布局,则迭代执行清理代码,删除控件
            self.clean_cards(child)

# 调用
def refresh_cards(self):
    self.clean_cards(self.ui.cards_layout)
    ...

更强大的功能

在上面代码的基础上,不难发现,还可以通过监听父控件的宽度变化,当宽度变化到一定程度后,可以进行重绘,实现更强大的功能。