I would like to know how I achieve an effect like overflow: hidden in Qt5.
In essence I want to create an infinite scroll Area similar to QScrollArea for a special case but am unable to archive this using setStyleSheet
my class QInfiniteScrollWidget
that is inheriting from QWidget
.
I am trying to display an extremely huge amount (>1e5) of entries from a database. I am fetching only the entries relevant to the displayed area, and have for each of their types (think of them as PDFs, photos, music or videos) an individual widget type that displays them, possibly playing videos or music, cropping or rotating the images, splitting the PDFs. Some of these actions are written back to the database, so the number of items could change. Some change their size during interaction, like cropped images. I want to be able to jump into each area of my database using a scrollbar or scroll entry by entry.
So I wanted to take a QWidget
subclass QInfiniteScrollWidget
, make a 2-3 times larger QWidget
(blue area) visible in a view-port (dotted box) in the QInfiniteScrollWidget
and control the part of the larger widget shown in the view-port by the QScrollBar
. Then I could shuffle children from the top of the larger widget to the bottom (and change what they display in the process) when the value of the scroll bar changes to create an infinite scroll widget.
This is how my current QT-App looks like:
I would like to have it look like a QScrollArea
:
The reason why my current QInfiniteScrollWidget
does not work is that it forces the whole large QWidget
(blue area in the browser image) into the view-port (dotted box in the browser image). I want the large QWidget
to behave similar to the paragraph in the browser and overflow its parent while being hidden outside of the view-port.
Then I could set the position of the large QWidget
relative to the view-port according to the local scroll progress and keep some widgets above and below the dotted box ready to be shown as soon as the user scrolls.
Here is my actual code:
class QInfiniteScrollWidget(QtWidgets.QFrame):
def __init__(self, parent):
super().__init__(parent)
self.setFixedSize(QSize(300, 400))
self.scroll_bar = QtWidgets.QScrollBar(self)
self.widget = QtWidgets.QWidget(self)
self.widget.setStyleSheet("overflow: hidden;")
self.layout = QtWidgets.QHBoxLayout(self)
self.layout.addWidget(self.widget)
self.layout.addWidget(self.scroll_bar)
self.widget_layout = QtWidgets.QVBoxLayout(self.widget)
self.widgets = [QtWidgets.QLabel(self.widget) for _ in range(36)]
for i, widget in enumerate(self.widgets):
widget.setText("Test text %i" % i)
widget.setStyleSheet("border:1px solid gray;")
self.widget_layout.addWidget(widget)
PS: I already tried to use QTableView
and its friends, but they do not work for various reasons. The biggest problem was that my child widgets contain dynamically created controls and triggering each of their connected actions by back-calculating the targeted control from coordinates to that the delegate did draw sounds like an epic undertaking.
PPS: Please excuse the unclear question.
Edit Here the code implementing @Atmos answer:
import sys
import typing
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtCore import QAbstractListModel, QModelIndex, QPoint
from PyQt5.QtGui import QRegion
from PyQt5.QtWidgets import QMainWindow, QApplication, QStyledItemDelegate, QStyleOptionViewItem, QAbstractItemView
class TestWidget(QtWidgets.QFrame):
def __init__(self, row: int, parent):
super().__init__(parent)
self.row = None
self.click_memory = {}
self.label_1 = QtWidgets.QPushButton()
self.label_1.setText("Click Me")
self.label_2 = QtWidgets.QLabel()
self.main_layout = QtWidgets.QVBoxLayout(self)
self.main_layout.addWidget(self.label_1)
self.main_layout.addWidget(self.label_2)
self.label_1.clicked.connect(self.button_clicked)
def update_row(self, row: int) -> None:
self.row = row
self.label_2.setText(("%i Clicked." if self.row in self.click_memory else "%i Not clicked yet.") % row)
def button_clicked(self):
self.click_memory[self.row] = True
self.label_2.setText("%i Clicked." % (self.row or 0))
class TestModel(QAbstractListModel):
def rowCount(self, parent: QModelIndex = ...) -> int:
return 12
def flags(self, index: QModelIndex) -> QtCore.Qt.ItemFlags:
return super().flags(index) | QtCore.Qt.ItemIsEditable
def data(self, index: QModelIndex, role: int = 0) -> typing.Any:
return str(index.row())
class TestDelegate(QStyledItemDelegate):
def __init__(self, parent):
super().__init__(parent)
self.widget = TestWidget(0, None)
def sizeHint(self, option: QStyleOptionViewItem, index: QtCore.QModelIndex) -> QtCore.QSize:
return QtCore.QSize(self.widget.sizeHint())
def paint(self, painter: QtGui.QPainter, option: QStyleOptionViewItem, index: QtCore.QModelIndex):
painter.save()
self.widget.update_row(index.row())
self.widget.resize(option.rect.size())
painter.translate(option.rect.topLeft())
self.widget.render(painter, QPoint(), QRegion(), QtWidgets.QWidget.DrawChildren)
painter.restore()
def createEditor(self, parent: QtWidgets.QWidget, option: QStyleOptionViewItem, index: QModelIndex):
widget = self.widget
widget.setFixedSize(self.widget.size())
self.widget.update_row(index.row())
self.widget.setParent(parent)
self.widget = TestWidget(0, None)
self.widget.click_memory = widget.click_memory
return widget
class App(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.main_widget = QtWidgets.QListView(self)
self.main_widget.setModel(TestModel())
self.main_widget.setItemDelegate(TestDelegate(self.main_widget))
self.main_widget.setEditTriggers(QAbstractItemView.DoubleClicked)
self.setCentralWidget(self.main_widget)
self.show()
@classmethod
def start_gui(cls, args):
app = QApplication(args)
ex = App()
sys.exit(app.exec_())
if __name__ == '__main__':
App.start_gui(sys.argv)
Short answer:
If you do not add a widget to the layout of your widget you can move it around as you please, which is effectively an overflow: hidden
, as soon as you move the widget to a position where only a part of the widget is visible.
Be aware that in this case one has to manage anything the layout normally takes care of by ones own source.
Long answer:
Well, right way to the solution is like most of the time to just read more source.
In this case one only need to study how exactly QAbstractScrollArea
and QScrollArea
are implemented, see ref and ref, thanks to Atmo for pointing me to correct classes.
Reading the source files of both classes I found that no layout is applied to the widget that displays the real contents. Instead its size and other properties are managed directly. The QScrollArea
s updateWidgetPosition
method then moves it accordingly to where it should be.
My final test code reads as
class TestWidget(QtWidgets.QFrame):
def __init__(self, row: int, parent):
super().__init__(parent)
self.row = row
self.click_memory = {}
self.label_1 = QtWidgets.QPushButton()
self.label_1.setText("Click Me")
self.label_2 = QtWidgets.QLabel()
self.main_layout = QtWidgets.QVBoxLayout(self)
self.main_layout.addWidget(self.label_1)
self.main_layout.addWidget(self.label_2)
self.label_1.clicked.connect(self.button_clicked)
def update_row(self, row: int) -> None:
self.row = row
self.label_2.setText(("%i Clicked." if self.row in self.click_memory else "%i Not clicked yet.") % row)
def button_clicked(self):
self.click_memory[self.row] = True
self.label_2.setText("%i Clicked." % (self.row or 0))
class QInfiniteScrollWidget(QAbstractScrollArea):
def __init__(self, parent):
super().__init__(parent)
self.global_click_memory = {}
self.widget = QtWidgets.QWidget(self)
self.layout = QtWidgets.QVBoxLayout(self.widget)
self.widgets = [TestWidget(i, self.widget) for i in range(24)]
for i, widget in enumerate(self.widgets):
widget.update_row(i)
widget.click_memory = self.global_click_memory
self.layout.addWidget(widget)
def viewportEvent(self, a0: QtCore.QEvent) -> bool:
area_size = self.viewport().size()
widget_size = self.widget.size()
self.verticalScrollBar().setPageStep(area_size.height() // self.widgets[0].height() * 5)
self.horizontalScrollBar().setPageStep(area_size.width())
self.verticalScrollBar().setRange(0, 5 * int(1e5))
self.horizontalScrollBar().setRange(0, widget_size.width() - area_size.width())
self.updateWidgetPosition()
return True
def scrollContentsBy(self, dx: int, dy: int) -> None:
self.updateWidgetPosition()
def updateWidgetPosition(self):
v_value = self.verticalScrollBar().value() * self.widgets[0].height() / 5
first_widget = int(self.verticalScrollBar().value() / 5 / 24 * 24)
for i, widget in enumerate(self.widgets):
widget.update_row(i + first_widget)
h_value, top_left = self.horizontalScrollBar().value(), self.viewport().rect().topLeft()
self.widget.move(top_left.x() - h_value, top_left.y() - int(v_value) % self.widget.height())
which has many problems like empty areas and not the correct numbers rendered in many places. I leave fixing this to the interested reader, it should only be related to calculating the new position.