For anyone familiar with UIKit and Interface Builder, SwiftUI can be a double-edged sword: on one hand, it’s wonderful how quickly and safely you can build UI; on the other, it can feel frustratingly brittle when common paradigms aren’t fully established.

Managing sheet presentation is one such area where good patterns don’t seem well documented—it’s easy to see how to use the API if you only need to present a single sheet, but the moment you want to present multiple sheets, things become less obvious.

For example, most documentation focuses on the sheet(isPresented:onDismiss:content:) API. This uses a boolean binding to determine whether the sheet should be shown. It looks something like this:

struct MainView: View {

  @State var showSheet: Bool = false

  var body: some View {
    VStack {
      Button(action: {
        self.showSheet = true
      }) {
        Text("Say Hello")
      }
    }
    .sheet(isPresented: $showSheet, onDismiss: {
      self.showSheet = false
    }) {
      NavigationView {
        HStack {
          Text("Hello, World")
        }
      }
    }
  }
}

Hopefully it’s easy to see how this works: pressing ‘Say Hello’ sets showSheet to true which causes the view hierarchy to be re-evaluated and the sheet to be displayed. Dismissing the sheet calls onDismiss which sets showSheet to false and the sheet is dismissed.

Taking this as a starting point, it makes complete sense to add additional state (an enum) to support multiple sheets and determine which should be displayed. For example,

enum SheetType {
    case hello
    case goodbye
}

This might then be used as follows:

struct MainView: View {

  @State var showSheet: Bool = false
  @State var sheet: SheetType = .hello

  var body: some View {
    VStack {
      Button(action: {
        self.showSheet = true
        self.sheet = .goodbye
      }) {
        Text("Say Goodbye")
      }
    }
    .sheet(isPresented: $showSheet, onDismiss: {
      self.showSheet = false
    }) {
      NavigationView {
        HStack {
          switch sheet {
            case .hello:
              Text("Hello, World")
            case .goodbye:
              Text("Goodbye, World")
          }
        }
      }
    }
  }
}

Unfortunately, this is messy: our UI state is now split across two variables that can get out of sync (and almost certainly will). At best we now need to handle updating these very carefully. (In my testing, I’ve found that even setting the two in the same callback as above doesn’t guarantee atomic behaviour.)

We could address these concerns by writing a custom Binding and a more involved onDismiss handler, but instead it’s better to make use of the other sheet API: sheet(item:onDismiss:content:). While this may look like it’s intended for displaying specific items (e.g., when tapping enumerated items in a list), it works well for our use-case: our enum perfectly describes the ‘item’ we wish to display.

In order to use an enum in this way, it’s necessary to conform it to Identifiable which allows SwiftUI to efficiently determine when the binding has changed:

extension SheetType: Identifiable {
    public var id: SheetType { self }
}

Having done this, we can use the alternative API:

struct MainView: View {

  @State var sheet: SheetType?

  var body: some View {
    VStack {
      Button(action: {
        self.sheet = .goodbye
      }) {
        Text("Say Goodbye")
      }
    }
    .sheet(item: $sheet, onDismiss: {
      sheet = nil
    }) { sheet in
      NavigationView {
        HStack {
          switch sheet {
            case .hello:
              Text("Hello, World")
            case .goodbye:
              Text("Goodbye, World")
          }
        }
      }
    }
  }
}

This approach ensures we have only one piece of state: nullability perfectly models the presence (or absence) of a sheet, and the different values of the enum describe the sheet to be displayed.

Using the enum in this way also permits for more complex sheets by using associated values. For example, I can store the name of the person I want to say goodbye to with an associated value:

enum SheetType {
    case hello
    case goodbye(name: String)
}

extension SheetType: Identifiable {
    public var id: String {
        switch self {
        case .hello:
            return "hello"
        case .goodbye(let name):
            return "goodbye:\(name)"
        }
    }
}

Putting this all together, and adding the final polish of omitting onDismiss (falling back on the default behaviour which will nil out our bound item), and adding a separate method to generate the sheet views, we get to:

struct MainView: View {

  @State var sheetType: SheetType?

  static func sheet(_ sheetType: SheetType) -> some View {
    switch sheetType {
      case .hello:
        return NavigationView {
          Text("Hello, World")
        }
      case .goodbye(let name):
        return NavigationView {
          Text("Goodbye, \(name)")
        }
    }
  }

  var body: some View {
    VStack {
      Button(action: {
        self.sheetType = .goodbye(name: "Apple")
      }) {
        Text("Say Goodbye")
      }
    }
    .sheet(item: $sheetType, content: MainView.sheet)
  }
}

I hope it proves helpful.