Navigation Coordinator in SwiftUI: Managing Modal Views Dynamically
Handling modal views effectively is essential for creating smooth, intuitive user experiences in SwiftUI apps. A Navigation Coordinator allows us to have much more control over when and how modal views are presented, ensuring that our apps feel polished and professional.
Additionally, planning different routes for each modal view helps management capabilities when working with complex navigation patterns.
Why use a Modal Coordinator?
Modal presentation is frequently used for presenting screens, including alerts, onboarding, configuration sheets, and more. Here are the key reasons to use a Modal Coordinator:
• Modal Coordinator helps us: • Manage modal presentation logic from the UI • Ensure consistency across different modal types • Provide clean dismiss logic separation from UI • Handle modal navigation state correctly across different views
Step 1: Define a Protocol for Modal Views
Creating a protocol allows SwiftUI UI to be built from the controller and displayed as a modal while maintaining the same structure throughout the app.
This will also help ensure that our presented modal has exactly the same behavior in the app as in the presentation.
import SwiftUI
enum ModalType: Identifiable, Hashable {
case settings
case profile
case addItem
case editItem(id: String)
case imageViewer(image: String)
case alert(title: String, message: String)
var id: String {
switch self {
case .settings:
return "settings"
case .profile:
return "profile"
case .addItem:
return "addItem"
case .editItem(let id):
return "editItem-\(id)"
case .imageViewer(let image):
return "imageViewer-\(image)"
case .alert(let title, _):
return "alert-\(title)"
}
}
}
Step 2: Create the Modal Coordinator
The coordinator will maintain the state of modal views and provide methods to present and dismiss modals:
import SwiftUI
class ModalCoordinator: ObservableObject {
@Published var activeModal: ModalType?
@Published var isPresented: Bool = false
func presentModal(_ modal: ModalType) {
activeModal = modal
isPresented = true
}
func dismissModal() {
isPresented = false
activeModal = nil
}
@ViewBuilder
func modalView() -> some View {
if let modal = activeModal {
switch modal {
case .settings:
SettingsView()
case .profile:
ProfileView()
case .addItem:
AddItemView()
case .editItem(let id):
EditItemView(itemId: id)
case .imageViewer(let image):
ImageViewerView(imageName: image)
case .alert(let title, let message):
AlertView(title: title, message: message)
}
}
}
}
Step 3: Implement the Main View Using the Coordinator
The main view will use the coordinator to handle modal presentation dynamically:
struct ContentView: View {
@StateObject private var modalCoordinator = ModalCoordinator()
var body: some View {
NavigationView {
VStack(spacing: 20) {
Text("Main Content View")
.font(.largeTitle)
.padding()
// Buttons to present different modals
VStack(spacing: 15) {
Button("Show Settings") {
modalCoordinator.presentModal(.settings)
}
.buttonStyle(.borderedProminent)
Button("Show Profile") {
modalCoordinator.presentModal(.profile)
}
.buttonStyle(.borderedProminent)
Button("Add New Item") {
modalCoordinator.presentModal(.addItem)
}
.buttonStyle(.borderedProminent)
Button("Edit Item") {
modalCoordinator.presentModal(.editItem(id: "123"))
}
.buttonStyle(.borderedProminent)
Button("View Image") {
modalCoordinator.presentModal(.imageViewer(image: "sample-image"))
}
.buttonStyle(.borderedProminent)
Button("Show Alert") {
modalCoordinator.presentModal(.alert(title: "Alert", message: "This is a modal alert"))
}
.buttonStyle(.borderedProminent)
}
}
.navigationTitle("Modal Coordinator Demo")
.sheet(isPresented: $modalCoordinator.isPresented) {
modalCoordinator.modalView()
.environmentObject(modalCoordinator)
}
}
.environmentObject(modalCoordinator)
}
}
Step 4: Create the Modal Coordinator Views
Each modal view will have access to the coordinator so it can dismiss itself properly:
struct SettingsView: View {
@EnvironmentObject var modalCoordinator: ModalCoordinator
var body: some View {
NavigationView {
VStack {
Text("Settings View")
.font(.title)
Text("Configure your app settings here")
.padding()
Spacer()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
modalCoordinator.dismissModal()
}
}
}
}
}
}
struct ProfileView: View {
@EnvironmentObject var modalCoordinator: ModalCoordinator
var body: some View {
NavigationView {
VStack {
Text("Profile View")
.font(.title)
Text("User profile information")
.padding()
Spacer()
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Close") {
modalCoordinator.dismissModal()
}
}
}
}
}
}
Step 5: Define Modal Views with Automatic Height
For smaller modal views, we can implement a system that automatically adjusts height:
struct AddItemView: View {
@EnvironmentObject var modalCoordinator: ModalCoordinator
@State private var itemName = ""
@State private var itemDescription = ""
var body: some View {
VStack(spacing: 20) {
Text("Add New Item")
.font(.title2)
.fontWeight(.bold)
TextField("Item Name", text: $itemName)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("Description", text: $itemDescription)
.textFieldStyle(RoundedBorderTextFieldStyle())
HStack(spacing: 15) {
Button("Cancel") {
modalCoordinator.dismissModal()
}
.buttonStyle(.bordered)
Button("Add Item") {
// Logic to add item
print("Adding item: \(itemName)")
modalCoordinator.dismissModal()
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.presentationDetents([.medium])
}
}
Step 6: Create the Modal Container View
A container view that handles more complex modal content with internal navigation:
struct ModalContainerView: View {
@EnvironmentObject var modalCoordinator: ModalCoordinator
var body: some View {
Group {
if let modal = modalCoordinator.activeModal {
switch modal {
case .settings, .profile:
// Modals that need full NavigationView
modalCoordinator.modalView()
case .addItem, .editItem, .alert:
// Simpler modals without NavigationView
modalCoordinator.modalView()
case .imageViewer:
// Full screen modal
modalCoordinator.modalView()
.presentationDetents([.large])
}
}
}
}
}
What Did We Achieve?
By using this Coordinator pattern, we've successfully created a robust, scalable modal navigation system for our SwiftUI application. Our solution provides:
Simply use the coordinator anywhere you need to present a modal:
// From any view with access to the coordinator
Button("Show Settings") {
modalCoordinator.presentModal(.settings)
}
// Or with parameters
Button("Edit Item") {
modalCoordinator.presentModal(.editItem(id: selectedItemId))
}
This pattern is particularly useful for complex applications where you need to present multiple types of modals with different behaviors and presentation styles. The coordinator provides a clean separation of concerns and makes your code more maintainable and testable.