QScrollArea 中的冻结小部件

Frozen widgets in QScrollArea

我正在尝试创建一个可滚动的方形按钮网格,如果 window 太小而无法显示所有按钮。我希望在最左边的列和最上面的行上有标签显示按钮索引。

有没有办法创建一个 QScrollArea,其中的小部件(标签)在最顶行和最左边的列“冻结”。类似于在 Excel Sheet 中冻结行和列的方式,它们会在您滚动时跟随视图。

在此处查看模型:

欢迎使用 Qt 和 PyQt。

我使用 this answer 中概述的方法解决了多个 QScrollAreas 的问题。这个想法是让冻结区域 QScrollArea 禁用滚动,而未冻结的 QScrollArea 滚动条信号连接到冻结的 QScrollArea 滚动条槽。

这是我的模型的代码,最上面的行和最左边的列被冻结。特别相关的部分是 FrozenScrollArea class 和 Window class.

中的连接
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
    QApplication,
    QPushButton,
    QWidget,
    QScrollArea,
    QGridLayout,
    QLabel,
    QFrame,
    QSpacerItem,
    QSizePolicy,
    )


ROWS = 10
COLS = 20
SIZE = 35


style = """
Button {
    padding: 0;
    margin: 0;
    border: 1px solid black;
}
Button::checked {
    background-color: lightgreen;
}
"""


class Button(QPushButton):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setFixedSize(SIZE, SIZE)
        self.setCheckable(True)
        self.setStyleSheet(style)


class Label(QLabel):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setAlignment(Qt.AlignCenter)
        self.setFixedSize(SIZE, SIZE)


class Labels(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        layout = QGridLayout()
        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)


class FrozenScrollArea(QScrollArea):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setWidgetResizable(True)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.verticalScrollBar().setEnabled(False)
        self.horizontalScrollBar().setEnabled(False)


class FrozenRow(FrozenScrollArea):
    def __init__(self, parent):
        super().__init__()

        labels = Labels(parent)
        for c in range(COLS):
            label = Label(self, text = str(c))
            labels.layout().addWidget(label, 0, c, 1, 1, Qt.AlignCenter)

        labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum), 0, COLS, 1, 1)

        self.setFrameShape(QFrame.NoFrame)
        self.setFixedHeight(SIZE)
        self.setWidget(labels)


class FrozenColumn(FrozenScrollArea):
    def __init__(self, parent):
        super().__init__()

        labels = Labels(parent)
        for r in range(ROWS):
            label = Label(self, text = str(r))
            labels.layout().addWidget(label, r, 0, 1, 1, Qt.AlignCenter)

        labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), ROWS, 0, 1, 1)

        self.setFrameShape(QFrame.NoFrame)
        self.setFixedWidth(SIZE)
        self.setWidget(labels)


class ButtonGroup(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        layout = QGridLayout()
        for r in range(ROWS):
            for c in range(COLS):
                button = Button(self)
                layout.addWidget(button, r, c, 1, 1)

        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)

        self.setLayout(layout)


class Buttons(QScrollArea):
    def __init__(self, parent):
        super().__init__()
        self.setFrameShape(QFrame.NoFrame)
        self.setWidget(ButtonGroup(parent))


class Window(QWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # layout
        layout = QGridLayout()
        self.setLayout(layout)
        layout.setHorizontalSpacing(0)
        layout.setVerticalSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)

        # frozen row (top)
        self.frozenRow = FrozenRow(self)
        layout.addWidget(self.frozenRow, 0, 1, 1, 1)

        # frozen column (left)
        self.frozenColumn = FrozenColumn(self)
        layout.addWidget(self.frozenColumn, 1, 0, 1, 1)

        # button grid
        self.buttons = Buttons(self)
        layout.addWidget(self.buttons, 1, 1, 1, 1)

        # scrollbar connections
        self.buttons.horizontalScrollBar().valueChanged.connect(self.frozenRow.horizontalScrollBar().setValue)  # horizontal scroll affects frozen row only
        self.buttons.verticalScrollBar().valueChanged.connect(self.frozenColumn.verticalScrollBar().setValue)  # vertical scroll affects frozemn column only

        self.show()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec())

虽然冻结滚动区域的方法很有效,但它也有一些缺点;最重要的是,它:

  • 不是动态的;
  • 不考虑基本的框布局;
  • 不支持不同方向(对于盒装布局)或原点(对于网格布局);

