Search code examples
pythonqtpyqtpyqt5

PyQt5 - Center a Widget *relative to the Window dimensions* while ignoring other widgets?


How can I vertically + horizontally center a widget, while ignoring all other widgets within the View/Window?

I'm trying to add equally-sized Expanding spacers on both the top and bottom of my QPushButton. I want these spacers to respect the window's height -- and the window's height only. However, an additional ~19px always gets added to the top spacer upon initialization. 19px seems to be the height of my breadcrumb in the upper-left. How do I tell the spacers to center the button Widget in respect to the Window/View size only--and not in respect to any widgets?

enter image description here

I've tried overriding resizeEvent and manually calculating what the top and bottom spacers' height should be, then changing those heights using QSpacerItem.changeSize(), but to no avail. I also experimented with QSizePolicy.Fixed instead of QSizePolicy.Expanding with inconsistent results. (The spacers also didn't expand when using QSizePolicy.Fixed, naturally.)

def resizeEvent(self, event):
    super().resizeEvent(event)
        
    if self.isResizing:
        return

    self.isResizing = True

    # Calculate the window's total height.
    totalHeight = self.size().height()
        
    # Calculate the total height taken by the breadcrumb.
    breadcrumbHeight = self.breadcrumb.sizeHint().height()
    breadcrumbMargins = self.breadcrumb.contentsMargins()
    breadcrumbTotalHeight = breadcrumbHeight + breadcrumbMargins.top() + breadcrumbMargins.bottom()
        
    # Calculate the total available space for the spacers.
    topSpacerHeight = (totalHeight // 2) - ((self.button.size().height()) // 2) - breadcrumbTotalHeight        
    bottomSpacerHeight = (totalHeight // 2) - ((self.button.size().height()) // 2)
    print('topSpacerHeight ' + str(topSpacerHeight))
    print('bottomSpacerHeight ' + str(bottomSpacerHeight))
        
    # Adjust the top spacer
    self.topSpacer.changeSize(20, topSpacerHeight, QSizePolicy.Minimum, QSizePolicy.Expanding)

    # Adjust the bottom spacer similarly
    self.bottomSpacer.changeSize(20, bottomSpacerHeight, QSizePolicy.Minimum, QSizePolicy.Expanding)

    self.isResizing = False

Any ideas? This seems like something that should be easily achievable. I'm able to calculate the size of breadcrumb within the overridden resizeEvent() function using breadcrumbHeight = self.breadcrumb.sizeHint().height(). I feel like we should be able to leverage that to adjust the top spacer's size so it's equal to the bottom one.

Here is a minimal reproducible example to play with:

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSpacerItem, QSizePolicy
from PyQt5.QtCore import Qt, QSize
    
class MinimalExample(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Minimal Example')
        self.setGeometry(100, 100, 800, 450)
        self.initUI()
    
    def initUI(self):
        self.centralWidget = QWidget(self)
        self.setCentralWidget(self.centralWidget)
    
        mainLayout = QVBoxLayout(self.centralWidget)
    
        # Breadcrumb at the top left
        breadcrumb = QLabel('<a href="#">Home</a> > Page')
        breadcrumb.setAlignment(Qt.AlignLeft | Qt.AlignTop)
        mainLayout.addWidget(breadcrumb)
    
        # Spacer to push everything else to the center
        topSpacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
        mainLayout.addSpacerItem(topSpacer)
    
        # Button in the middle
        buttonLayout = QHBoxLayout()
        mainLayout.addLayout(buttonLayout)
    
        button = QPushButton("Click Me")
        button.setFixedSize(QSize(750, 200))
        buttonLayout.addWidget(button, 0, Qt.AlignCenter)
    
        # Spacer at the bottom
        bottomSpacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
        mainLayout.addSpacerItem(bottomSpacer)
    
if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MinimalExample()
    window.show()
    sys.exit(app.exec_())

Thank you in advance for any help! I've embarrassingly been spending all day on this!


Solution

  • Using a layout manager means that the geometries of its managed items is completely delegated to it.

    While it's normally preferable to always let layout managers do their job, it's sometimes necessary to override that by manually setting the geometry of some widgets, and in that case it's better to not add those widgets to the layout, but just ensure that they are created with the parent widget argument.

    This makes it necessary to manually manage the geometries of those widgets, which normally happens in the resizeEvent() override of the parent widget, but, in case those widgets should be considered as part of the "visual layout", some further precautions are needed.

    In this specific case, you can just add a stretch at the end of the layout (right after the label), but you should ensure that the parent widget always has enough space to show the "floating" widget by properly setting its minimum size:

    class MinimalExample(QMainWindow):
        def __init__(self):
            super().__init__()
            self.setWindowTitle('Minimal Example')
            self.setGeometry(100, 100, 800, 450)
            self.initUI()
    
        def initUI(self):
            centralWidget = QWidget(self)
            self.setCentralWidget(centralWidget)
        
            mainLayout = QVBoxLayout(centralWidget)
        
            # Breadcrumb at the top left
            breadcrumb = QLabel('<a href="#">Home</a> > Page')
            breadcrumb.setAlignment(Qt.AlignLeft | Qt.AlignTop)
            mainLayout.addWidget(breadcrumb)
            mainLayout.addStretch()
    
            self.button = QPushButton("Click Me", centralWidget)
            self.button.setFixedSize(QSize(750, 200))
            margins = mainLayout.contentsMargins()
            centralWidget.setMinimumSize(
                margins.left() + margins.right() + self.button.width(), 
                mainLayout.spacing()
                + mainLayout.minimumSize().height() + self.button.height()
            )
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            r = self.button.rect()
            r.moveCenter(self.centralWidget().geometry().center())
            self.button.setGeometry(r)
    

    You should be aware, though, that this may have unwanted results: for instance, the if the window height is changed to the minimum, the button will be overlaid above the label, and that's because even if we included the minimum height of the label and button, the attempt to always center the widget results in inconsistent geometries, which is why such attempts are normally discouraged, and you should probably rethink the whole UI, as the "global" centering of a specific widget, no matter the remaining contents, is probably based on wrong UI assumptions.

    Note that centralWidget() is an existing function of QMainWindow, and it's always better to never overwrite such names. That's why I didn't assign an instance attribute to it and then used the real function getter in resizeEvent(). If you still want an instance attribute, choose another name.