PyQt - QScrollArea moves widget down to viewport when layout is updated while scrolled down

81 Views Asked by At

I have a custom widget that is a table consisting of a QGridLayout inside a QScrollArea. You can place widgets into the cells. I also placed QScrollArea s as headers in the margins of the table. When I scroll the table the headers also scroll. This is the result. I coloured the headers' widgets in blue and the headers' scroll areas in red.

Table

You can add or remove rows and columns to the table's model and then the whole table and its headers is updated. If I scroll down and want to e.g. place a new row under the selected cell the table displays the changes without any problems. But the respective header's widget is moved down into the viewport of its scroll area so that the numbers doesn't line up with the grid anymore but the widget is aligned with the viewport. When you then scroll up there is a big gap between the header's widget and the top of the scroll area. It looks like this:

Misaligned headers

This is a problem with both headers. Each header section is a QWidget with a QLabel inside. The model is a list with all header sections. To summerize, when a header readds the sections to its layout it always begins within the visible area to the user.

Here is the code:

UI_Header.py (UI only)

class Header(QtWidgets.QScrollArea):
    
    def __init__(self, direction: QtWidgets.QBoxLayout.Direction, parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)

        self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setWidgetResizable(True)
        self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
        self.setStyleSheet("background-color: red")

        self.header_widget = QtWidgets.QWidget(self)
        self.header_widget.setStyleSheet("background-color: blue")
        self.header_widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)  
        self.header_layout = QtWidgets.QBoxLayout(direction)
        self.header_layout.setSpacing(0)
        self.header_layout.setContentsMargins(0, 0, 0, 0)
        self.header_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
        self.header_widget.setLayout(self.header_layout)
        self.setWidget(self.header_widget)

        if direction == QtWidgets.QBoxLayout.Direction.LeftToRight:
            self.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
            self.header_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
        else:
            self.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
            self.header_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)

header.py (Controll of the ui.)

class HeaderView(Header):
    itemResized = QtCore.pyqtSignal()

    def __init__(self, orientation: QtCore.Qt.Orientation, direction: QtWidgets.QBoxLayout.Direction, parent: QtWidgets.QWidget | None = ...) -> None:
        super().__init__(direction, parent)
        self.orientation = orientation
        self.direction = direction
        self.model = None


    def clear(self) -> None:
        for i in reversed(range(self.header_layout.count())):
            w = self.header_layout.takeAt(i).widget()
            self.header_layout.removeWidget(w)

    def update_header(self) -> None:
        self.clear()
        for i, hitem in enumerate(self.model.header(self.orientation)):
            hitem.resized.connect(self.itemResized.emit)
            hitem.isResizing.connect(self.set_resizing)
            if not hitem.model.text or hitem.model.orientation == QtCore.Qt.Orientation.Vertical:
                hitem.label.setText(str(i+1))
            self.header_layout.addWidget(hitem)
        self.header_widget.updateGeometry()

You can see that when the model changes the every section "item" is removed from the layout and added to the layout again according to the model. I know this is not the most efficient way to update this.

This is how the headers are placed into the table-widget:

UI_Table.py

class Table(QtWidgets.QScrollArea):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setUi()
        self.setObjectName("Table")
        self.setStyleSheet(fromStyle("Table"))
        self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)       
    
    def setUi(self):
        self.setWidgetResizable(True)
        self.grid_widget = GridWidget(self)
        self.grid_widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)  
        self.grid_widget.setContentsMargins(0, 0, 0, 0)

        self.grid = QtWidgets.QGridLayout(self)
        self.grid.setContentsMargins(1, 1, 0, 0)
        self.grid.setSpacing(0)
        self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
        self.grid_widget.setLayout(self.grid)
        self.setWidget(self.grid_widget)

        self.margins = QtCore.QMargins(30, 30, 0, 0)
        self.setViewportMargins(self.margins)

        self.hheaders = HeaderView(QtCore.Qt.Orientation.Horizontal, QtWidgets.QBoxLayout.Direction.LeftToRight, self)
        self.vheaders = HeaderView(QtCore.Qt.Orientation.Vertical, QtWidgets.QBoxLayout.Direction.TopToBottom, self)


    def scrollContentsBy(self, dx: int, dy: int) -> None:
        self.hheaders.scroll(dx, 0)
        self.vheaders.scroll(0, dy)
        self.updateGeometry()
        super().scrollContentsBy(dx, dy)

    def resizeEvent(self, a0: QtGui.QResizeEvent | None) -> None:
        rect = self.viewport().geometry()
        self.hheaders.setGeometry(
            rect.x() +1, rect.y() - self.margins.top(), rect.width(), self.margins.top()
        )
        self.vheaders.setGeometry(
            rect.x()  - self.margins.left(), rect.y() + 1, self.margins.left(), rect.height()
        )
        super().resizeEvent(a0)