虽然这更像是一个“边缘案例”,但我想建议一个替代方案,它基于 QHeaderView 和一个使用 header 尺寸的布局管理器的“私有”模型。

它并不像标准 QHeaderView 所期望的那样直接支持调整大小,但这几乎是不可能的:对于盒装布局,不可能设置布局项目大小(如果不是通过完全覆盖布局设置几何图形的方式) ),并且对于网格布局,没有办法知道行或列是否被“实际”删除,因为 rowCount()columnCount() 永远不会在网格大小更改时动态更新。

该概念基于覆盖滚动区域的事件过滤器,并检查是否正在发生几何变化 如果布局必须再次布置项目。然后,实现使用布局信息来更新基础模型并为 SizeHintRoleheaderData().

提供适当的值

子类 QScrollArea 创建两个 QHeaderView,并在需要时使用 ResizeToContents 部分调整大小模式(查询 headerData())更新它们,并根据 setViewportMargins 的大小提示使用 headers.

class LayoutModel(QtCore.QAbstractTableModel):
    reverse = {
        QtCore.Qt.Horizontal: False, 
        QtCore.Qt.Vertical: False
    }
    def __init__(self, rows=None, columns=None):
        super().__init__()
        self.rows = rows or []
        self.columns = columns or []

    def setLayoutData(self, hSizes, vSizes, reverseH=False, reverseV=False):
        self.beginResetModel()
        self.reverse = {
            QtCore.Qt.Horizontal: reverseH, 
            QtCore.Qt.Vertical: reverseV
        }
        self.rows = vSizes
        self.columns = hSizes
        opt = QtWidgets.QStyleOptionHeader()
        opt.text = str(len(vSizes))
        style = QtWidgets.QApplication.style()
        self.headerSizeHint = style.sizeFromContents(style.CT_HeaderSection, opt, QtCore.QSize())
        self.endResetModel()

    def rowCount(self, parent=None):
        return len(self.rows)

    def columnCount(self, parent=None):
        return len(self.columns)

    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        if role == QtCore.Qt.DisplayRole:
            if self.reverse[orientation]:
                if orientation == QtCore.Qt.Horizontal:
                    section = len(self.columns) - 1 - section
                else:
                    section = len(self.rows) - 1 - section
            # here you can add support for custom header labels
            return str(section + 1)
        elif role == QtCore.Qt.SizeHintRole:
            if orientation == QtCore.Qt.Horizontal:
                return QtCore.QSize(self.columns[section], self.headerSizeHint.height())
            return QtCore.QSize(self.headerSizeHint.width(), self.rows[section])

    def data(self, *args, **kwargs):
        pass # not really required, but provided for consistency


