간단한 데스크톱용 프로그램은 저한테 맞춰서 커스텀해서 개발해서 사용중입니다.
하지만 내가 만들어서 쓰면 느끼는 단점은, 디자인이 부실하고, 애니메이션 효과가 없기 때문에
사용할 때 조금 사용할 맛이 안난다는 느낌이 있습니다.
보통 데스크톱용 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()없이
반복갱신하면 더 부드럽게 표현할 수 있습니다.