As you can see I used updateGeometry() after the headers are updated. There was a scenario where this worked although there was the caveat that the table scrolled to the top position but I couldn't replicate this behavior.

I also set the alignment on the scroll areas itselves and not only on their layouts but that also didn't work. I also tried to use a QGridLayout like in the table's layout because it doesn't have the problem. I have a guess that it may have to do how the headers' geometry is set in the table's resizeEvent() because the table grid works fine with this way of updating but I don't know how to place the headers in the margins otherwise.

So I ran out if ideas and I hope that someone has an idea to fix the problem with the scrolling areas of the headers whenever they are updated. Tell me if you need more code to replicate but my project is really big so I can't include everything. I am thankful for every idea.

1

There are 1 best solutions below

2
Deator On

OK I found the solution by myself. These are the changes I made:

UI_Header.py:

First I enabled the scrollbars again and made them invisible with the stylesheet. Also the size policy setting is unnecessary.

class Header(QtWidgets.QScrollArea):
    
    def __init__(self, direction: QtWidgets.QBoxLayout.Direction, parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)

        self.verticalScrollBar().setStyleSheet("QScrollBar {height:0px;}")
        self.horizontalScrollBar().setStyleSheet("QScrollBar {width:0px;}")
        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        self.setWidgetResizable(True)
        self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
        self.setStyleSheet("background-color: red")
        ...

UI_Table.py

Instead of using the scroll() method I connected the scrollbars of the table to the scrollbars of the headers. This will ensure that the scrolling position of the table and the headers always matches. These are the additons. I don't use the method scrollContentsBy() anymore. Also in the on_resized() method I changed the code so the header_widget is being resized. Before that the scrollbars wouldn't work because I resized the scroll area.

class Table(QtWidgets.QScrollArea):
    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setUi()
        self.setObjectName("Table")
        self.setStyleSheet(fromStyle("Table"))
        self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)       
    
    def setUi(self):
        ...
        self.horizontalScrollBar().valueChanged.connect(self.on_scroll_bar_moved)
        self.verticalScrollBar().valueChanged.connect(self.on_scroll_bar_moved)

    def on_scroll_bar_moved(self) -> None:
        self.hheaders.horizontalScrollBar().setValue(self.horizontalScrollBar().value())
        self.vheaders.verticalScrollBar().setValue(self.verticalScrollBar().value())

    def on_resized(self) -> None:
        self.hheaders.header_widget.setMinimumWidth(self.grid_widget.width())
        self.vheaders.header_widget.setMinimumHeight(self.grid_widget.height())

table.py

The misalignment still happened but now I just needed to set the headers' scroll bars values to the scroll bar values of the table. This happens after the table is updated.

class WidgetTable(Table):

    def clear(self) -> None:
        """Removes all rows and columns."""
        for i in reversed(range(self.grid.count())):
            w = self.grid.takeAt(i).widget()
            self.grid.removeWidget(w)

    def updateTable(self) -> None:
        """Updates table according to the model."""
        self.clear()
        self.vheaders.verticalScrollBar().setValue(0)
        self.hheaders.horizontalScrollBar().setValue(0)
        for irow in range(self.model.row_count()):
            for icolumn in range(self.model.column_count()):
                cell = self.model.data(irow, icolumn)
                self.grid.addWidget(cell, irow, icolumn)

        ...

        self.hheaders.horizontalScrollBar().setMaximum(self.horizontalScrollBar().maximum())
        self.vheaders.verticalScrollBar().setMaximum(self.verticalScrollBar().maximum())
        self.hheaders.horizontalScrollBar().setValue(self.horizontalScrollBar().value())
        self.vheaders.verticalScrollBar().setValue(self.verticalScrollBar().value())

Maybe there is a better way but this is the solution for now. There is still some minor misalignment but this can be solved by adjusting spacing and the margins.