The Encapsulated Sheet Pattern: Leveraging Nested Modifiers for Strict Privacy

Published by malhal on

When managing modal presentation in SwiftUI, developers frequently face a dilemma between direct boolean bindings (.sheet(isPresented:)) and item bindings (.sheet(item:)). While .sheet(item:) binds presentation directly to optional data, it presents architectural trade-offs: the item passed to the sheet content closure is immutable value data, meaning two-way editing requires workarounds like class reference types or complex parent binding lookups. Moreover, spreading inline .sheet(item:) calls across multiple parent views leads to duplicated layout and lifecycle boilerplate.

The Encapsulated Sheet Pattern solves this by nesting the ViewModifier directly inside the domain state struct. This technique allows us to use private access control on internal state flags like isPresented while allowing the nested sheet modifier full access to mutate state during presentation and dismissal.

1. Terminology: The Pattern Components

  • Outer State Struct: PersonalInfoEditor — Encapsulates draft state (name), cached validation state (canSave), presentation state (isPresented), and public API methods (present(name:), dismiss()).
  • Nested View Modifier: PersonalInfoEditor.Sheet — A ViewModifier nested inside PersonalInfoEditor that accesses private properties on PersonalInfoEditor directly.
  • Modifier Extension: .personalInfoEditorSheet(title:editor:onSave:) — A fluent extension on Viewwrapping the nested modifier.

2. Why Nesting the Modifier Unlocks Strict Privacy

In Swift, nested types share access to private members of their enclosing type (within the same source file). By placing Sheet directly inside PersonalInfoEditor, Sheet can read and mutate editor.isPresented directly without needing fileprivate or public access setters on isPresented.

    OUTSIDE CALLERS (Views / Extensions)           ENCLOSING FILE SCOPE
  ┌─────────────────────────────────────┐  ┌──────────────────────────────────────┐
  │ editor.present(name: "Alice")        │  │ PersonalInfoEditor Struct            │
  │   └── Validates & sets isPresented  │  │  ├── private var isPresented = false │
  │                                     │  │  │   (Inaccessible outside file)     │
  │ editor.isPresented = true ❌ ERROR   │  │  │                                      │
  └─────────────────────────────────────┘  │  └── struct Sheet: ViewModifier      │
                                           │       └── $editor.isPresented ✅ OK   │
                                           └──────────────────────────────────────┘

Swift Implementation

struct PersonalInfoEditor {
    // 🔒 Strictly private—external views cannot toggle presentation arbitrarily
    private var isPresented: Bool = false
    
    var name: String = "" {
        didSet {
            // Recalculate validation cache on value change
            canSave = Person.isValid(name: name) && initialName != name
        }
    }
    
    private var initialName: String = ""
    private(set) var canSave = false
    
    /// Public entry point guarantees setup of initial values before showing the sheet
    mutating func present(name: String = "") {
        self.name = name
        self.initialName = name
        self.canSave = Person.isValid(name: name) && initialName != name
        self.isPresented = true
    }
    
    mutating func dismiss() {
        isPresented = false
    }
    
    // MARK: - Nested View Modifier
    struct Sheet: ViewModifier {
        let title: String
        @Binding var editor: PersonalInfoEditor
        let onSave: () -> Void
        
        func body(content: Content) -> some View {
            content
                .sheet(isPresented: $editor.isPresented) {
                    NavigationStack {
                        Form {
                            TextField("Name", text: $editor.name)
                                .autocorrectionDisabled()
                        }
                        .navigationTitle(title)
                        .toolbar {
                            ToolbarItem(placement: .cancellationAction) {
                                Button("Cancel") {
                                    editor.dismiss()
                                }
                            }
                            
                            ToolbarItem(placement: .confirmationAction) {
                                Button("Save") {
                                    onSave()
                                    editor.dismiss()
                                }
                                .disabled(!editor.canSave)
                            }
                        }
                    }
                }
        }
    }
}

