Files

182 lines
4.5 KiB
QML

import QtQuick
import QtQuick.Layouts
import qs.Commons
Item {
id: root
objectName: "NTabView"
property int currentIndex: 0
// Private
property int previousIndex: 0
property bool initialized: false
property bool animating: false
property real animatingHeight: 0
property real transitionGap: Style.marginXL
property real transitionTime: Style.animationNormal
property list<Item> contentItems: []
default property alias content: container.data
clip: true
Layout.fillWidth: true
// During animation, use max height to prevent clipping. Otherwise use current item height.
implicitHeight: animating ? animatingHeight : (contentItems[currentIndex] ? contentItems[currentIndex].implicitHeight : 0)
Item {
id: container
anchors.fill: parent
}
// Set the visible tab to idx without triggering a slide animation.
// Call this BEFORE the bound currentIndex changes so that
// onCurrentIndexChanged sees previousIndex === currentIndex and skips.
function setIndexWithoutAnimation(idx) {
fromXAnim.stop();
fromOpacityAnim.stop();
toXAnim.stop();
toOpacityAnim.stop();
animating = false;
previousIndex = idx;
for (let i = 0; i < contentItems.length; i++) {
if (i === idx) {
contentItems[i].x = 0;
contentItems[i].visible = true;
contentItems[i].opacity = 1.0;
} else {
contentItems[i].x = root.width;
contentItems[i].visible = false;
contentItems[i].opacity = 1.0;
}
}
}
Component.onCompleted: {
_initializeItems();
}
function _initializeItems() {
contentItems = [];
for (let i = 0; i < container.children.length; i++) {
const child = container.children[i];
contentItems.push(child);
child.width = Qt.binding(() => root.width);
if (i === currentIndex) {
child.x = 0;
child.visible = true;
} else {
child.x = root.width;
child.visible = false;
}
}
initialized = true;
}
onCurrentIndexChanged: {
if (!initialized || contentItems.length === 0)
return;
if (previousIndex === currentIndex)
return;
_animateTransition(previousIndex, currentIndex);
previousIndex = currentIndex;
}
function _animateTransition(fromIdx, toIdx) {
// Stop any running animations
fromXAnim.stop();
fromOpacityAnim.stop();
toXAnim.stop();
toOpacityAnim.stop();
// Reset all items to clean state (except target)
for (let i = 0; i < contentItems.length; i++) {
if (i !== toIdx) {
contentItems[i].visible = false;
contentItems[i].opacity = 1.0;
}
}
const fromItem = contentItems[fromIdx];
const toItem = contentItems[toIdx];
const slideLeft = toIdx > fromIdx;
// Set height to max of both items during animation
const fromHeight = fromItem ? fromItem.implicitHeight : 0;
const toHeight = toItem ? toItem.implicitHeight : 0;
animatingHeight = Math.max(fromHeight, toHeight);
animating = true;
// Position outgoing item and make visible for animation
if (fromItem) {
fromItem.visible = true;
fromItem.x = 0;
fromItem.opacity = 1.0;
}
// Position incoming item off-screen (with gap) and set initial opacity
if (toItem) {
toItem.visible = true;
toItem.x = slideLeft ? root.width + transitionGap : -root.width - transitionGap;
toItem.opacity = 0.0;
}
// Animate both items together (with gap)
if (fromItem) {
fromXAnim.target = fromItem;
fromXAnim.to = slideLeft ? -root.width - transitionGap : root.width + transitionGap;
fromOpacityAnim.target = fromItem;
fromXAnim.start();
fromOpacityAnim.start();
}
if (toItem) {
toXAnim.target = toItem;
toOpacityAnim.target = toItem;
toXAnim.start();
toOpacityAnim.start();
}
}
NumberAnimation {
id: fromXAnim
property: "x"
duration: root.transitionTime
easing.type: Easing.OutCubic
onFinished: {
if (target && target !== contentItems[currentIndex]) {
target.visible = false;
target.opacity = 1.0;
}
animating = false;
}
}
NumberAnimation {
id: fromOpacityAnim
property: "opacity"
to: 0.25
duration: root.transitionTime
easing.type: Easing.OutCubic
}
NumberAnimation {
id: toXAnim
property: "x"
to: 0
duration: root.transitionTime
easing.type: Easing.OutCubic
}
NumberAnimation {
id: toOpacityAnim
property: "opacity"
to: 1.0
duration: root.transitionTime
easing.type: Easing.OutCubic
}
}