Skip to content
Advertisement

QScrollArea: Scroll from item to item

Please consider the following code:

from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *


class Gallery(QScrollArea):

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

        self.setFixedWidth(175)
        self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)

        # Set widget and layout
        self._scroll_widget = QWidget()

        self._layout = QVBoxLayout()
        self._layout.setContentsMargins(0, 0, 0, 0)
        self._layout.setSpacing(25)
        self._scroll_widget.setLayout(self._layout)

        self.setWidget(self._scroll_widget)
        self.setWidgetResizable(True)

        # Stretch
        self._layout.addStretch(1)  # Stretch above widgets
        self._layout.addStretch(1)  # Stretch below widgets

        # Initialize ---------------------------------|
        for _ in range(10):
            self.add_item()

    def resizeEvent(self, event: QResizeEvent) -> None:
        super().resizeEvent(event)

        # Calculate Margins --------------------|
        children = self._scroll_widget.findChildren(QLabel)
        first_widget = children[0]
        last_widget = children[-1]

        self._layout.setContentsMargins(
            0,
            int(event.size().height() / 2 - first_widget.size().height() / 2),
            0,
            int(event.size().height() / 2 - last_widget.size().height() / 2)
        )

    def add_item(self) -> None:
        widget = QLabel()
        widget.setStyleSheet('background: #22FF88')
        widget.setFixedSize(90, 125)

        child_count = len(
            self._scroll_widget.findChildren(QLabel)
        )
        self._layout.insertWidget(1 + child_count, widget,
                                  alignment=Qt.AlignCenter)


if __name__ == '__main__':
    app = QApplication([])
    window = Gallery()
    window.show()
    app.exec()

Currently, the margins of the layout are dynamically set, so that, no matter the size of the window, the first and last item are always vertically centered:

Scrollbar containing multiple green rectangles, the first is vertically centered

What I want to achieve now is that whenever I scroll (either by mousewheel or with the arrow-keys, as the scrollbars are disabled) the next widget should take the position in the vertical center, i.e. I want to switch the scrolling mode from a per pixel to a per-widget-basis, so that no matter how far I scroll, I will never land between two widgets.

How can this be done?

I found that QAbstractItemView provides the option the switch the ScrollMode to ScrollPerItem, though I’m not sure if that’s what I need because I was a bit overwhelmed when trying to subclass QAbstractItemView.


Edit:
This shows the delay I noticed after adapting @musicamante‘s answer:

GIF showing a short delay before the widgets are drawn

It’s not really disrupting, but I don’t see it in larger projects, so I suppose something is not working as it should.

Advertisement

Answer

Since most of the features QScrollArea provides are actually going to be ignored, subclassing from it doesn’t give lots of benefits. On the contrary, it could make things much more complex.

Also, using a layout isn’t very useful: the “container” of the widget is not constrained by the scroll area, and all features for size hinting and resizing are almost useless in this case.

A solution could be to just set all items as children of the “scroll area”, that could be even a basic QWidget or QFrame, but for better styling support I chose to use a QAbstractScrollArea.

The trick is to compute the correct position of each child widget, based on its geometry. Note that I’m assuming all widgets have a fixed size, otherwise you might need to use their sizeHint, minimumSizeHint or minimum sizes, and check for their size policies.

Here’s a possible implementation (I changed the item creation in order to correctly show the result):

from random import randrange

class Gallery(QAbstractScrollArea):

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

        self.setFixedWidth(175)
        self.items = []
        self.currentIndex = -1

        for _ in range(10):
            widget = QLabel(str(len(self.items) + 1), 
                self, alignment=Qt.AlignCenter)
            widget.setStyleSheet('background: #{:02x}{:02x}{:02x}'.format(
                randrange(255), randrange(255), randrange(255)))
            widget.setFixedSize(randrange(60, 100), randrange(50, 200))
            self.addItem(widget)

    def addItem(self, widget):
        self.insertItem(len(self.items), widget)

    def insertItem(self, index, widget):
        widget.setParent(self.viewport())
        widget.show()
        self.items.insert(index, widget)
        if len(self.items) == 1:
            self.currentIndex = 0
        self.updateGeometry()

    def setCurrentIndex(self, index):
        if not self.items:
            self.currentIndex = -1
            return
        self.currentIndex = max(0, min(index, len(self.items) - 1))
        self.updateGeometry()

    def stepBy(self, step):
        self.setCurrentIndex(self.currentIndex + step)

    def updateGeometry(self):
        super().updateGeometry()
        if not self.items:
            return
        rects = []
        y = 0
        for i, widget in enumerate(self.items):
            rect = widget.rect()
            rect.moveTop(y)
            rects.append(rect)
            if i == self.currentIndex:
                centerY = rect.center().y()
            y = rect.bottom() + 25
        centerY -= self.height() / 2
        centerX = self.width() / 2
        for widget, rect in zip(self.items, rects):
            widget.setGeometry(rect.translated(centerX - rect.width() / 2, -centerY))

    def sizeHint(self):
        return QSize(175, 400)

    def resizeEvent(self, event: QResizeEvent):
        self.updateGeometry()

    def wheelEvent(self, event):
        if event.angleDelta().y() < 0:
            self.stepBy(1)
        else:
            self.stepBy(-1)
User contributions licensed under: CC BY-SA
3 People found this is helpful
Advertisement