From 6e8835237d47954f7a57c391454578fb27ec3c8a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 11 Oct 2026 21:28:51 +0200 Subject: [PATCH] Add a Navigation setting to keep the tab bar on large screens iPad and iPhone Duo users can pick Tab Bar instead of Automatic to opt out of the sidebar layout. Switching rebuilds the navigation Settings lives in, so the change asks for confirmation and lands on Home first. --- Yattee/ContentView.swift | 6 +- Yattee/Core/Settings/SettingsKey.swift | 5 +- .../Core/Settings/SettingsManager+Home.swift | 17 ++++ Yattee/Core/Settings/SettingsTypes.swift | 21 +++++ Yattee/Core/SettingsManager.swift | 6 ++ Yattee/Localizable.xcstrings | 77 +++++++++++++++++++ .../Navigation/NavigationRouter.swift | 7 +- .../LayoutNavigationSettingsView.swift | 69 ++++++++++++++++- 8 files changed, 199 insertions(+), 9 deletions(-) diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index dc01fc8a..0bfa8a71 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -246,7 +246,11 @@ struct AdaptiveTabView: View { } private var usesSidebar: Bool { - NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) + NavigationLayout.usesSidebar( + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass, + style: appEnvironment.settingsManager.navigationStyle + ) } private static var usesTabBottomAccessory: Bool { diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index f1659807..e0602aca 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -88,6 +88,9 @@ enum SettingsKey: String, CaseIterable { // Top Shelf (tvOS) case topShelfSections + // Navigation layout (iPad, iPhone Duo inner screen) + case navigationStyle + // Tab Bar (compact size class) case tabBarItemOrder case tabBarItemVisibility @@ -162,7 +165,7 @@ enum SettingsKey: String, CaseIterable { // Top Shelf — tvOS only .topShelfSections, // Tab bar (compact size class) layout - .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, + .navigationStyle, .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, // Sidebar layout/selection .sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab, .sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources, diff --git a/Yattee/Core/Settings/SettingsManager+Home.swift b/Yattee/Core/Settings/SettingsManager+Home.swift index d59bd206..efc882b5 100644 --- a/Yattee/Core/Settings/SettingsManager+Home.swift +++ b/Yattee/Core/Settings/SettingsManager+Home.swift @@ -964,6 +964,23 @@ extension SettingsManager { } } + // MARK: - Navigation Style + + #if os(iOS) + /// Whether screens that fit the sidebar (iPad, iPhone Duo inner screen) + /// use it, or keep the tab bar. Default is automatic. + var navigationStyle: NavigationStyle { + get { + if let cached = _navigationStyle { return cached } + return NavigationStyle(rawValue: string(for: .navigationStyle) ?? "") ?? .automatic + } + set { + _navigationStyle = newValue + set(newValue.rawValue, for: .navigationStyle) + } + } + #endif + // MARK: - Startup Tab Settings /// The startup tab for tab bar mode (compact/iPhone). Default is home. diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index c2916700..f894fb3c 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -386,6 +386,27 @@ enum MiniPlayerVideoTapAction: String, CaseIterable, Codable { } } +// MARK: - Navigation Style + +/// Navigation layout on screens that fit the sidebar (iPad, iPhone Duo inner screen). +#if os(iOS) +enum NavigationStyle: String, CaseIterable, Codable { + /// Sidebar where it fits, tab bar elsewhere. + case automatic + /// Tab bar everywhere. + case tabBar + + var displayName: String { + switch self { + case .automatic: + return String(localized: "settings.navigationStyle.automatic") + case .tabBar: + return String(localized: "settings.navigationStyle.tabBar") + } + } +} +#endif + // MARK: - Mini Player Minimize Behavior /// Behavior for minimizing the mini player (iOS 26+ only). diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index e8f7147d..ee704863 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -146,6 +146,9 @@ final class SettingsManager { // Tab bar startup var _tabBarStartupTab: SidebarMainItem? + #if os(iOS) + var _navigationStyle: NavigationStyle? + #endif var _sidebarSourcesEnabled: Bool? var _sidebarSourceSort: SidebarSourceSort? var _sidebarSourcesLimitEnabled: Bool? @@ -509,6 +512,9 @@ final class SettingsManager { _sidebarMainItemVisibility = nil _sidebarStartupTab = nil _tabBarStartupTab = nil + #if os(iOS) + _navigationStyle = nil + #endif _sidebarSourcesEnabled = nil _sidebarSourceSort = nil _sidebarSourcesLimitEnabled = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 527367b5..0468f6db 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -13974,6 +13974,83 @@ } } }, + "settings.navigationStyle" : { + "comment" : "Picker label for choosing sidebar or tab bar navigation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigation" + } + } + } + }, + "settings.navigationStyle.automatic" : { + "comment" : "Navigation style option: sidebar on large screens, tab bar elsewhere", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic" + } + } + } + }, + "settings.navigationStyle.confirm.change" : { + "comment" : "Confirm button for changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change" + } + } + } + }, + "settings.navigationStyle.confirm.message" : { + "comment" : "Alert message before changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app's navigation will be rebuilt for the new layout. Settings will close and you'll return to Home." + } + } + } + }, + "settings.navigationStyle.confirm.title" : { + "comment" : "Alert title before changing the navigation style", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Navigation?" + } + } + } + }, + "settings.navigationStyle.footer" : { + "comment" : "Footer explaining the navigation style setting", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic uses the sidebar when the screen is large enough. Tab Bar always uses the tab bar." + } + } + } + }, + "settings.navigationStyle.tabBar" : { + "comment" : "Navigation style option: always use the tab bar", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab Bar" + } + } + } + }, "settings.notifications.defaultForNew" : { "comment" : "Toggle for enabling notifications on new subscriptions", "localizations" : { diff --git a/Yattee/Services/Navigation/NavigationRouter.swift b/Yattee/Services/Navigation/NavigationRouter.swift index 6570f55e..931b40e1 100644 --- a/Yattee/Services/Navigation/NavigationRouter.swift +++ b/Yattee/Services/Navigation/NavigationRouter.swift @@ -170,12 +170,13 @@ enum NavigationLayout { /// iPhone Duo inner screen in either orientation). A large iPhone in /// landscape is regular width but compact height, so it keeps the tab /// bar. An iPad keeps the sidebar for as long as its width is regular, - /// even in a short Stage Manager window. + /// even in a short Stage Manager window. The `.tabBar` style opts out. static func usesSidebar( horizontalSizeClass: UserInterfaceSizeClass?, - verticalSizeClass: UserInterfaceSizeClass? + verticalSizeClass: UserInterfaceSizeClass?, + style: NavigationStyle = .automatic ) -> Bool { - guard horizontalSizeClass == .regular else { return false } + guard style == .automatic, horizontalSizeClass == .regular else { return false } return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad } } diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 9c027173..1f6c72ff 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -13,7 +13,7 @@ struct LayoutNavigationSettingsView: View { var body: some View { SettingsFormContainer { if let settings = appEnvironment?.settingsManager { - CustomizationSection() + CustomizationSection(settings: settings) #if os(iOS) HapticsSection(settings: settings) #endif @@ -78,11 +78,62 @@ private struct HapticsSection: View { // MARK: - Customization Section private struct CustomizationSection: View { + @Bindable var settings: SettingsManager + #if os(iOS) + @Environment(\.appEnvironment) private var appEnvironment @State private var hasHinge = HingeLayoutObserver.forceTabletop + /// Style picked but not yet confirmed: switching rebuilds the navigation + /// Settings lives in, so the user agrees to leaving it first. + @State private var pendingNavigationStyle: NavigationStyle? #endif + /// iPad and foldable iPhones (inner screen): where the sidebar layout can appear. + private var canUseSidebar: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .pad || hasHinge + #else + true + #endif + } + var body: some View { + #if os(iOS) + if canUseSidebar { + SettingsFormSection(footer: "settings.navigationStyle.footer") { + Picker( + String(localized: "settings.navigationStyle"), + selection: Binding( + get: { settings.navigationStyle }, + set: { style in + guard style != settings.navigationStyle else { return } + pendingNavigationStyle = style + } + ) + ) { + ForEach(NavigationStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + } + .alert( + String(localized: "settings.navigationStyle.confirm.title"), + isPresented: Binding( + get: { pendingNavigationStyle != nil }, + set: { if !$0 { pendingNavigationStyle = nil } } + ), + presenting: pendingNavigationStyle + ) { style in + Button(String(localized: "settings.navigationStyle.confirm.change")) { + applyNavigationStyle(style) + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { _ in + Text(String(localized: "settings.navigationStyle.confirm.message")) + } + } + #endif + SettingsFormSection { #if os(tvOS) NavigationLink { @@ -104,9 +155,7 @@ private struct CustomizationSection: View { TabBarSettingsView() } - // Sidebar settings where the sidebar layout can appear: iPad and - // foldable iPhones (inner screen) - if UIDevice.current.userInterfaceIdiom == .pad || hasHinge { + if canUseSidebar && settings.navigationStyle == .automatic { SettingsNavigationRow("settings.sidebar.title", systemImage: "sidebar.leading") { SidebarSettingsView() } @@ -132,6 +181,18 @@ private struct CustomizationSection: View { .observeHinge(hasHinge: $hasHinge) #endif } + + #if os(iOS) + /// Lands on Home before the layout swap, so neither layout remaps the + /// Settings selection (the tab bar would reopen it as a sheet). + private func applyNavigationStyle(_ style: NavigationStyle) { + if let router = appEnvironment?.navigationCoordinator.router { + router.popToRoot(router.selection) + router.selection = .home + } + settings.navigationStyle = style + } + #endif } // MARK: - Link Action Section