# coding:utf-8 from collections import deque from enum import Enum from math import cos, pi, ceil from PySide6.QtCore import QDateTime, Qt, QTimer, QPoint from PySide6.QtGui import QWheelEvent from PySide6.QtWidgets import QApplication, QScrollArea, QAbstractScrollArea class SmoothScroll: """ Scroll smoothly """ def __init__(self, widget: QScrollArea, orient=Qt.Vertical): """ Parameters ---------- widget: QScrollArea scroll area to scroll smoothly orient: Orientation scroll orientation """ self.widget = widget self.orient = orient self.fps = 60 self.duration = 400 self.stepsTotal = 0 self.stepRatio = 1.5 self.acceleration = 1 self.lastWheelEvent = None self.scrollStamps = deque() self.stepsLeftQueue = deque() self.smoothMoveTimer = QTimer(widget) self.smoothMode = SmoothMode(SmoothMode.LINEAR) self.smoothMoveTimer.timeout.connect(self.__smoothMove) def setSmoothMode(self, smoothMode): """ set smooth mode """ self.smoothMode = smoothMode def wheelEvent(self, e): # only process the wheel events triggered by mouse, fixes issue #75 delta = e.angleDelta().y() if e.angleDelta().y() != 0 else e.angleDelta().x() if self.smoothMode == SmoothMode.NO_SMOOTH or abs(delta) % 120 != 0: QAbstractScrollArea.wheelEvent(self.widget, e) return # push current time to queque now = QDateTime.currentDateTime().toMSecsSinceEpoch() self.scrollStamps.append(now) while now - self.scrollStamps[0] > 500: self.scrollStamps.popleft() # adjust the acceration ratio based on unprocessed events accerationRatio = min(len(self.scrollStamps) / 15, 1) self.lastWheelPos = e.position() self.lastWheelGlobalPos = e.globalPosition() # get the number of steps self.stepsTotal = self.fps * self.duration / 1000 # get the moving distance corresponding to each event delta = delta* self.stepRatio if self.acceleration > 0: delta += delta * self.acceleration * accerationRatio # form a list of moving distances and steps, and insert it into the queue for processing. self.stepsLeftQueue.append([delta, self.stepsTotal]) # overflow time of timer: 1000ms/frames self.smoothMoveTimer.start(int(1000 / self.fps)) def __smoothMove(self): """ scroll smoothly when timer time out """ totalDelta = 0 # Calculate the scrolling distance of all unprocessed events, # the timer will reduce the number of steps by 1 each time it overflows. for i in self.stepsLeftQueue: totalDelta += self.__subDelta(i[0], i[1]) i[1] -= 1 # If the event has been processed, move it out of the queue while self.stepsLeftQueue and self.stepsLeftQueue[0][1] == 0: self.stepsLeftQueue.popleft() # construct wheel event if self.orient == Qt.Vertical: pixelDelta = QPoint(round(totalDelta), 0) bar = self.widget.verticalScrollBar() else: pixelDelta = QPoint(0, round(totalDelta)) bar = self.widget.horizontalScrollBar() e = QWheelEvent( self.lastWheelPos, self.lastWheelGlobalPos, pixelDelta, QPoint(round(totalDelta), 0), Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.ScrollBegin, False, ) # send wheel event to app QApplication.sendEvent(bar, e) # stop scrolling if the queque is empty if not self.stepsLeftQueue: self.smoothMoveTimer.stop() def __subDelta(self, delta, stepsLeft): """ get the interpolation for each step """ m = self.stepsTotal / 2 x = abs(self.stepsTotal - stepsLeft - m) res = 0 if self.smoothMode == SmoothMode.NO_SMOOTH: res = 0 elif self.smoothMode == SmoothMode.CONSTANT: res = delta / self.stepsTotal elif self.smoothMode == SmoothMode.LINEAR: res = 2 * delta / self.stepsTotal * (m - x) / m elif self.smoothMode == SmoothMode.QUADRATI: res = 3 / 4 / m * (1 - x * x / m / m) * delta elif self.smoothMode == SmoothMode.COSINE: res = (cos(x * pi / m) + 1) / (2 * m) * delta return res class SmoothMode(Enum): """ Smooth mode """ NO_SMOOTH = 0 CONSTANT = 1 LINEAR = 2 QUADRATI = 3 COSINE = 4