'Multiple sheet(isPresented:) doesn't work in SwiftUI

I have this ContentView with two different modal views, so I'm using sheet(isPresented:) for both, but as it seems only the last one gets presented. How could I solve this issue? Or is it not possible to use multiple sheets on a view in SwiftUI?

struct ContentView: View {
    
    @State private var firstIsPresented = false
    @State private var secondIsPresented = false
    
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Button("First modal view") {
                    self.firstIsPresented.toggle()
                }
                Button ("Second modal view") {
                    self.secondIsPresented.toggle()
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
            .sheet(isPresented: $firstIsPresented) {
                    Text("First modal view")
            }
            .sheet(isPresented: $secondIsPresented) {
                    Text("Only the second modal view works!")
            }
        }
    }
}

The above code compiles without warnings (Xcode 11.2.1).



Solution 1:[1]

UPD

Starting from Xcode 12.5.0 Beta 3 (3 March 2021) this question makes no sense anymore as it is possible now to have multiple .sheet(isPresented:) or .fullScreenCover(isPresented:) in a row and the code presented in the question will work just fine.

Nevertheless I find this answer still valid as it organizes the sheets very well and makes the code clean and much more readable - you have one source of truth instead of a couple of independent booleans

The actual answer

Best way to do it, which also works for iOS 14:

enum ActiveSheet: Identifiable {
    case first, second
    
    var id: Int {
        hashValue
    }
}

struct YourView: View {
    @State var activeSheet: ActiveSheet?

    var body: some View {
        VStack {
            Button {
                activeSheet = .first
            } label: {
                Text("Activate first sheet")
            }

            Button {
                activeSheet = .second
            } label: {
                Text("Activate second sheet")
            }
        }
        .sheet(item: $activeSheet) { item in
            switch item {
            case .first:
                FirstView()
            case .second:
                SecondView()
            }
        }
    }
}

Read more here: https://developer.apple.com/documentation/swiftui/view/sheet(item:ondismiss:content:)

To hide the sheet just set activeSheet = nil

Bonus: If you want your sheet to be fullscreen, then use the very same code, but instead of .sheet write .fullScreenCover

Solution 2:[2]

Please try below code

Update Answer (iOS 14, Xcode 12)

enum ActiveSheet {
   case first, second
   var id: Int {
      hashValue
   }
}

struct ContentView: View {

    @State private var showSheet = false
    @State private var activeSheet: ActiveSheet? = .first

