feat(ui): add tab switch animations and improve responsive layout

- Add AnimateTabSwitch method to configure dialogs with slide/fade animations
- Update responsive layout breakpoint from 550px to 850px
- Improve configure UI layout structure (accent color, rainbow mode positioning)
- Update button stylesheets for horizontal/vertical navigation modes
- Add animation includes (QPropertyAnimation, QGraphicsOpacityEffect, etc.)

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-11-28 16:09:25 +10:00
parent c27533949c
commit 8e9b179b55
7 changed files with 238 additions and 80 deletions

View File

@@ -7,11 +7,16 @@
#include <memory> #include <memory>
#include <QApplication> #include <QApplication>
#include <QButtonGroup> #include <QButtonGroup>
#include <QGraphicsOpacityEffect>
#include <QLabel>
#include <QMessageBox> #include <QMessageBox>
#include <QParallelAnimationGroup>
#include <QPropertyAnimation>
#include <QPushButton> #include <QPushButton>
#include <QScreen> #include <QScreen>
#include <QScrollArea> #include <QScrollArea>
#include <QScrollBar> #include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QString> #include <QString>
#include <QTimer> #include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -106,7 +111,6 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
nav_layout->setSpacing(4); nav_layout->setSpacing(4);
for (QPushButton* button : tab_buttons) { for (QPushButton* button : tab_buttons) {
button->setParent(ui->topButtonWidget); button->setParent(ui->topButtonWidget);
// Buttons are added to a layout in SetUIPositioning
if (button->property("class").toString() == QStringLiteral("tabButton")) { if (button->property("class").toString() == QStringLiteral("tabButton")) {
button->installEventFilter(animation_filter); button->installEventFilter(animation_filter);
} }
@@ -154,9 +158,7 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
ui->stackedWidget->addWidget(CreateScrollArea(applets_tab.get())); ui->stackedWidget->addWidget(CreateScrollArea(applets_tab.get()));
ui->stackedWidget->addWidget(CreateScrollArea(debug_tab_tab.get())); ui->stackedWidget->addWidget(CreateScrollArea(debug_tab_tab.get()));
connect(tab_button_group.get(), qOverload<int>(&QButtonGroup::idClicked), this, [this](int id) { connect(tab_button_group.get(), qOverload<int>(&QButtonGroup::idClicked), this, &ConfigureDialog::AnimateTabSwitch);
ui->stackedWidget->setCurrentIndex(id);
});
connect(ui_tab.get(), &ConfigureUi::themeChanged, this, &ConfigureDialog::UpdateTheme); connect(ui_tab.get(), &ConfigureUi::themeChanged, this, &ConfigureDialog::UpdateTheme);
connect(ui_tab.get(), &ConfigureUi::UIPositioningChanged, this, &ConfigureDialog::SetUIPositioning); connect(ui_tab.get(), &ConfigureUi::UIPositioningChanged, this, &ConfigureDialog::SetUIPositioning);
connect(rainbow_timer, &QTimer::timeout, this, &ConfigureDialog::UpdateTheme); connect(rainbow_timer, &QTimer::timeout, this, &ConfigureDialog::UpdateTheme);
@@ -241,7 +243,6 @@ void ConfigureDialog::SetUIPositioning(const QString& positioning) {
if (positioning == QStringLiteral("Horizontal")) { if (positioning == QStringLiteral("Horizontal")) {
ui->nav_container->hide(); ui->nav_container->hide();
ui->horizontalNavScrollArea->show(); ui->horizontalNavScrollArea->show();
// Remove stretch from vertical layout if it exists
if (v_layout->count() > 0) { if (v_layout->count() > 0) {
if (auto* item = v_layout->itemAt(v_layout->count() - 1); item && item->spacerItem()) { if (auto* item = v_layout->itemAt(v_layout->count() - 1); item && item->spacerItem()) {
v_layout->takeAt(v_layout->count() - 1); v_layout->takeAt(v_layout->count() - 1);
@@ -251,13 +252,12 @@ void ConfigureDialog::SetUIPositioning(const QString& positioning) {
for (QPushButton* button : tab_buttons) { for (QPushButton* button : tab_buttons) {
v_layout->removeWidget(button); v_layout->removeWidget(button);
h_layout->addWidget(button); h_layout->addWidget(button);
button->setStyleSheet(QStringLiteral("text-align: center;")); button->setStyleSheet(QStringLiteral("text-align: left center; padding-left: 15px;"));
} }
h_layout->addStretch(1); h_layout->addStretch(1);
} else { // Vertical } else { // Vertical
ui->horizontalNavScrollArea->hide(); ui->horizontalNavScrollArea->hide();
ui->nav_container->show(); ui->nav_container->show();
// Remove stretch from horizontal layout if it exists
if (h_layout->count() > 0) { if (h_layout->count() > 0) {
if (auto* item = h_layout->itemAt(h_layout->count() - 1); item && item->spacerItem()) { if (auto* item = h_layout->itemAt(h_layout->count() - 1); item && item->spacerItem()) {
h_layout->takeAt(h_layout->count() - 1); h_layout->takeAt(h_layout->count() - 1);
@@ -267,7 +267,8 @@ void ConfigureDialog::SetUIPositioning(const QString& positioning) {
for (QPushButton* button : tab_buttons) { for (QPushButton* button : tab_buttons) {
h_layout->removeWidget(button); h_layout->removeWidget(button);
v_layout->addWidget(button); v_layout->addWidget(button);
button->setStyleSheet(QStringLiteral("")); // Reset to parent stylesheet // Reset the inline stylesheet so it uses the main template's style.
button->setStyleSheet(QStringLiteral(""));
} }
v_layout->addStretch(1); v_layout->addStretch(1);
} }
@@ -327,3 +328,94 @@ void ConfigureDialog::OnLanguageChanged(const QString& locale) {
RetranslateUI(); RetranslateUI();
SetConfiguration(); SetConfiguration();
} }
void ConfigureDialog::AnimateTabSwitch(int id) {
static bool is_animating = false;
if (is_animating) {
return;
}
QWidget* current_widget = ui->stackedWidget->currentWidget();
QWidget* next_widget = ui->stackedWidget->widget(id);
if (current_widget == next_widget || !current_widget || !next_widget) {
return;
}
const int duration = 400;
// Prepare Widgets for Live Animation
next_widget->setGeometry(0, 0, ui->stackedWidget->width(), ui->stackedWidget->height());
next_widget->move(0, 0);
next_widget->show();
next_widget->raise();
// Animation Logic
auto anim_old_pos = new QPropertyAnimation(current_widget, "pos");
anim_old_pos->setEndValue(QPoint(-ui->stackedWidget->width(), 0));
anim_old_pos->setDuration(duration);
anim_old_pos->setEasingCurve(QEasingCurve::InOutQuart);
auto anim_new_pos = new QPropertyAnimation(next_widget, "pos");
anim_new_pos->setStartValue(QPoint(ui->stackedWidget->width(), 0));
anim_new_pos->setEndValue(QPoint(0, 0));
anim_new_pos->setDuration(duration);
anim_new_pos->setEasingCurve(QEasingCurve::InOutQuart);
auto new_opacity_effect = new QGraphicsOpacityEffect(next_widget);
next_widget->setGraphicsEffect(new_opacity_effect);
auto anim_new_opacity = new QPropertyAnimation(new_opacity_effect, "opacity");
anim_new_opacity->setStartValue(0.0);
anim_new_opacity->setEndValue(1.0);
anim_new_opacity->setDuration(duration);
anim_new_opacity->setEasingCurve(QEasingCurve::InQuad);
auto* button_opacity_effect = qobject_cast<QGraphicsOpacityEffect*>(ui->buttonBox->graphicsEffect());
if (!button_opacity_effect) {
button_opacity_effect = new QGraphicsOpacityEffect(ui->buttonBox);
ui->buttonBox->setGraphicsEffect(button_opacity_effect);
}
auto* button_anim_sequence = new QSequentialAnimationGroup(this);
auto* anim_buttons_fade_out = new QPropertyAnimation(button_opacity_effect, "opacity");
anim_buttons_fade_out->setDuration(duration / 2);
anim_buttons_fade_out->setStartValue(1.0);
anim_buttons_fade_out->setEndValue(0.0);
anim_buttons_fade_out->setEasingCurve(QEasingCurve::OutCubic);
auto* anim_buttons_fade_in = new QPropertyAnimation(button_opacity_effect, "opacity");
anim_buttons_fade_in->setDuration(duration / 2);
anim_buttons_fade_in->setStartValue(0.0);
anim_buttons_fade_in->setEndValue(1.0);
anim_buttons_fade_in->setEasingCurve(QEasingCurve::InCubic);
button_anim_sequence->addAnimation(anim_buttons_fade_out);
button_anim_sequence->addAnimation(anim_buttons_fade_in);
auto animation_group = new QParallelAnimationGroup(this);
animation_group->addAnimation(anim_old_pos);
animation_group->addAnimation(anim_new_pos);
animation_group->addAnimation(anim_new_opacity);
animation_group->addAnimation(button_anim_sequence);
connect(animation_group, &QAbstractAnimation::finished, this, [this, current_widget, next_widget, id]() {
ui->stackedWidget->setCurrentIndex(id);
// Clean up graphics effects to return control to the stylesheet
next_widget->setGraphicsEffect(nullptr);
// Ensure the old widget is hidden and reset for next time
current_widget->hide();
current_widget->move(0, 0);
is_animating = false;
for (auto button : tab_button_group->buttons()) {
button->setEnabled(true);
}
});
is_animating = true;
for (auto button : tab_button_group->buttons()) {
button->setEnabled(false);
}
animation_group->start(QAbstractAnimation::DeleteWhenStopped);
}

View File

@@ -57,6 +57,7 @@ signals:
private slots: private slots:
void SetUIPositioning(const QString& positioning); void SetUIPositioning(const QString& positioning);
void AnimateTabSwitch(int id);
private: private:
void SetConfiguration(); void SetConfiguration();

View File

@@ -20,6 +20,12 @@
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QGraphicsPixmapItem> #include <QGraphicsPixmapItem>
#include <QGraphicsOpacityEffect>
#include <QParallelAnimationGroup>
#include <QPropertyAnimation>
#include <QSequentialAnimationGroup>
#include <QTimer>
#include "citron/configuration/style_animation_event_filter.h"
#include <QMessageBox> #include <QMessageBox>
#include <QMetaObject> #include <QMetaObject>
#include <QProgressDialog> #include <QProgressDialog>
@@ -127,47 +133,52 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::st
UpdateTheme(); UpdateTheme();
connect(rainbow_timer, &QTimer::timeout, this, &ConfigurePerGame::UpdateTheme); connect(rainbow_timer, &QTimer::timeout, this, &ConfigurePerGame::UpdateTheme);
auto* animation_filter = new StyleAnimationEventFilter(this);
button_group = new QButtonGroup(this); button_group = new QButtonGroup(this);
button_group->setExclusive(true); button_group->setExclusive(true);
const auto add_tab = [&](QWidget* widget, const QString& title) { const auto add_tab = [&](QWidget* widget, const QString& title, int id) {
auto button = new QPushButton(title, this); auto button = new QPushButton(title, this);
button->setCheckable(true); button->setCheckable(true);
button->setObjectName(QStringLiteral("tabButton")); // This object name matches the stylesheet ID selector `QPushButton#aestheticTabButton`
button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed); button->setObjectName(QStringLiteral("aestheticTabButton"));
// This custom property is used by the event filter for the animated style
button->setProperty("class", QStringLiteral("tabButton")); // Keep class for animation
button->installEventFilter(animation_filter);
ui->tabButtonsLayout->addWidget(button); ui->tabButtonsLayout->addWidget(button);
button_group->addButton(button); button_group->addButton(button, id);
QScrollArea* scroll_area = new QScrollArea(this); QScrollArea* scroll_area = new QScrollArea(this);
scroll_area->setWidgetResizable(true); scroll_area->setWidgetResizable(true);
scroll_area->setWidget(widget); scroll_area->setWidget(widget);
ui->stackedWidget->addWidget(scroll_area); ui->stackedWidget->addWidget(scroll_area);
connect(button, &QPushButton::clicked, this, [this, scroll_area]() {
ui->stackedWidget->setCurrentWidget(scroll_area);
});
}; };
add_tab(addons_tab.get(), tr("Add-Ons")); int tab_id = 0;
add_tab(cheats_tab.get(), tr("Cheats")); add_tab(addons_tab.get(), tr("Add-Ons"), tab_id++);
add_tab(system_tab.get(), tr("System")); add_tab(cheats_tab.get(), tr("Cheats"), tab_id++);
add_tab(cpu_tab.get(), tr("CPU")); add_tab(system_tab.get(), tr("System"), tab_id++);
add_tab(graphics_tab.get(), tr("Graphics")); add_tab(cpu_tab.get(), tr("CPU"), tab_id++);
add_tab(graphics_advanced_tab.get(), tr("Adv. Graphics")); add_tab(graphics_tab.get(), tr("Graphics"), tab_id++);
add_tab(audio_tab.get(), tr("Audio")); add_tab(graphics_advanced_tab.get(), tr("Adv. Graphics"), tab_id++);
add_tab(input_tab.get(), tr("Input Profiles")); add_tab(audio_tab.get(), tr("Audio"), tab_id++);
add_tab(input_tab.get(), tr("Input Profiles"), tab_id++);
#ifdef __unix__ #ifdef __unix__
add_tab(linux_tab.get(), tr("Linux")); add_tab(linux_tab.get(), tr("Linux"), tab_id++);
#endif #endif
ui->tabButtonsLayout->addStretch(); ui->tabButtonsLayout->addStretch();
if (auto first_button = qobject_cast<QPushButton*>(button_group->buttons().first())) { connect(button_group, qOverload<int>(&QButtonGroup::idClicked), this, &ConfigurePerGame::AnimateTabSwitch);
if (auto first_button = qobject_cast<QPushButton*>(button_group->button(0))) {
first_button->setChecked(true); first_button->setChecked(true);
first_button->click(); ui->stackedWidget->setCurrentIndex(0);
} }
setFocusPolicy(Qt::ClickFocus); setFocusPolicy(Qt::ClickFocus);
setWindowTitle(tr("Properties")); setWindowTitle(tr("Properties"));
addons_tab->SetTitleId(title_id); addons_tab->SetTitleId(title_id);
@@ -731,3 +742,72 @@ void ConfigurePerGame::OnTrimXCI() {
tr("Failed to trim XCI file:\n%1").arg(error_message)); tr("Failed to trim XCI file:\n%1").arg(error_message));
} }
} }
void ConfigurePerGame::AnimateTabSwitch(int id) {
static bool is_animating = false;
if (is_animating) {
return;
}
QWidget* current_widget = ui->stackedWidget->currentWidget();
QWidget* next_widget = ui->stackedWidget->widget(id);
if (current_widget == next_widget || !current_widget || !next_widget) {
return;
}
const int duration = 350;
// Prepare Widgets for Live Animation
next_widget->setGeometry(0, 0, ui->stackedWidget->width(), ui->stackedWidget->height());
next_widget->move(0, 0);
next_widget->show();
next_widget->raise();
// Animate OLD widget: SLIDE LEFT
auto anim_old_pos = new QPropertyAnimation(current_widget, "pos");
anim_old_pos->setEndValue(QPoint(-ui->stackedWidget->width(), 0));
anim_old_pos->setDuration(duration);
anim_old_pos->setEasingCurve(QEasingCurve::InOutQuart);
// Animate NEW widget: SLIDE IN FROM RIGHT and FADE IN
auto anim_new_pos = new QPropertyAnimation(next_widget, "pos");
anim_new_pos->setStartValue(QPoint(ui->stackedWidget->width(), 0));
anim_new_pos->setEndValue(QPoint(0, 0));
anim_new_pos->setDuration(duration);
anim_new_pos->setEasingCurve(QEasingCurve::InOutQuart);
auto new_opacity_effect = new QGraphicsOpacityEffect(next_widget);
next_widget->setGraphicsEffect(new_opacity_effect);
auto anim_new_opacity = new QPropertyAnimation(new_opacity_effect, "opacity");
anim_new_opacity->setStartValue(0.0);
anim_new_opacity->setEndValue(1.0);
anim_new_opacity->setDuration(duration);
anim_new_opacity->setEasingCurve(QEasingCurve::InQuad);
// Group, Run, and Clean Up
auto animation_group = new QParallelAnimationGroup(this);
animation_group->addAnimation(anim_old_pos);
animation_group->addAnimation(anim_new_pos);
animation_group->addAnimation(anim_new_opacity);
// Use a context-aware connection to prevent crashes
connect(animation_group, &QAbstractAnimation::finished, this, [this, current_widget, next_widget, id]() {
ui->stackedWidget->setCurrentIndex(id);
next_widget->setGraphicsEffect(nullptr);
current_widget->hide();
current_widget->move(0, 0);
is_animating = false;
for (auto button : button_group->buttons()) {
button->setEnabled(true);
}
});
is_animating = true;
for (auto button : button_group->buttons()) {
button->setEnabled(false);
}
animation_group->start(QAbstractAnimation::DeleteWhenStopped);
}

View File

@@ -67,6 +67,9 @@ protected:
void changeEvent(QEvent* event) override; void changeEvent(QEvent* event) override;
void resizeEvent(QResizeEvent* event) override; void resizeEvent(QResizeEvent* event) override;
private slots:
void AnimateTabSwitch(int id);
private: private:
void RetranslateUI(); void RetranslateUI();
void HandleApplyButtonClicked(); void HandleApplyButtonClicked();

View File

@@ -96,34 +96,33 @@
width: 0px; width: 0px;
} }
QPushButton#tabButton { QPushButton#aestheticTabButton {
background-color: %%BUTTON_BG_COLOR%%; background-color: transparent;
color: %%TEXT_COLOR%%; color: %%TEXT_COLOR%%;
padding: 8px 18px; border: none;
margin-right: 2px; padding: 10px;
border-top-left-radius: 8px; padding-left: 20px; /* Indent text from the left */
border-top-right-radius: 8px; border-radius: 8px;
border-bottom: none;
min-width: 100px;
font-weight: bold; font-weight: bold;
font-size: 10pt; font-size: 10pt;
border: 1px solid %%SECONDARY_BG_COLOR%%; text-align: left center; /* Align text to the left like the original */
min-width: 110px; /* Stricter minimum width to prevent smudging */
max-height: 25px; /* Enforce a consistent height */
} }
QPushButton#tabButton:checked { QPushButton#aestheticTabButton:checked {
background-color: %%ACCENT_COLOR%%; background-color: %%SECONDARY_BG_COLOR%%; /* Add subtle background when checked */
color: #ffffff; color: %%TEXT_COLOR%%; /* Keep text color consistent when checked */
font-weight: bold; border-left: 3px solid %%ACCENT_COLOR%%; /* Accent color bar on the LEFT */
border-color: %%ACCENT_COLOR%%; padding-left: 17px; /* Adjust padding to keep text aligned perfectly */
} }
QPushButton#tabButton:hover:!checked { QPushButton#aestheticTabButton:hover:!checked {
background-color: %%HOVER_BG_COLOR%%; background-color: %%HOVER_BG_COLOR%%;
border-color: %%TERTIARY_BG_COLOR%%;
} }
QPushButton#tabButton:pressed { QPushButton#aestheticTabButton:pressed {
background-color: %%ACCENT_COLOR_PRESSED%%; background-color: %%TERTIARY_BG_COLOR%%;
} }
QGroupBox { QGroupBox {

View File

@@ -231,9 +231,9 @@ void ConfigureUi::resizeEvent(QResizeEvent* event) {
return; return;
} }
if (width() < 550 && !isCompact) { if (width() < 850 && !isCompact) {
switchToCompactLayout(); switchToCompactLayout();
} else if (width() >= 550 && isCompact) { } else if (width() >= 850 && isCompact) {
switchToWideLayout(); switchToWideLayout();
} }
} }

