The Encapsulated Sheet Pattern: Leveraging Nested Modifiers for Strict Privacy
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— AViewModifiernested insidePersonalInfoEditorthat accesses private properties onPersonalInfoEditordirectly. - Modifier Extension:
.personalInfoEditorSheet(title:editor:onSave:)— A fluent extension onViewwrapping 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
- Strict Field Encapsulation:
isPresentedremains completelyprivate. Callers outside the source file cannot bypass initialization logic or manually force presentation without invokingpresent(name:). - Encapsulated View Modifier Namespace: Rather than polluting the global module scope with
PersonalInfoEditorSheet, the view modifier is cleanly scoped underPersonalInfoEditor.Sheet. - Cached Property Observers (
didSet): Computing validation flags insidedidSetensures 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))
}
}
}
}
}
}