Python – PyQt – Custom scrolling using QListWidget

PyQt – Custom scrolling using QListWidget… here is a solution to the problem.

PyQt – Custom scrolling using QListWidget

I’m trying to figure out a way to customize the QListWidget’s scroll bars so that the scroll bars are above and below the QListWidget instead of the normal vertical and horizontal scroll bars.

If you don’t understand what I mean, please see my example below.
In the example below, I’m using QPushButtons and QTimers to control scrolling instead of scroll bars, but what I’m looking for is scroll bars like when QMenu enables menu scrolling.

If this is not an option, I would like to know if there is a scrollbar signal or something I can try to use to know when the scrollbar activates normally? This way I can show/hide the button as needed. Thank you.

import sys
from PyQt5.QtCore import pyqtSignal, QTimer, Qt
from PyQt5.QtGui import QPainter
from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, \
    QApplication, QStyle, QListWidget, QStyleOptionButton, QListWidgetItem

class UpBtn(QPushButton):
    mouseHover = pyqtSignal()
    def __init__(self):
        QPushButton.__init__(self)
        self.setMouseTracking(True)
        self.timer = QTimer()

def paintEvent(self, event):
        painter = QPainter()
        painter.begin(self)
        opt = QStyleOptionButton()
        self.initStyleOption(opt)
        self.style().drawControl(QStyle.CE_ScrollBarSubLine, opt, painter, self)
        painter.end()

def startScroll(self):
        self.mouseHover.emit()

def enterEvent(self, event):
        self.timer.timeout.connect(self.startScroll)
        self.timer.start(120)

def leaveEvent(self, event):
        self.timer.stop()

class DwnBtn(QPushButton):
    mouseHover = pyqtSignal()
    def __init__(self):
        QPushButton.__init__(self)
        self.setMouseTracking(True)
        self.timer = QTimer()

def paintEvent(self, event):
        painter = QPainter()
        painter.begin(self)
        opt = QStyleOptionButton()
        self.initStyleOption(opt)
        self.style().drawControl(QStyle.CE_ScrollBarAddLine, opt, painter, self)
        painter.end()

def startScroll(self):
        self.mouseHover.emit()

def enterEvent(self, event):
        self.timer.timeout.connect(self.startScroll)
        self.timer.start(120)

def leaveEvent(self, event):
        self.timer.stop()

class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()
        self.layout = QVBoxLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.layout.setSpacing(0)
        self.upBtn = UpBtn()
        self.upBtn.setFixedWidth(230)
        self.layout.addWidget(self.upBtn)

self.listWidget = QListWidget()
        self.listWidget.setFixedWidth(230)
        self.listWidget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.listWidget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.layout.addWidget(self.listWidget)

self.downBtn = DwnBtn()
        self.downBtn.setFixedWidth(230)
        self.layout.addWidget(self.downBtn)

self.setLayout(self.layout)
        self.upBtn.clicked.connect(self.upBtnClicked)
        self.upBtn.mouseHover.connect(self.upBtnClicked)
        self.downBtn.clicked.connect(self.downBtnClicked)
        self.downBtn.mouseHover.connect(self.downBtnClicked)

for i in range(100):
            item = QListWidgetItem()
            item.setText("list item " + str(i))
            self.listWidget.addItem(item)

def upBtnClicked(self):
        cur = self.listWidget.currentRow()
        self.listWidget.setCurrentRow(cur - 1)

def downBtnClicked(self):
        cur = self.listWidget.currentRow()
        self.listWidget.setCurrentRow(cur + 1)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    sys.exit(app.exec_())

Edit:
This is an example image I’m talking about. This is a scrollable QMenu.

enter image description here

Edit:
Scrollable QMenu code.
Uncomment the comment section to get a fixed size in the image. Typically, Qmenu scrolling only works when a menu item exceeds screen height. I’m just looking for top and bottom hover style scrolling, but to use in QListWidget.

import sys
from PyQt5.QtCore import QPoint, QEvent
from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, \
    QApplication, QAction, QMenu, QProxyStyle, QStyle

class MyMenu(QMenu):
    def event(self, event):
        if event.type() == QEvent.Show:
            self.move(self.parent().mapToGlobal(QPoint(-108, 0)))
        return super(MyMenu, self).event(event)

# class CustomStyle(QProxyStyle):
#     def pixelMetric(self, QStyle_PixelMetric, option=None, widget=None):
#         if QStyle_PixelMetric == QStyle.PM_MenuScrollerHeight:
#             return 15
#         if QStyle_PixelMetric == QStyle.PM_MenuDesktopFrameWidth:
#             return 290
#         else:
#             return QProxyStyle.pixelMetric(self, QStyle_PixelMetric, option, widget)

