[Python] Pyqt로 스와이프 애니메이션 효과 넣기

hwhyeons·2023년 12월 28일

간단한 데스크톱용 프로그램은 저한테 맞춰서 커스텀해서 개발해서 사용중입니다.

하지만 내가 만들어서 쓰면 느끼는 단점은, 디자인이 부실하고, 애니메이션 효과가 없기 때문에

사용할 때 조금 사용할 맛이 안난다는 느낌이 있습니다.

보통 데스크톱용 GUI 라이브러리 들은 애니메이션 효과를 따로 설정해주지 않으면

효과가 없거나, 아니면 기본적인 애니메이션 효과를 적용하면 부자연스럽거나 부드럽지 않습니다.


Python으로 프로젝트를 몇번 진행하고 나서, Python 외부 라이브러리의 강력함을 알고나서

Python을 거의 사용하고 있습니다. 따라서 요즘은 제 개인적인 용도의 데스크톱 프로그램도

파이썬을 이용해서 만들고 있습니다.


파이썬에서 주로 사용하는 GUI 라이브러리 들은 보통 Tkinter나 PyQT, PySide를 사용합니다.

처음에는 tkinter를 사용했는데, PyQt나 PySide를 이용하면 전용 GUI 툴을 통해

마우스로 컴포넌트를 배치하고 사용할 수 있어서 개인적으로 Pyqt를 애용중입니다.

하지만 Pyqt는 라이선스 때문에 함부로 상업적 프로그램을 만들 수는 없지만,

어차피 저는 데스크톱용 프로그램은 제 개인적인 용도로만 사용하기 때문에 PyQt를 사용 중입니다.


qt는 기본적으로 여러가지 애니메이션 효과를 제공해주고 이걸 불러서 사용할 수 있습니다.

하지만 120hz 주사율에 익숙해진 저에게는 기본 효과를 그대로 불러서 이용하면

버벅이는 느낌이나 부자연스럽다는 느낌을 지울 수가 없었습니다.

Qt는 QAnimationGroup같은 클래스 등으로 애니메이션 상태 관리를 도와주지만, 애니메이션 효과를

확실하게 컨트롤 하기가 어려워서, QTimer을 이용해서 갱신 주기를 직접 조정해서 훨씬 더 자연스럽고 부드럽게

움직일 수 있게 구현해봤습니다.


처음에 기반으로 참고한 코드는 링크이며,

하지만 이 답변에 나온 방식은 작동은 잘 되어도 탭간 전환이 일어날 때 부드럽게 전환되는 느낌은

아니였습니다. 그래서 코드 일부를 수정하여 더 자연스럽게 작동할 수 있게 직접 수정했습니다.

물론 이렇게 직접 수정해놓으면 나중에 다른 곳에 적용해서 사용할 때 많이 까다로워지겠지만,

그래도 부드러운 효과를 포기할 수가 없어서 직접 해봤습니다..!


먼저 gif로 확인해보면

요런 느낌인데, gif다 보니 프레임이 높지 않아서 사진으로만 보면

전혀 부드럽지 않아보이는데, 직접 코드로 실행시켜보면 아주 자연스럽고

부드럽게 작동합니다.

또한, 맨 앞이나 맨 끝 탭애 도착했을 때 다음 또는 이전으로

넘길 때는 끝에 도달했음을 알려주듯이 살짝 튕기는 효과까지

구현했습니다.


"""
참고 링크
https://stackoverflow.com/questions/25644026/setting-word-wrap-on-qlabel-breaks-size-constrains-for-the-window
"""

import random
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QEasingCurve, QTimer, QElapsedTimer, QRect