class ScrollAreaLayoutHeaders(QtWidgets.QScrollArea):
    _initialized = False
    def __init__(self):
        super().__init__()
        self.hHeader = QtWidgets.QHeaderView(QtCore.Qt.Horizontal, self)
        self.vHeader = QtWidgets.QHeaderView(QtCore.Qt.Vertical, self)
        self.layoutModel = LayoutModel()
        for header in self.hHeader, self.vHeader:
            header.setModel(self.layoutModel)
            header.setSectionResizeMode(header.Fixed)
        self.updateTimer = QtCore.QTimer(
            interval=0, timeout=self.updateHeaderSizes, singleShot=True)

    def layout(self):
        try:
            return self.widget().layout()
        except AttributeError:
            pass

    def eventFilter(self, obj, event):
        if obj == self.widget() and obj.layout() is not None:
            if event.type() in (event.Resize, event.Move):
                if self.sender() in (self.verticalScrollBar(), self.horizontalScrollBar()):
                    self.updateGeometries()
                else:
                    self.updateHeaderSizes()
            elif event.type() == event.LayoutRequest:
                self.widget().adjustSize()
                self.updateTimer.start()
        return super().eventFilter(obj, event)

    def updateHeaderSizes(self):
        layout = self.layout()
        if layout is None:
            self.layoutModel.setLayoutData([], [])
            self.updateGeometries()
            return
        self._initialized = True
        hSizes = []
        vSizes = []
        layGeo = self.widget().rect()
        reverseH = reverseV = False
        if isinstance(layout, QtWidgets.QBoxLayout):
            count = layout.count()
            direction = layout.direction()
            geometries = [layout.itemAt(i).geometry() for i in range(count)]
            # LeftToRight and BottomToTop layouts always have a first bit set
            reverse = direction & 1
            if reverse:
                geometries.reverse()
            lastPos = 0
            lastGeo = geometries[0]
            if layout.direction() in (layout.LeftToRight, layout.RightToLeft):
                if reverse:
                    reverseH = True
                vSizes.append(layGeo.bottom())
                lastExt = lastGeo.x() + lastGeo.width()
                for geo in geometries[1:]:
                    newPos = lastExt + (geo.x() - lastExt) / 2
                    hSizes.append(newPos - lastPos)
                    lastPos = newPos
                    lastExt = geo.x() + geo.width()
                hSizes.append(layGeo.right() - lastPos - 1)
            else:
                if reverse:
                    reverseV = True
                hSizes.append(layGeo.right())
                lastExt = lastGeo.y() + lastGeo.height()
                for geo in geometries[1:]:
                    newPos = lastExt + (geo.y() - lastExt) / 2
                    vSizes.append(newPos - lastPos)
                    lastPos = newPos
                    lastExt = geo.y() + geo.height()
                vSizes.append(layGeo.bottom() - lastPos + 1)
        else:
            # assume a grid layout
            origin = layout.originCorner()
            if origin & 1:
                reverseH = True
            if origin & 2:
                reverseV = True
            first = layout.cellRect(0, 0)
            lastX = lastY = 0
            lastRight = first.x() + first.width()
            lastBottom = first.y() + first.height()
            for c in range(1, layout.columnCount()):
                cr = layout.cellRect(0, c)
                newX = lastRight + (cr.x() - lastRight) / 2
                hSizes.append(newX - lastX)
                lastX = newX
                lastRight = cr.x() + cr.width()
            hSizes.append(layGeo.right() - lastX)
            for r in range(1, layout.rowCount()):
                cr = layout.cellRect(r, 0)
                newY = lastBottom + (cr.y() - lastBottom) / 2
                vSizes.append(newY - lastY)
                lastY = newY
                lastBottom = cr.y() + cr.height()
            vSizes.append(layGeo.bottom() - lastY)
        hSizes[0] += 2
        vSizes[0] += 2
        self.layoutModel.setLayoutData(hSizes, vSizes, reverseH, reverseV)
        self.updateGeometries()

    def updateGeometries(self):
        self.hHeader.resizeSections(self.hHeader.ResizeToContents)
        self.vHeader.resizeSections(self.vHeader.ResizeToContents)
        left = self.vHeader.sizeHint().width()
        top = self.hHeader.sizeHint().height()
        self.setViewportMargins(left, top, 0, 0)
        vg = self.viewport().geometry()
        self.hHeader.setGeometry(vg.x(), 0, 
            self.viewport().width(), top)
        self.vHeader.setGeometry(0, vg.y(), 
            left, self.viewport().height())
        self.hHeader.setOffset(self.horizontalScrollBar().value())
        self.vHeader.setOffset(self.verticalScrollBar().value())

    def sizeHint(self):
        if not self._initialized and self.layout():
            self.updateHeaderSizes()
        hint = super().sizeHint()
        if self.widget():
            viewHint = self.viewportSizeHint()
            if self.horizontalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
                if viewHint.width() > hint.width():
                    hint.setHeight(hint.height() + self.horizontalScrollBar().sizeHint().height())
            if self.verticalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
                if viewHint.height() > hint.height():
                    hint.setWidth(hint.width() + self.verticalScrollBar().sizeHint().width())
        hint += QtCore.QSize(
            self.viewportMargins().left(), self.viewportMargins().top())
        return hint

    def resizeEvent(self, event):
        super().resizeEvent(event)
        QtCore.QTimer.singleShot(0, self.updateGeometries)

备注:

  • 上面的代码会导致某种程度的递归;这是预料之中的,因为调整视口的大小显然会触发 resizeEvent,但 Qt 足够聪明,可以在大小不变时忽略它们;
  • 这仅适用于基本的 QBoxLayouts 和 QGridLayout;它未经 QFormLayout 测试,其他自定义 QLayout 子类的行为完全出乎意料;