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 7335b52..92c9b63 100644 --- a/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift +++ b/Sources/FuturedArchitecture/Architecture/TabViewFlow.swift @@ -2,7 +2,14 @@ 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. +@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 @@ -16,38 +23,47 @@ 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 - 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:)) - } - #endif +} - 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) } - } +// 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 } - #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) } + public var body: some View { + TabView(selection: $coordinator.selectedTab) { + content() } + .modifier(ModalCoverModifier(coordinator: coordinator)) } - #endif }