Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions examples/navigation/navigation_indicator_animation/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# coding:utf-8
import sys
from pathlib import Path
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QFrame, QStackedWidget, QHBoxLayout

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))

from qfluentwidgets import (NavigationInterface, NavigationItemPosition, SubtitleLabel,
setFont, NavigationItemHeader, FluentIcon as FIF)
from qframelesswindow import FramelessWindow, StandardTitleBar


class Widget(QFrame):

def __init__(self, text: str, parent=None):
super().__init__(parent=parent)
self.label = SubtitleLabel(text, self)
self.hBoxLayout = QHBoxLayout(self)

setFont(self.label, 24)
self.label.setAlignment(Qt.AlignCenter)
self.hBoxLayout.addWidget(self.label, 1, Qt.AlignCenter)
self.setObjectName(text.replace(' ', '-'))


class Window(FramelessWindow):

def __init__(self):
super().__init__()
self.setTitleBar(StandardTitleBar(self))
self.setWindowTitle('Navigation Indicator Animation')

self.hBoxLayout = QHBoxLayout(self)
self.navigationInterface = NavigationInterface(self, showMenuButton=True)
self.stackWidget = QStackedWidget(self)

# create sub interface
self.homeInterface = Widget('Home Interface', self)
self.searchInterface = Widget('Search Interface', self)
self.musicInterface = Widget('Music Interface', self)
self.videoInterface = Widget('Video Interface', self)
self.albumInterface = Widget('Album Interface', self)
self.albumInterface1 = Widget('Album 1 Interface', self)
self.albumInterface2 = Widget('Album 2 Interface', self)
self.albumInterface1_1 = Widget('Album 1-1 Interface', self)
self.folderInterface = Widget('Folder Interface', self)
self.settingInterface = Widget('Setting Interface', self)

# initialize layout
self.initLayout()

# add items to navigation interface
self.initNavigation()

self.initWindow()

def initLayout(self):
self.hBoxLayout.setSpacing(0)
self.hBoxLayout.setContentsMargins(0, self.titleBar.height(), 0, 0)
self.hBoxLayout.addWidget(self.navigationInterface)
self.hBoxLayout.addWidget(self.stackWidget)
self.hBoxLayout.setStretchFactor(self.stackWidget, 1)

def initNavigation(self):
# When vertical distance between items exceeds this threshold, CrossFade animation will be used
self.navigationInterface.panel.setCrossFadeDistanceThreshold(200)

# Set to False to disable smooth animations and instantly show indicator
self.navigationInterface.panel.indicator.setIndicatorAnimationEnabled(True)

# add items to top
self.addSubInterface(self.homeInterface, FIF.HOME, 'Home')
self.addSubInterface(self.searchInterface, FIF.SEARCH, 'Search')
self.addSubInterface(self.musicInterface, FIF.MUSIC, 'Music library')
self.addSubInterface(self.videoInterface, FIF.VIDEO, 'Video library')

self.navigationInterface.addSeparator()

# add navigation header
self.navigationInterface.addWidget(
routeKey='Header',
widget=NavigationItemHeader('Library'),
position=NavigationItemPosition.SCROLL
)

# add items to scroll area with tree menu
self.addSubInterface(self.albumInterface, FIF.ALBUM, 'Albums', NavigationItemPosition.SCROLL)
self.addSubInterface(self.albumInterface1, FIF.ALBUM, 'Album 1', parent=self.albumInterface)
self.addSubInterface(self.albumInterface1_1, FIF.ALBUM, 'Album 1.1', parent=self.albumInterface1)
self.addSubInterface(self.albumInterface2, FIF.ALBUM, 'Album 2', parent=self.albumInterface)

self.addSubInterface(self.folderInterface, FIF.FOLDER, 'Folder library', NavigationItemPosition.SCROLL)

# add items to bottom
self.addSubInterface(self.settingInterface, FIF.SETTING, 'Settings', NavigationItemPosition.BOTTOM)

self.navigationInterface.setCurrentItem(self.homeInterface.objectName())

