From b0f5a64277c8ec2b6f25f61640ee56ab810aec35 Mon Sep 17 00:00:00 2001 From: janpollak <40995260+janpollak@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:51:34 +0100 Subject: [PATCH 1/3] Add TabContentFlow for iOS 18+ Tab API support Enables use of Tab(role: .search) and other iOS 18+ tab features within the FuturedArchitecture coordinator pattern. Extracts shared modal presentation logic into ModalCoverModifier to reduce duplication. --- .../Architecture/TabViewFlow.swift | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift index 7335b52..ff137d5 100644 --- a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift +++ b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift @@ -2,6 +2,9 @@ import SwiftUI /// The `TabViewFlow` encapsulates the ``SwiftUI.TabView`` and binds it to the /// variables and callbacks of the ``TabCoordinator`` which it retains as a ``SwiftUI.State``. +/// +/// Uses the legacy `.tabItem` + `.tag` pattern. For iOS 18+ projects that need +/// ``SwiftUI.Tab`` features (e.g. `Tab(role: .search)`), use ``TabContentFlow`` instead. /// - Experiment: This API is in preview and subject to change. public struct TabViewFlow: View { @State private var coordinator: Coordinator @@ -16,24 +19,58 @@ public struct TabViewFlow: View { self.content = content } - #if os(macOS) public var body: some View { TabView(selection: $coordinator.selectedTab) { content() } - .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + .modifier(ModalCoverModifier(coordinator: coordinator)) } - #else +} + +// MARK: - TabContentFlow (iOS 18+) + +/// A variant of ``TabViewFlow`` that accepts ``SwiftUI.TabContent`` instead of `View`, +/// enabling the iOS 18+ ``SwiftUI.Tab`` API with features like `Tab(role: .search)`. +/// +/// Use this instead of ``TabViewFlow`` when you need `Tab(role:)`, customizable tab +/// placement, or other iOS 18+ tab features. +/// +/// Usage: +/// ```swift +/// TabContentFlow(coordinator: instance) { +/// Tab("Home", systemImage: "house", value: Tab.home) { ... } +/// Tab(value: Tab.search, role: .search) { ... } +/// } +/// ``` +/// - Experiment: This API is in preview and subject to change. +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +public struct TabContentFlow>: View { + @State private var coordinator: Coordinator + @TabContentBuilder private let content: () -> Content + + /// - Parameters: + /// - coordinator: The instance of the coordinator used as the model and retained as ``SwiftUI.State`` + /// - content: The definition of tabs using the ``SwiftUI.Tab`` API. + public init(coordinator: Coordinator, @TabContentBuilder content: @MainActor @escaping () -> Content) { + self._coordinator = State(wrappedValue: coordinator) + self.content = content + } + public var body: some View { TabView(selection: $coordinator.selectedTab) { content() } - .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) - .fullScreenCover(item: fullscreenCoverBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + .modifier(ModalCoverModifier(coordinator: coordinator)) } - #endif +} + +// MARK: - Shared Modal Presentation + +/// Shared modifier that adds sheet and full-screen cover presentation to any coordinator-backed view. +struct ModalCoverModifier: ViewModifier { + var coordinator: C - private var sheetBinding: Binding { + private var sheetBinding: Binding { .init { coordinator.modalCover?.style == .sheet ? coordinator.modalCover?.destination : nil } set: { destination in @@ -41,13 +78,24 @@ public struct TabViewFlow: View { } } - #if !os(macOS) - private var fullscreenCoverBinding: Binding { + #if os(macOS) + func body(content: Content) -> some View { + content + .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + } + #else + private var fullscreenCoverBinding: Binding { .init { coordinator.modalCover?.style == .fullscreenCover ? coordinator.modalCover?.destination : nil } set: { destination in coordinator.modalCover = destination.map { .init(destination: $0, style: .fullscreenCover) } } } + + func body(content: Content) -> some View { + content + .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + .fullScreenCover(item: fullscreenCoverBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + } #endif } From e1198dac2331fbb63ec1e619e5e7f756568ac9b6 Mon Sep 17 00:00:00 2001 From: janpollak <40995260+janpollak@users.noreply.github.com> Date: Tue, 12 May 2026 12:50:14 +0200 Subject: [PATCH 2/3] Extract ModalCoverModifier and reuse in NavigationStackFlow Pulls the sheet/full-screen cover binding boilerplate out of TabViewFlow into a standalone ModalCoverModifier generic over any Coordinator. Apply it in NavigationStackFlow too so all flow containers share the same modal presentation logic. --- .../Architecture/ModalCoverModifier.swift | 39 +++++++++++++++++++ .../Architecture/NavigationStackFlow.swift | 23 +---------- .../Architecture/TabViewFlow.swift | 36 ----------------- 3 files changed, 40 insertions(+), 58 deletions(-) create mode 100644 Sources/FuturedArchitecture/Architecture/ModalCoverModifier.swift diff --git a/Sources/FuturedArchitecture/Architecture/ModalCoverModifier.swift b/Sources/FuturedArchitecture/Architecture/ModalCoverModifier.swift new file mode 100644 index 0000000..2d8a491 --- /dev/null +++ b/Sources/FuturedArchitecture/Architecture/ModalCoverModifier.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// Adds `.sheet` and (on non-macOS platforms) `.fullScreenCover` presentation +/// driven by a ``Coordinator``'s ``Coordinator/modalCover`` state. +/// +/// Use this modifier in any flow container view (e.g. ``NavigationStackFlow``, +/// ``TabViewFlow``, ``TabContentFlow``) to keep modal presentation behaviour consistent. +struct ModalCoverModifier: ViewModifier { + var coordinator: C + + private var sheetBinding: Binding { + .init { + coordinator.modalCover?.style == .sheet ? coordinator.modalCover?.destination : nil + } set: { destination in + coordinator.modalCover = destination.map { .init(destination: $0, style: .sheet) } + } + } + + #if os(macOS) + func body(content: Content) -> some View { + content + .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + } + #else + private var fullscreenCoverBinding: Binding { + .init { + coordinator.modalCover?.style == .fullscreenCover ? coordinator.modalCover?.destination : nil + } set: { destination in + coordinator.modalCover = destination.map { .init(destination: $0, style: .fullscreenCover) } + } + } + + func body(content: Content) -> some View { + content + .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + .fullScreenCover(item: fullscreenCoverBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) + } + #endif +} diff --git a/Sources/FuturedArchitecture/Architecture/NavigationStackFlow.swift b/Sources/FuturedArchitecture/Architecture/NavigationStackFlow.swift index 91c9edc..0bdc28e 100644 --- a/Sources/FuturedArchitecture/Architecture/NavigationStackFlow.swift +++ b/Sources/FuturedArchitecture/Architecture/NavigationStackFlow.swift @@ -29,29 +29,8 @@ public struct NavigationStackFlow { - .init { - coordinator.modalCover?.style == .sheet ? coordinator.modalCover?.destination : nil - } set: { destination in - coordinator.modalCover = destination.map { .init(destination: $0, style: .sheet) } - } - } - - #if !os(macOS) - private var fullscreenCoverBinding: Binding { - .init { - coordinator.modalCover?.style == .fullscreenCover ? coordinator.modalCover?.destination : nil - } set: { destination in - coordinator.modalCover = destination.map { .init(destination: $0, style: .fullscreenCover) } - } - } - #endif } /// A view modifier that conditionally applies presentation detents only when they are non-nil. diff --git a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift index ff137d5..8714858 100644 --- a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift +++ b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift @@ -63,39 +63,3 @@ public struct TabContentFlow: ViewModifier { - var coordinator: C - - private var sheetBinding: Binding { - .init { - coordinator.modalCover?.style == .sheet ? coordinator.modalCover?.destination : nil - } set: { destination in - coordinator.modalCover = destination.map { .init(destination: $0, style: .sheet) } - } - } - - #if os(macOS) - func body(content: Content) -> some View { - content - .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) - } - #else - private var fullscreenCoverBinding: Binding { - .init { - coordinator.modalCover?.style == .fullscreenCover ? coordinator.modalCover?.destination : nil - } set: { destination in - coordinator.modalCover = destination.map { .init(destination: $0, style: .fullscreenCover) } - } - } - - func body(content: Content) -> some View { - content - .sheet(item: sheetBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) - .fullScreenCover(item: fullscreenCoverBinding, onDismiss: coordinator.onModalDismiss, content: coordinator.scene(for:)) - } - #endif -} From 49ec2bf81487eb343b6afa26c883a3640dc377b7 Mon Sep 17 00:00:00 2001 From: janpollak <40995260+janpollak@users.noreply.github.com> Date: Tue, 12 May 2026 12:50:35 +0200 Subject: [PATCH 3/3] Deprecate TabViewFlow on iOS 18+ in favour of TabContentFlow Uses per-platform availability so projects still targeting iOS 16/17 (or other pre-iOS 18 platforms) are not warned, while iOS 18+ targets get a nudge towards TabContentFlow. --- Sources/FuturedArchitecture/Architecture/TabViewFlow.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift index 8714858..92c9b63 100644 --- a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift +++ b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift @@ -6,6 +6,10 @@ import SwiftUI /// Uses the legacy `.tabItem` + `.tag` pattern. For iOS 18+ projects that need /// ``SwiftUI.Tab`` features (e.g. `Tab(role: .search)`), use ``TabContentFlow`` instead. /// - Experiment: This API is in preview and subject to change. +@available(iOS, deprecated: 18.0, message: "Use TabContentFlow for the iOS 18+ Tab API.") +@available(macOS, deprecated: 15.0, message: "Use TabContentFlow for the macOS 15+ Tab API.") +@available(tvOS, deprecated: 18.0, message: "Use TabContentFlow for the tvOS 18+ Tab API.") +@available(watchOS, deprecated: 11.0, message: "Use TabContentFlow for the watchOS 11+ Tab API.") public struct TabViewFlow: View { @State private var coordinator: Coordinator @ViewBuilder private let content: () -> Content