class SlidingStackedWidget(QtWidgets.QStackedWidget):
    def __init__(self, parent=None):
        super(SlidingStackedWidget, self).__init__(parent)

        self.duration = 400 # 애니메이션 지속 시간 (단위 : ms)
        self.duration_bounce = 150 # 끝에 도달해서 살짝 튀는 애니메이션 지속 시간 (단위 : ms)
        self.m_direction = 'horizontal' # 애니메이션 진행 방향 (horizontal, vertical, diagonal) / QtCore.Qt.Horizontal
        self.animationType = QtCore.QEasingCurve.InOutQuad # Incurve, OutCurve, InOutQuad ...
        self.animation_bounce = QtCore.QEasingCurve.OutBounce # Incurve, OutCurve, InOutQuad ...
        self.circular = False  # 끝에 도달하면 처음으로 돌아가는지 여부

        # self.m_pnow = QtCore.QPoint(0, 0)
        self.m_active = False # active가 True면 애니메이션 진행중 -> 버튼 눌러도 동작 X
        self.move_right = False
        self.move_up = False
        self.is_bounce = False



    @QtCore.pyqtSlot()
    def slideInPrev(self):
        now = self.currentIndex()
        self.slideInIdx(now - 1)


    @QtCore.pyqtSlot()
    def slideInNext(self):
        now = self.currentIndex()
        self.slideInIdx(now + 1)


    def slideInIdx(self, idx):
        idx = idx % self.count()
        self.slideInWgt(self.widget(idx))


    def updateAnimation(self):
        elapsed = self.elapsedTimer.elapsed()
        duration = self.duration  # Total duration in milliseconds
        frame_width = self.frameRect().width()
        frame_height = self.frameRect().height()
        if elapsed > duration:
            self.timer.stop()
            self.m_active = False
            self.setCurrentIndex(self._next_idx)
            if self._now_idx != self._next_idx:
                self.widget(self._now_idx).hide()
            self.is_bounce = False
            return

        progress = elapsed / duration
        easedProgress = self.easingCurve.valueForProgress(progress)

        width_const = frame_width if not self.is_bounce else frame_width//4
        height_const = frame_height if not self.is_bounce else frame_height//4
        easedProgress_cal = None # bounce의 경우 추가 계산이 필요함
        if self.is_bounce and easedProgress >= 0.5:
            easedProgress_cal = 1 - easedProgress
        else:
            easedProgress_cal = easedProgress

        currentX = int(width_const*easedProgress_cal) if self.m_direction == 'horizontal' or self.m_direction == 'diagonal' else self.offset_X
        currentY = int(height_const*easedProgress_cal) if self.m_direction == 'vertical' or self.m_direction == 'diagonal' else self.offset_Y

        if self.m_direction == 'vertical':
            x_pos_next_move = currentX
            x_pos_now_move = currentX
        else:
            x_pos_next_move = frame_width - currentX if self.move_right else -frame_width + currentX
            x_pos_now_move = -currentX if self.move_right else currentX

        if self.m_direction == 'horizontal':
            y_pos_next_move = currentY
            y_pos_now_move = currentY
        else:
            y_pos_next_move = frame_height - currentY if self.move_up else -frame_height+currentY
            y_pos_now_move = -currentY if self.move_up else currentY

        if self._next_idx != self._now_idx:
            self.widget(self._next_idx).move(QtCore.QPoint(x_pos_next_move, y_pos_next_move))
            self.widget(self._next_idx).show()
            self.widget(self._next_idx).raise_()
        self.widget(self._now_idx).move(QtCore.QPoint(x_pos_now_move, y_pos_now_move))
        self.widget(self._now_idx).show()
        self.widget(self._now_idx).raise_()



    def slideInWgt(self, new_widget):
        # print("self.m_active : ", self.m_active)
        if self.m_active:
            return

        self.m_active = True

        _now = self.currentIndex()
        _next = self.indexOf(new_widget)


        if _now == _next:
            self.m_active = False
            return

        if abs(_now-_next) == self.count()-1 and not self.circular:
            self.is_bounce = True
            _next = _now
        # if _now == _next and not self.circular:
        #     self.is_bounce = True
        self._now_idx = _now
        self._next_idx = _next
        animation_type = self.animationType if not self.is_bounce else self.animation_bounce
        self.easingCurve = QEasingCurve(animation_type)
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.updateAnimation)
        self.elapsedTimer = QElapsedTimer()

        offset_X, offset_Y = self.frameRect().width(), self.frameRect().height()
        self.widget(_next).setGeometry(self.frameRect())

        if self.m_direction == 'horizontal':
            if _now < _next:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_right = False
                else:
                    self.move_right = True
            elif _now == _next and self.is_bounce and _now == 0:
                self.move_right = False
            elif _now == _next and self.is_bounce and _now == (self.count() - 1):
                self.move_right = True
            else:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_right = True
                else:
                    self.move_right = False
            offset_X = -offset_X
            offset_Y = 0
        elif self.m_direction == 'vertical':
            if _now < _next:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_up = False
                else:
                    self.move_up = True
            elif _now == _next and self.is_bounce and _now == 0:
                self.move_up = False
            elif _now == _next and self.is_bounce and _now == (self.count() - 1):
                self.move_up = True
            else:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_up = True
                else:
                    self.move_up = False
            offset_X, offset_Y = 0, -offset_Y
        elif self.m_direction == 'diagonal':
            if _now < _next:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_right = False
                    self.move_up = False
                else:
                    self.move_right = True
                    self.move_up = True
            elif _now == _next and self.is_bounce and _now == 0:
                self.move_right = False
                self.move_up = False
            elif _now == _next and self.is_bounce and _now == (self.count() - 1):
                self.move_right = True
                self.move_up = True
            else:
                if self.circular and abs(_next-_now) == self.count()-1:
                    self.move_right = True
                    self.move_up = True
                else:
                    self.move_right = False
                    self.move_up = False
            offset_X, offset_Y = -offset_X, -offset_Y
        else:
            raise Exception("잘못된 방향 값")

        self.offset_X = offset_X
        self.offset_Y = offset_Y

        self.elapsedTimer.start()
        self.timer.start(5)  # Update every 10 milliseconds