def initWindow(self):
self.resize(900, 600)
desktop = QApplication.desktop().availableGeometry()
w, h = desktop.width(), desktop.height()
self.move(w//2 - self.width()//2, h//2 - self.height()//2)

def addSubInterface(self, interface, icon, text: str, position=NavigationItemPosition.TOP, parent=None):
""" add sub interface """
self.stackWidget.addWidget(interface)
self.navigationInterface.addItem(
routeKey=interface.objectName(),
icon=icon,
text=text,
onClick=lambda: self.switchTo(interface),
position=position,
tooltip=text,
parentRouteKey=parent.objectName() if parent else None
)

def switchTo(self, widget):
self.stackWidget.setCurrentWidget(widget)


if __name__ == '__main__':
app = QApplication(sys.argv)
w = Window()
w.show()
app.exec_()
1 change: 1 addition & 0 deletions qfluentwidgets/components/navigation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .navigation_widget import (NavigationWidget, NavigationPushButton, NavigationSeparator, NavigationToolButton,
NavigationTreeWidget, NavigationTreeWidgetBase, NavigationAvatarWidget, NavigationItemHeader)
from .navigation_indicator import NavigationIndicator
from .navigation_panel import NavigationPanel, NavigationItemPosition, NavigationDisplayMode
from .navigation_interface import NavigationInterface
from .navigation_bar import NavigationBarPushButton, NavigationBar
Expand Down
233 changes: 233 additions & 0 deletions qfluentwidgets/components/navigation/navigation_indicator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# coding:utf-8
from PyQt5.QtCore import (Qt, QPropertyAnimation, QRectF, QEasingCurve, pyqtProperty,
QPointF, QTimer, QAbstractAnimation, QParallelAnimationGroup,
QSequentialAnimationGroup, QSize)
from PyQt5.QtGui import QColor, QPainter, QBrush
from PyQt5.QtWidgets import QWidget

from ...common.style_sheet import themeColor, isDarkTheme
from ...common.color import autoFallbackThemeColor


class NavigationIndicator(QWidget):
""" Navigation indicator """

def __init__(self, parent=None):
super().__init__(parent)
self.resize(3, 16)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setAttribute(Qt.WA_TranslucentBackground)
self.hide()

self._opacity = 0.0
self._geometry = QRectF(0, 0, 3, 16)
self._isIndicatorAnimationEnabled = True
self.lightColor = themeColor()
self.darkColor = themeColor()
self.isHorizontal = False

self.aniGroup = QParallelAnimationGroup(self)
self.aniGroup.finished.connect(self.hide)

def setIndicatorColor(self, light, dark):
self.lightColor = QColor(light)
self.darkColor = QColor(dark)
self.update()

def setIndicatorAnimationEnabled(self, enabled: bool):
""" set whether indicator animation is enabled """
self._isIndicatorAnimationEnabled = enabled

def getOpacity(self):
return self._opacity

def setOpacity(self, opacity):
self._opacity = opacity
self.update()

def getGeometry(self):
return QRectF(self.geometry())

def setGeometry(self, geometry: QRectF):
self._geometry = geometry
super().setGeometry(geometry.toRect())

def getPos(self):
return QPointF(super().pos())

def setPos(self, pos: QPointF):
self._geometry.moveTopLeft(pos)
self.move(pos.toPoint())

def getLength(self):
return self.width() if self.isHorizontal else self.height()

def setLength(self, length):
if self.isHorizontal:
self._geometry.setWidth(length)
self.resize(int(length), self.height())
else:
self._geometry.setHeight(length)
self.resize(self.width(), int(length))

opacity = pyqtProperty(float, getOpacity, setOpacity)
geometry = pyqtProperty(QRectF, getGeometry, setGeometry)
pos = pyqtProperty(QPointF, getPos, setPos)
length = pyqtProperty(float, getLength, setLength)

def paintEvent(self, e):
painter = QPainter(self)
painter.setRenderHints(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setOpacity(self._opacity)

color = autoFallbackThemeColor(self.lightColor, self.darkColor)
painter.setBrush(color)

# Draw filling the widget
painter.drawRoundedRect(self.rect(), 1.5, 1.5)

def stopAnimation(self):
self.aniGroup.stop()
self.aniGroup.clear()

def animate(self, startRect: QRectF, endRect: QRectF, isHorizontal=False, useCrossFade=False):
self.stopAnimation()
self.isHorizontal = isHorizontal

# If animation is disabled, directly set final state
if not self._isIndicatorAnimationEnabled:
self.setGeometry(endRect)
self.setOpacity(1)
self.show()
return

# Determine if same level
if isHorizontal:
sameLevel = abs(startRect.y() - endRect.y()) < 1
dim = startRect.width()
start = startRect.x()
end = endRect.x()
else:
sameLevel = abs(startRect.x() - endRect.x()) < 1
dim = startRect.height()
start = startRect.y()
end = endRect.y()

if sameLevel and not useCrossFade:
self._startSlideAnimation(startRect, endRect, start, end, dim)
else:
self._startCrossFadeAnimation(startRect, endRect)

def _startSlideAnimation(self, startRect, endRect, from_, to, dimension):
""" Animate the indicator using WinUI 3 squash and stretch logic

Key algorithm:
1. middleScale = abs(to - from) / dimension + (from < to ? endScale : beginScale)
2. At 33% progress, the indicator stretches to cover the distance between two items
"""
self.setGeometry(startRect)
self.setOpacity(1)
self.show()

dist = abs(to - from_)
midLength = dist + dimension
isForward = to > from_

s1 = QSequentialAnimationGroup()
s2 = QSequentialAnimationGroup()

posAni1 = QPropertyAnimation(self, b"pos")
posAni1.setDuration(200)
posAni2 = QPropertyAnimation(self, b"pos")
posAni2.setDuration(400)

lenAni1 = QPropertyAnimation(self, b"length")
lenAni1.setDuration(200)
lenAni2 = QPropertyAnimation(self, b"length")
lenAni2.setDuration(400)

startPos = startRect.topLeft()
endPos = endRect.topLeft()

if isForward:
# 0->0.33: Head moves to B (len increases), Pos stays at A
posAni1.setStartValue(startPos)
posAni1.setEndValue(startPos)
lenAni1.setStartValue(dimension)
lenAni1.setEndValue(midLength)

# 0.33->1.0: Tail moves to B (len decreases), Pos moves to B
posAni2.setStartValue(startPos)
posAni2.setEndValue(endPos)
lenAni2.setStartValue(midLength)
lenAni2.setEndValue(dimension)

else:
# 0->0.33: Head moves to A (len increases), Pos moves to B
# Note: For backward, "Head" is top. Top moves from A to B.
posAni1.setStartValue(startPos)
posAni1.setEndValue(endPos)
lenAni1.setStartValue(dimension)
lenAni1.setEndValue(midLength)

# 0.33->1.0: Tail moves to B (len decreases), Pos stays at B
posAni2.setStartValue(endPos)
posAni2.setEndValue(endPos)
lenAni2.setStartValue(midLength)
lenAni2.setEndValue(dimension)

# Curves
curve1 = QEasingCurve(QEasingCurve.BezierSpline)
curve1.addCubicBezierSegment(QPointF(0.9, 0.1), QPointF(1.0, 0.2), QPointF(1.0, 1.0))

curve2 = QEasingCurve(QEasingCurve.BezierSpline)
curve2.addCubicBezierSegment(QPointF(0.1, 0.9), QPointF(0.2, 1.0), QPointF(1.0, 1.0))

posAni1.setEasingCurve(curve1)
lenAni1.setEasingCurve(curve1)
posAni2.setEasingCurve(curve2)
lenAni2.setEasingCurve(curve2)

s1.addAnimation(posAni1)
s1.addAnimation(posAni2)
s2.addAnimation(lenAni1)
s2.addAnimation(lenAni2)

self.aniGroup.addAnimation(s1)
self.aniGroup.addAnimation(s2)
self.aniGroup.start()

def _startCrossFadeAnimation(self, startRect, endRect):
self.setGeometry(endRect)
self.setOpacity(1)
self.show()

# Determine growth direction based on relative position
# WinUI 3 logic: Grow from top/bottom edge depending on direction
isNextBelow = endRect.y() > startRect.y() if not self.isHorizontal else endRect.x() > startRect.x()

if self.isHorizontal:
dim = endRect.width()
startGeo = QRectF(endRect.x() + (0 if isNextBelow else dim), endRect.y(), 0, endRect.height())
else:
dim = endRect.height()
startGeo = QRectF(endRect.x(), endRect.y() + (0 if isNextBelow else dim), endRect.width(), 0)

self.setGeometry(startGeo)

lenAni = QPropertyAnimation(self, b"length")
lenAni.setDuration(600)
lenAni.setStartValue(0)
lenAni.setEndValue(dim)
lenAni.setEasingCurve(QEasingCurve.OutQuint)

posAni = QPropertyAnimation(self, b"pos")
posAni.setDuration(600)
posAni.setStartValue(startGeo.topLeft())
posAni.setEndValue(endRect.topLeft())
posAni.setEasingCurve(QEasingCurve.OutQuint)

self.aniGroup.addAnimation(lenAni)
self.aniGroup.addAnimation(posAni)
self.aniGroup.start()
Loading