View File

@@ -131,44 +131,27 @@
</widget> </widget>
</item> </item>
<item row="3" column="1"> <item row="3" column="1">
<layout class="QHBoxLayout" name="accentColorLayout"> <widget class="QPushButton" name="accentColorButton">
<item> <property name="text">
<widget class="QPushButton" name="accentColorButton"> <string>Choose Color...</string>
<property name="text"> </property>
<string>Choose Color...</string> </widget>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="rainbowModeCheckBox">
<property name="text">
<string>Enable Rainbow Mode</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item> </item>
<item row="4" column="0"> <item row="4" column="1">
<widget class="QCheckBox" name="rainbowModeCheckBox">
<property name="text">
<string>Enable Rainbow Mode</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label"> <widget class="QLabel" name="label">
<property name="text"> <property name="text">
<string>UI Positioning</string> <string>UI Positioning</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="1"> <item row="5" column="1">
<widget class="QComboBox" name="ui_positioning_combo"> <widget class="QComboBox" name="ui_positioning_combo">
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
@@ -410,14 +393,14 @@
</widget> </widget>
</item> </item>
<item row="2" column="1"> <item row="2" column="1">
<layout class="QHBoxLayout" name="resolutionLayout"> <layout class="QVBoxLayout" name="resolutionLayout">
<item> <item>
<widget class="QLabel" name="screenshot_width"> <widget class="QLabel" name="screenshot_width">
<property name="text"> <property name="text">
<string>TextLabel</string> <string>TextLabel</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property> </property>
<property name="wordWrap"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>