3. Benefits of the Nested Modifier Approach

  1. Strict Field Encapsulation: isPresented remains completely private. Callers outside the source file cannot bypass initialization logic or manually force presentation without invoking present(name:).
  2. Encapsulated View Modifier Namespace: Rather than polluting the global module scope with PersonalInfoEditorSheet, the view modifier is cleanly scoped under PersonalInfoEditor.Sheet.
  3. Cached Property Observers (didSet): Computing validation flags inside didSet ensures that UI elements (like .disabled(!editor.canSave)) read precomputed properties without re-executing validation logic on every view re-render.

4. Complete Code Implementation

Here is the entire decoupled setup in action:

import SwiftUI

// MARK: - 1. Domain Model
struct Person: Identifiable {
    let id = UUID()
    var name: String
    
    static func isValid(name: String) -> Bool {
        !name.trimmingCharacters(in: .whitespaces).isEmpty
    }
}

// MARK: - 2. Encapsulated State Struct with Nested Sheet
struct PersonalInfoEditor {
    private var isPresented: Bool = false
    
    var name: String = "" {
        didSet {
            canSave = Person.isValid(name: name) && initialName != name
        }
    }
    
    private var initialName: String = ""
    private(set) var canSave = false
    
    mutating func present(name: String = "") {
        self.name = name
        self.initialName = name
        self.canSave = Person.isValid(name: name) && initialName != name
        self.isPresented = true
    }
    
    mutating func dismiss() {
        isPresented = false
    }
    
    // Nested View Modifier
    struct Sheet: ViewModifier {
        let title: String
        @Binding var editor: PersonalInfoEditor
        let onSave: () -> Void
        
        func body(content: Content) -> some View {
            content
                .sheet(isPresented: $editor.isPresented) {
                    NavigationStack {
                        Form {
                            TextField("Name", text: $editor.name)
                                .autocorrectionDisabled()
                        }
                        .navigationTitle(title)
                        .toolbar {
                            ToolbarItem(placement: .cancellationAction) {
                                Button("Cancel") {
                                    editor.dismiss()
                                }
                            }
                            
                            ToolbarItem(placement: .confirmationAction) {
                                Button("Save") {
                                    onSave()
                                    editor.dismiss()
                                }
                                .disabled(!editor.canSave)
                            }
                        }
                    }
                }
        }
    }
}

// MARK: - 3. Declarative View Extension
extension View {
    func personalInfoEditorSheet(
        title: String,
        editor: Binding<PersonalInfoEditor>,
        onSave: @escaping () -> Void
    ) -> some View {
        self.modifier(
            PersonalInfoEditor.Sheet(
                title: title,
                editor: editor,
                onSave: onSave
            )
        )
    }
}

// MARK: - 4. Feature Call Sites (Decoupled from Person)

struct EditPersonalInfo: View {
    let initialName: String
    let onSave: (String) -> Void
    
    @State private var editor = PersonalInfoEditor()
    
    var body: some View {
        Button("Edit") {
            editor.present(name: initialName)
        }
        .personalInfoEditorSheet(
            title: "Edit Personal Info",
            editor: $editor
        ) {
            onSave(editor.name)
        }
    }
}

struct AddPersonalInfo: View {
    let onSave: (String) -> Void
    
    @State private var editor = PersonalInfoEditor()
    
    var body: some View {
        Button {
            editor.present(name: "")
        } label: {
            Label("Add Item", systemImage: "plus")
        }
        .personalInfoEditorSheet(
            title: "Add Personal Info",
            editor: $editor
        ) {
            onSave(editor.name)
        }
    }
}

// MARK: - 5. Host View
struct ContentView: View {
    @State var people = [Person(name: "Jim")]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(people) { person in
                    NavigationLink(person.name, value: person.id)
                }
            }
            .navigationDestination(for: Person.ID.self) { personID in
                if let $person = $people[id: personID] {
                    Form {
                        Text(verbatim: $person.wrappedValue.name)
                    }
                    .toolbar {
                        EditPersonalInfo(
                            initialName: $person.wrappedValue.name
                        ) { updatedName in
                            $person.wrappedValue.name = updatedName
                        }
                    }
                } else {
                    ContentUnavailableView("Person Not Found", systemImage: "person.slash")
                }
            }
            .navigationTitle("Items")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    AddPersonalInfo { newName in
                        people.append(Person(name: newName))
                    }
                }
            }
        }
    }
}
Categories: SwiftUI