class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)

        slidingStacked = SlidingStackedWidget()
        for i in range(4):
            label = QtWidgets.QLabel(
                f"label : {i} ", alignment=QtCore.Qt.AlignCenter
            )
            color = QtGui.QColor(*random.sample(range(255), 3))
            label.setStyleSheet(
                "QLabel{ background-color: %s; color : white; font: 40pt}"
                % (color.name(),)
            )
            slidingStacked.addWidget(label)

        button_prev = QtWidgets.QPushButton(
            "Previous", pressed=slidingStacked.slideInPrev
        )
        button_next = QtWidgets.QPushButton(
            "Next", pressed=slidingStacked.slideInNext
        )


        hlay = QtWidgets.QHBoxLayout()
        hlay.addWidget(button_prev)
        hlay.addWidget(button_next)

        central_widget = QtWidgets.QWidget()
        self.setCentralWidget(central_widget)
        lay = QtWidgets.QVBoxLayout(central_widget)
        lay.addLayout(hlay)
        lay.addWidget(slidingStacked)


if __name__ == "__main__":
    import sys

    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.resize(640, 480)
    w.show()
    sys.exit(app.exec_())

(아무런 설계나 계획 없이 해보고 수정하고 기능 늘리고 하다보니 if 분기가 매우 더럽습니다..

양해 부탁드립니다)


self.m_direction을 vertical이나 digonal로 수정하면

상하 방향 또는 대각선 방향으로도 사용이 가능합니다.

사용한 효과는 탭간 전환에는 InOutQuad 곡선,

그리고 맨 앞과 맨 뒤 탭에서 막힐 떄 튕기는 이펙트는

OutBounce를 사용하였습니다.

Qt 공식 문서 페이지에서 곡선에 대한 설명이 있으니,

원하는 곡선을 선택해서 사용하시면 됩니다.

(링크)

self.timer.start(5) 에서 5값을 늘리면 컴포넌트 갱신 주기가 길어지면서,

모니터 주사율을 더 낮게 설정했을 때 처럼 약간 덜 부드럽게도 사용이 가능합니다.


참고로, 위 방식을 사용하더라도 화면에 표현할 내용이 많은 경우 버벅임이 발생할 수 있습니다.
이런 경우에는 예를들어 QTimer을 5ms마다 액션이 수행되게 해놨음에도 다른 작업으로 10ms마다
한번씩 발동되는 경우 버벅이는 것처럼 보일 수 있습니다.

이런 경우 updateAnimation()을 QTimer을 통해 호출하지말고, while문으로 sleep()없이
반복갱신하면 더 부드럽게 표현할 수 있습니다.

0개의 댓글