class MainWindow(QWidget):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.layout = QVBoxLayout()
        self.btn = QPushButton("Button")
        self.btn.setFixedHeight(30)
        self.btn.setFixedWidth(100)
        self.myMenu = MyMenu("Menu", self.btn)
        self.btn.setMenu(self.myMenu)
        self.layout.addWidget(self.btn)
        self.setLayout(self.layout)
        menus = []
        for _ in range(5):
            myMenus = QMenu("Menu"+str(_+1), self.btn)
            # myMenus.setFixedHeight(120)
            myMenus.setStyleSheet("QMenu{menu-scrollable: 1; }")
            menus.append(myMenus)
        for i in menus:
            self.btn.menu().addMenu(i)
            for item in range(100):
                action = QAction("item" + str(item), self)
                i.addAction(action)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    # app.setStyle(CustomStyle())
    w = MainWindow()
    w.show()
    app.exec_()

Solution

The idea is to get the row of the upper and lower elements that determine whether the button is hidden or not, for this we use the method itemAt () to return the item for the given geometric coordinates. On the other hand, I improved the calculation that must be performed every time they change the number of items in QListView because we use signals from the internal model.

import sys
from PyQt5 import QtCore, QtGui, QtWidgets

class Button(QtWidgets.QPushButton):
    moveSignal = QtCore.pyqtSignal()
    def __init__(self, *args, **kwargs):
        super(Button, self).__init__(*args, **kwargs)
        self.m_timer = QtCore.QTimer(self, interval=120)
        self.m_timer.timeout.connect(self.moveSignal)
        self.setMouseTracking(True)
        self.setFixedHeight(20)

def mouseReleaseEvent(self, e):
        super(Button, self).mousePressEvent(e)
        self.setDown(True)

def enterEvent(self, e):
        self.setDown(True)
        self.m_timer.start()
        super(Button, self).enterEvent(e)

def leaveEvent(self, e):
        self.setDown(False)
        self.m_timer.stop()
        super(Button, self).leaveEvent(e)

class Window(QtWidgets.QWidget):
    def __init__(self):
        super(Window, self).__init__()
        self.setFixedWidth(230)

icon = self.style().standardIcon(QtWidgets.QStyle.SP_ArrowUp)
        self.upBtn = Button(icon=icon)
        self.upBtn.moveSignal.connect(self.moveUp)
        icon = self.style().standardIcon(QtWidgets.QStyle.SP_ArrowDown)
        self.downBtn = Button(icon=icon)
        self.downBtn.moveSignal.connect(self.moveDown)

self.listWidget = QtWidgets.QListWidget()
        self.listWidget.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.listWidget.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)

layout = QtWidgets.QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        layout.addWidget(self.upBtn)
        layout.addWidget(self.listWidget)
        layout.addWidget(self.downBtn)
        self.adjust_buttons()
        self.create_connections()

def create_connections(self):
        self.listWidget.currentItemChanged.connect(self.adjust_buttons)
        model = self.listWidget.model()
        model.rowsInserted.connect(self.adjust_buttons)
        model.rowsRemoved.connect(self.adjust_buttons)
        model.rowsMoved.connect(self.adjust_buttons)
        model.modelReset.connect(self.adjust_buttons)
        model.layoutChanged.connect(self.adjust_buttons)

@QtCore.pyqtSlot()
    def adjust_buttons(self):
        first = self.listWidget.itemAt(QtCore.QPoint())
        r = self.listWidget.row(first)
        self.upBtn.setVisible(r != 0 and  r!= -1)
        last = self.listWidget.itemAt(self.listWidget.viewport().rect().bottomRight())
        r = self.listWidget.row(last)
        self.downBtn.setVisible( r != (self.listWidget.count() -1) and r != -1)

@QtCore.pyqtSlot()
    def moveUp(self):
        ix = self.listWidget.moveCursor(QtWidgets.QAbstractItemView.MoveUp, QtCore.Qt.NoModifier)
        self.listWidget.setCurrentIndex(ix)

@QtCore.pyqtSlot()
    def moveDown(self):
        ix = self.listWidget.moveCursor(QtWidgets.QAbstractItemView.MoveDown, QtCore.Qt.NoModifier)
        self.listWidget.setCurrentIndex(ix)

@QtCore.pyqtSlot(str)
    def add_item(self, text):
        item = QtWidgets.QListWidgetItem(text)
        self.listWidget.addItem(item)

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    window = Window()
    for i in range(100):
        window.add_item("item {}".format(i))
    window.show()
    sys.exit(app.exec_())

Related Problems and Solutions