    var body: some View {
    
        NavigationView {
            VStack(spacing: 20) {
                Button("First modal view") {
                    self.showSheet = true
                    self.activeSheet = .first
                }
                Button ("Second modal view") {
                    self.showSheet = true
                    self.activeSheet = .second
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
            .sheet(isPresented: $showSheet) {
                if self.activeSheet == .first {
                    Text("First modal view")
                }
                else {
                    Text("Only the second modal view works!")
                }
            }
        }
    }
}

Solution 3:[3]

Can also add the sheet to an EmptyView placed in the view's background. This can be done multiple times:

  .background(EmptyView()
        .sheet(isPresented: isPresented, content: content))

Solution 4:[4]

You're case can be solved by the following (tested with Xcode 11.2)

var body: some View {

    NavigationView {
        VStack(spacing: 20) {
            Button("First modal view") {
                self.firstIsPresented.toggle()
            }
            .sheet(isPresented: $firstIsPresented) {
                    Text("First modal view")
            }
            Button ("Second modal view") {
                self.secondIsPresented.toggle()
            }
            .sheet(isPresented: $secondIsPresented) {
                    Text("Only the second modal view works!")
            }
        }
        .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
    }
}

backup

Solution 5:[5]

You can accomplish this simply by grouping the button and the .sheet calls together. If you have one leading and one trailing it is that simple. However, if you have multiple navigationbaritems in either the leading or trailing you need to wrap them in an HStack and also wrap each button with its sheet call in a VStack.

Here's an example of two trailing buttons:

            trailing:
            HStack {
                VStack {
                    Button(
                        action: {
                            self.showOne.toggle()
                    }
                    ) {
                        Image(systemName: "camera")
                    }
                    .sheet(isPresented: self.$showOne) {
                        OneView().environment(\.managedObjectContext, self.managedObjectContext)
                    }
                }//showOne vstack

                VStack {
                    Button(
                        action: {
                            self.showTwo.toggle()
                    }
                    ) {
                        Image(systemName: "film")
                    }
                    .sheet(isPresented: self.$showTwo) {
                        TwoView().environment(\.managedObjectContext, self.managedObjectContext)
                    }
                }//show two vstack
            }//nav bar button hstack

Solution 6:[6]

Creating custom Button view and call sheet in it solve this problem.

struct SheetButton<Content>: View where Content : View {

    var text: String
    var content: Content
    @State var isPresented = false

    init(_ text: String, @ViewBuilder content: () -> Content) {
        self.text = text
        self.content = content()
    }

    var body: some View {
        Button(text) {
            self.isPresented.toggle()
        }
        .sheet(isPresented: $isPresented) {
            self.content
        }
    }
}

The ContentView will be more cleaner.

struct ContentView: View {

    var body: some View {

        NavigationView {
            VStack(spacing: 20) {
                SheetButton("First modal view") {
                    Text("First modal view")
                }
                SheetButton ("Second modal view") {
                    Text("Only the second modal view works!")
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
        }
    }
}

This method also works fine when opening sheets depends on List row content.

struct ContentView: View {

    var body: some View {

        NavigationView {
            List(1...10, id: \.self) { row in
                SheetButton("\(row) Row") {
                    Text("\(row) modal view")
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
        }
    }
}

Solution 7:[7]

As of iOS & iPadOS 14.5 Beta 3, and whenever they will be publicly released, multiple sheets will work as expected and none of the workarounds in the other answers will be needed. From the release notes:

SwiftUI

Resolved in iOS & iPadOS 14.5 Beta 3

You can now apply multiple sheet(isPresented:onDismiss:content:) and fullScreenCover(item:onDismiss:content:) modifiers in the same view hierarchy. (74246633)

Solution 8:[8]

In addition to Rohit Makwana's answer, I found a way to extract the sheet content to a function because the compiler was having a hard time type-checking my gigantic View.

extension YourView {
    enum Sheet {
        case a, b
    }

    @ViewBuilder func sheetContent() -> some View {
        if activeSheet == .a {
            A()
        } else if activeSheet == .b {
            B()
        }
    }
}

You can use it this way:

.sheet(isPresented: $isSheetPresented, content: sheetContent)

It makes the code cleaner and also relieves the stress of your compiler.

Solution 9:[9]

I know that this question already has many answers, but I found another possible solution to this problem that I find extremely useful. It is wrapping sheets inside if statements like this. For action sheets, I find that using other solutions here (like wrapping each sheet and its button inside a group) inside a scroll view on the iPad often makes action sheets go to weird places so this answer will fix that problem for action sheets inside scroll views on the iPad.

struct ContentView: View{
    @State var sheet1 = false
    @State var sheet2 = false
    var body: some View{
        VStack{
            Button(action: {
                self.sheet1.toggle()
            },label: {
                Text("Sheet 1")
            }).padding()
            Button(action: {
                self.sheet2.toggle()
            },label: {
                Text("Sheet 2")
            }).padding()
        }
        if self.sheet1{
            Text("")
                .sheet(isPresented: self.$sheet1, content: {
                    Text("Some content here presenting sheet 1")
                })
        }
        if self.sheet2{
            Text("")
                .sheet(isPresented: self.$sheet2, content: {
                    Text("Some content here presenting sheet 2")
                })
        }

    }
}

Solution 10:[10]

This solution is working for iOS 14.0

This solution is using .sheet(item:, content:) construct

struct ContentView: View {
    enum ActiveSheet: Identifiable {
        case First, Second
        
        var id: ActiveSheet { self }
    }
    
    @State private var activeSheet: ActiveSheet?

    var body: some View {

        NavigationView {
            VStack(spacing: 20) {
                Button("First modal view") {
                    activeSheet = .First
                }
                Button ("Second modal view") {
                    activeSheet = .Second
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
            .sheet(item: $activeSheet) { sheet in
                switch sheet {
                case .First:
                    Text("First modal view")
                case .Second:
                    Text("Only the second modal view works!")
                }
            }
        }
    }
}

Solution 11:[11]

This is an example which shows the use of 4 sheets, 1 (or more) alerts, and an actionSheet in the same ContentView. OK in iOS 13, iOS 14. OK in Preview

(From comments:) The purpose is the use of sheet(item:onDismiss:content:) with item as @State var, and values defined in an enum. With that, all the "business" is self.contained in the ContentView. In that manner, the number of sheets or alerts is not limited.

Here is the output of the below code:

All in one

import SwiftUI

// exemple which show use of 4 sheets, 
// 1 (or more) alerts, 
// and an actionSheet in the same ContentView
// OK in iOS 13, iOS 14
// OK in Preview

// Any number of sheets, displayed as Views
// can be used for sheets in other views (with unique case values, of course)
enum SheetState {
    case none
    case AddItem
    case PickPhoto
    case DocPicker
    case ActivityController
}

// Make Identifiable
extension SheetState: Identifiable {
    var id: SheetState { self }
}

// the same for Alerts (who are not View, but Alert)
enum AlertState {
    case none
    case Delete
}

extension AlertState: Identifiable {
    var id: AlertState { self }
}

struct ContentView: View {

// Initialized with nil value
@State private var sheetState: SheetState?
@State private var alertState: AlertState?

var body: some View {
    NavigationView {
        Form {
            Text("Hello, world!")
            Section(header: Text("sheets")) {
                addItemButton
                pickDocumentButton
                pickPhoto
                buttonExportView
            }
            Section(header: Text("alert")) {
                confirmDeleteButton
            }
            Section(header: Text("Action sheet")) {
                showActionSheetButton
            }
        }
        .navigationTitle("Sheets & Alerts")
                    
        // ONLY ONE call .sheet(item: ... with required value in enum
        // if item become not nil => display sheet
        // when dismiss sheet (drag the modal view, or use presentationMode.wrappedValue.dismiss in Buttons) => item = nil
        // in other way : if you set item to nil => dismiss sheet
                    
        // in closure, look for which item value display which view
        // the "item" returned value contains the value passed in .sheet(item: ...
        .sheet(item: self.$sheetState) { item in
            if item == SheetState.AddItem {
                addItemView // SwiftUI view
            } else if item == SheetState.DocPicker {
                documentPickerView // UIViewControllerRepresentable
            } else if item == SheetState.PickPhoto {
                imagePickerView // UIViewControllerRepresentable
            } else if item == SheetState.ActivityController {
                activityControllerView // UIViewControllerRepresentable
            }
            
        }
        
        .alert(item: self.$alertState) { item in
            if item == AlertState.Delete {
                return deleteAlert
            } else {
                // Not used, but seem to be required
                // .alert(item: ... MUST return an Alert
                return noneAlert
            }
        }
    }
}

// For cleaner contents : controls, alerts and sheet views are "stocked" in private var

// MARK: - Sheet Views

private var addItemView: some View {
    Text("Add item").font(.largeTitle).foregroundColor(.blue)
    // drag the modal view set self.sheetState to nil
}

private var documentPickerView: some View {
    DocumentPicker() { url in
        if url != nil {
            DispatchQueue.main.async {
                print("url")
            }
        }
        self.sheetState = nil
        // make the documentPicker view dismissed
    }
}

private var imagePickerView: some View {
    ImagePicker() { image in
        if image != nil {
            DispatchQueue.main.async {
                self.logo = Image(uiImage: image!)
            }
        }
        self.sheetState = nil
    }
}

private var activityControllerView: some View {
    ActivityViewController(activityItems: ["Message to export"], applicationActivities: [], excludedActivityTypes: [])
}

// MARK: - Alert Views

private var deleteAlert: Alert {
    Alert(title: Text("Delete?"),
          message: Text("That cant be undone."),
          primaryButton: .destructive(Text("Delete"), action: { print("delete!") }),
          secondaryButton: .cancel())
}

private var noneAlert: Alert {
    Alert(title: Text("None ?"),
          message: Text("No action."),
          primaryButton: .destructive(Text("OK"), action: { print("none!") }),
          secondaryButton: .cancel())
}

// In buttons, action set value in item for .sheet(item: ...
// Set self.sheetState value make sheet displayed
// MARK: - Buttons

private var addItemButton: some View {
    Button(action: { self.sheetState = SheetState.AddItem }) {
        HStack {
            Image(systemName: "plus")
            Text("Add an Item")
        }
    }
}

private var pickDocumentButton: some View {
    Button(action: { self.sheetState = SheetState.DocPicker }) {
        HStack {
            Image(systemName: "doc")
            Text("Choose Document")
        }
    }
}

@State private var logo: Image = Image(systemName: "photo")
private var pickPhoto: some View {
    ZStack {
        HStack {
            Text("Pick Photo ->")
            Spacer()
        }
        HStack {
            Spacer()
            logo.resizable().scaledToFit().frame(height: 36.0)
            Spacer()
        }
    }
    .onTapGesture { self.sheetState = SheetState.PickPhoto }
}

private var buttonExportView: some View {
    Button(action: { self.sheetState = SheetState.ActivityController }) {
        HStack {
            Image(systemName: "square.and.arrow.up").imageScale(.large)
            Text("Export")
        }
    }
}

private var confirmDeleteButton: some View {
    Button(action: { self.alertState = AlertState.Delete}) {
        HStack {
            Image(systemName: "trash")
            Text("Delete!")
        }.foregroundColor(.red)
    }
}

@State private var showingActionSheet = false
@State private var foregroundColor = Color.blue
private var showActionSheetButton: some View {
    Button(action: { self.showingActionSheet = true }) {
        HStack {
            Image(systemName: "line.horizontal.3")
            Text("Show Action Sheet")
        }.foregroundColor(foregroundColor)
    }
    .actionSheet(isPresented: $showingActionSheet) {
        ActionSheet(title: Text("Change foreground"), message: Text("Select a new color"), buttons: [
            .default(Text("Red")) { self.foregroundColor = .red },
            .default(Text("Green")) { self.foregroundColor = .green },
            .default(Text("Blue")) { self.foregroundColor = .blue },
            .cancel()
        ])
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Solution 12:[12]

This worked well for my App with three sheet presentation possibilities on iOS 13.x. Funny behavior began with iOS 14. For some reason on app launch when I select a sheet to be presented the state variables do not get set and the sheet appears with a blank screen. If I keep selecting the first choice it continues to present a blank sheet. As soon as I select a second choice (different from the first) the variables are set and the proper sheet presents. It doesn't matter which sheet I select first, the same bahavior happens.

Bug?? or am I missing something. My code is almost identicle to the above except for 3 sheet options and I have a custom button that takes an argument, () -> Void, to run when the button is pressed. Works great in iOS 13.x but not in iOS 14.

Dave

Solution 13:[13]

Edit: as of iOS 14.5 beta 3 this is now fixed:

SwiftUI Resolved in iOS & iPadOS 14.5 Beta 3

  • You can now apply multiple sheet(isPresented:onDismiss:content:) and fullScreenCover(item:onDismiss:content:) modifiers in the same view hierarchy. (74246633)

Before the fix, a workaround was to apply the sheet modifier to each Button:

struct ContentView: View {

    @State private var firstIsPresented = false
    @State private var secondIsPresented = false

    var body: some View {

        NavigationView {
            VStack(spacing: 20) {
                Button("First modal view") {
                    self.firstIsPresented.toggle()
                }
                .sheet(isPresented: $firstIsPresented) {
                        Text("First modal view")
                }

                Button ("Second modal view") {
                    self.secondIsPresented.toggle()
                }
                .sheet(isPresented: $secondIsPresented) {
                    Text("Second modal view")
                }
            }
            .navigationBarTitle(Text("Multiple modal view problem"), displayMode: .inline)
        }
    }
}

Since the sheets both do the same thing you could extract that duplicated functionality to a sub View:

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                ShowSheet(title:"First modal view")
                ShowSheet(title:"Second modal view")
            }
            .navigationBarTitle(Text("Multiple modal view no problem!"), displayMode: .inline)
        }
    }
}

struct ShowSheet: View {
    @State private var isPresented = false
    let title: String
    var body: some View {
        Button(title) {
            isPresented.toggle()
        }
        .sheet(isPresented: $isPresented) {
            Text(title)
        }
    }
}

Solution 14:[14]

The accepted solution works great, but I wanted to share an additional augmentation just in case anyone else runs into this same problem.

My problem

I was having an issue where two buttons were acting as one. Pairing two buttons together, transformed the entire VStack or HStack into a single, large button. This was only allowing one .sheet to fire, regardless of using the accepted.

Solution

The answer here acted as the missing piece of the puzzle for me.

Adding either .buttonStyle(BorderlessButtonStyle()) or .buttonStyle(PlainButtonStyle()) to each button, made them act as single buttons as you would expect.

Sorry if I committed any faux pas by adding this here, but this is my first time posting on StackOverlflow.

Solution 15:[15]

Another simple way to display many sheets in one view :

Each view private var has its own Bool @State value and .sheet(isPresented: ... call

Simple to implement, all necessary in one place. OK in iOS 13, iOS 14, Preview

import SwiftUI

struct OtherContentView: View {
    var body: some View {
        Form {
            Section {
                button1
            }
            Section {
                button2
            }
            Section {
                button3
            }
            Section {
                button4
            }
        }
    }
    
    @State private var showSheet1 = false
    private var button1: some View {
        Text("Sheet 1")
            .onTapGesture { showSheet1 = true }
            .sheet(isPresented: $showSheet1) { Text("Modal Sheet 1") }
    }
    
    @State private var showSheet2 = false
    private var button2: some View {
        Text("Sheet 2")
            .onTapGesture { showSheet2 = true }
            .sheet(isPresented: $showSheet2) { Text("Modal Sheet 2") }
    }
    
    @State private var showSheet3 = false
    private var button3: some View {
        Text("Sheet 3")
            .onTapGesture { showSheet3 = true }
            .sheet(isPresented: $showSheet3) { Text("Modal Sheet 3") }
    }
    
    @State private var showSheet4 = false
    private var button4: some View {
        Text("Sheet 4")
            .onTapGesture { showSheet4 = true }
            .sheet(isPresented: $showSheet4) { Text("Modal Sheet 4") }
    }
}

struct OtherContentView_Previews: PreviewProvider {
    static var previews: some View {
        OtherContentView()
    }
}

Solution 16:[16]

I solved the messiness of @State and multiple sheets by creating an observable SheetContext that holds and manages the state for me. I then only need a single context instance and can tell it to present any view as a sheet.

I describe it in more details in this blog post: https://danielsaidi.com/blog/2020/06/06/swiftui-sheets

Solution 17:[17]

I don't think that is the right way for SwiftUI to present any kind of view.

The paradigm works by creating specific views that show some content on the screen, so you can have more than one view inside the body of superview that needs to present something. So the SwiftUI 2, on iOS 14, will not accept that and the developer should call all presentations in the superview that can be accepted in some cases, but will have moments that will be better if the specific views present the content.

I implemented a solution for that and test on Swift 5.3 with Xcode 12.1 on iOS 14.1

struct Presentation<Content>: View where Content: View {
    enum Style {
        case sheet
        case popover
        case fullScreenCover
    }

    @State private var isTrulyPresented: Bool = false
    @State private var willPresent: Bool = false
    @Binding private var isPresented: Bool

    let content: () -> Content
    let dismissHandler: (() -> Void)?
    let style: Style

    init(_ style: Style, _ isPresented: Binding<Bool>, onDismiss: (() -> Void)?, content: @escaping () -> Content) {
        self._isPresented = isPresented
        self.content = content
        self.dismissHandler = onDismiss
        self.style = style
    }

    @ViewBuilder
    var body: some View {
        if !isPresented && !willPresent {
            EmptyView()
        } else {
            switch style {
            case .sheet:
                EmptyView()
                    .sheet(isPresented: $isTrulyPresented, onDismiss: dismissHandler, content: dynamicContent)
            case .popover:
                EmptyView()
                    .popover(isPresented: $isTrulyPresented, content: dynamicContent)
            case .fullScreenCover:
                EmptyView()
                    .fullScreenCover(isPresented: $isTrulyPresented, onDismiss: dismissHandler, content: dynamicContent)
            }
        }
    }
}

extension Presentation {
    var dynamicContent: () -> Content {
        if isPresented && !isTrulyPresented {
            OperationQueue.main.addOperation {
                willPresent = true
                OperationQueue.main.addOperation {
                    isTrulyPresented = true
                }
            }
        } else if isTrulyPresented && !isPresented {
            OperationQueue.main.addOperation {
                isTrulyPresented = false
                OperationQueue.main.addOperation {
                    willPresent = false
                }
            }
        }

        return content
    }
}

After that, I can implement these methods for all views in SwiftUI

public extension View {
    func _sheet<Content>(
        isPresented: Binding<Bool>,
        content: @escaping () -> Content
    ) -> some View where Content: View {

        self.background(
            Presentation(
                .sheet,
                isPresented,
                onDismiss: nil,
                content: content
            )
        )
    }

    func _sheet<Content>(
        isPresented: Binding<Bool>,
        onDismiss: @escaping () -> Void,
        content: @escaping () -> Content
    ) -> some View where Content: View {

        self.background(
            Presentation(
                .sheet,
                isPresented,
                onDismiss: onDismiss,
                content: content
            )
        )
    }
}

public extension View {
    func _popover<Content>(
        isPresented: Binding<Bool>,
        content: @escaping () -> Content
    ) -> some View where Content: View {

        self.background(
            Presentation(
                .popover,
                isPresented,
                onDismiss: nil,
                content: content
            )
        )
    }
}

public extension View {
    func _fullScreenCover<Content>(
        isPresented: Binding<Bool>,
        content: @escaping () -> Content
    ) -> some View where Content: View {

        self.background(
            Presentation(
                .fullScreenCover,
                isPresented,
                onDismiss: nil,
                content: content
            )
        )
    }

    func _fullScreenCover<Content>(
        isPresented: Binding<Bool>,
        onDismiss: @escaping () -> Void,
        content: @escaping () -> Content
    ) -> some View where Content: View {

        self.background(
            Presentation(
                .fullScreenCover,
                isPresented,
                onDismiss: onDismiss,
                content: content
            )
        )
    }
}

Solution 18:[18]

In addition to the above answers

  1. You can replace the old sheet if two sheets have sequential relationships
    import SwiftUI
    struct Sheet1: View {
        @Environment(\.dismiss) private var dismiss
        @State var text: String = "Text"
        
        var body: some View {
            
            Text(self.text)
            if self.text == "Modified Text" {
                Button {
                    dismiss()
                } label: {
                    Text("Close sheet")
                }
            } else {
                Button {
                    self.text = "Modified Text"
                } label: {
                    Text("Modify Text")
                }
            }
        }
    }
    struct SheetTester: View {
        @State private var isShowingSheet1 = false
        
        var body: some View {
            Button(action: {
                isShowingSheet1.toggle()
            }) {
                Text("Show Sheet1")
            }
            .sheet(isPresented: $isShowingSheet1) {
                Sheet1()
            }
        }
    }

Or 2. Use two sheets parallel

    struct SheetTester: View {
        @State private var isShowingSheet1 = false
        var body: some View {
                Button(action: {
                    isShowingSheet1.toggle()
                }) {
                    Text("Show Sheet1")
                }
                .sheet(isPresented: $isShowingSheet1) {
                    Text("Sheet1")
                    Button {
                        isShowingSheet1.toggle()
                        isShowingSheet2.toggle()
                    } label: {
                        Text("Show Sheet2")
                    }
                }
                .sheet(isPresented: $isShowingSheet2) {
                    Text("Sheet2")
                }
            }
        }
    }

Solution 19:[19]

Bit late to this party, but none of the answers so far have addressed the possibility of having a viewModel do the work. As I'm by no means an expert at SwiftUI (being pretty new to it), it's entirely possible that there may be better ways of doing this, but the solution I reached is here -

enum ActiveSheet: Identifiable {
    case first
    case second
        
    var id: ActiveSheet { self }
}

struct MyView: View {

    @ObservedObject private var viewModel: MyViewModel

    private var activeSheet: Binding<ActiveSheet?> {
        Binding<ActiveSheet?>(
            get: { viewModel.activeSheet },
            set: { viewModel.activeSheet = $0 }
        )
    }

    init(viewModel: MyViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {

        HStack {
            /// some views
        }
        .onTapGesture {
            viewModel.doSomething()
        }
        .sheet(item: activeSheet) { _ in
            viewModel.activeSheetView()
        }
    }
}

...and in the viewModel -

    @Published var activeSheet: ActiveSheet?

    func activeSheetView() -> AnyView {
        
        switch activeSheet {
        case .first:
            return AnyView(firstSheetView())
        case .second:
            return AnyView(secondSheetView())
        default:
            return AnyView(EmptyView())
        }
    }

    // call this from the view, eg, when the user taps a button
    func doSomething() {
        activeSheet = .first // this will cause the sheet to be presented
    }

where firstSheetView() & secondSheetView() are providing the required actionSheet content.

I like this approach as it keeps all the business logic out of the views.