37

I'm trying to setup a view that can display multiple modals depending on which button is tapped.

When I add just one sheet, everything works:

.sheet(isPresented: $showingModal1) { ... }

But when I add another sheet, only the last one works.

.sheet(isPresented: $showingModal1) { ... }
.sheet(isPresented: $showingModal2) { ... }

UPDATE

I tried to get this working, but I'm not sure how to declare the type for modal. I'm getting an error of Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements.

struct ContentView: View {
    @State var modal: View?
    var body: some View {
        VStack {
            Button(action: {
                self.modal = ModalContentView1()
            }) {
                Text("Show Modal 1")
            }
            Button(action: {
                self.modal = ModalContentView2()
            }) {
                Text("Show Modal 2")
            }
        }.sheet(item: self.$modal, content: { modal in
            return modal
        })
    }
}

struct ModalContentView1: View {
    var body: some View {
        Text("Modal 1")
    }
}

struct ModalContentView2: View {
    var body: some View {
        Text("Modal 2")
    }
}
keegan3d
  • 10,357
  • 9
  • 53
  • 77
  • 6
    @Alexander, how are you helping out? To the OP, whether your approach is anything good... are you asking a beta 4 question? A lot changed, and your code suggests that. BUT - to me, the *real* question (if I'm accurate) is did you have things working in beta 3> –  Jul 19 '19 at 00:03
  • @dfd Preface: I'm not sure if the code 1) sets up 2 different model callbacks, to be triggered by different events, at different times, or 2) sets up a modal callback, and sets up another modal callback on the callback, which causes a second modal to show over the first. When I wrote my comment, I thought it was #2, but looking back on it now, I think it might be #1, but I can't find good documentation on it – Alexander Jul 19 '19 at 01:12
  • @dfd Given that I thought #1 was occurring, and that #1 would be bad UX design, I was nudging OP away from that option, which has the added side-effect of side-stepping this question entirely. – Alexander Jul 19 '19 at 01:13
  • 3
    My intention is to only show one modal at a time. I have multiple buttons on screen, each one shows a different modal. I did have this working in Beta 3 because I could pass a function to `.presentation` that would return the appropriate modal to show, or nil if no modal needed to be shown. I can't seem to find a way to do this now in Beta 4. – keegan3d Jul 19 '19 at 01:50
  • Does this answer your question? [Multiple sheet(isPresented:) doesn't work in SwiftUI](https://stackoverflow.com/questions/58837007/multiple-sheetispresented-doesnt-work-in-swiftui) – Curiosity Mar 02 '21 at 23:07

8 Answers8

41

This works:

.background(EmptyView().sheet(isPresented: $showingModal1) { ... }
   .background(EmptyView().sheet(isPresented: $showingModal2) { ... }))

Notice how these are nested backgrounds. Not two backgrounds one after the other.

Thanks to DevAndArtist for finding this.

plivesey
  • 2,337
  • 1
  • 16
  • 18
  • 5
    Excellent!! Works like a charm! – Mycroft Canner Oct 16 '19 at 03:40
  • 5
    great trick, I'm not sure if this should be the proper way to solve this, but it keep so clean. – Andrea Miotto Nov 21 '19 at 08:24
  • 1
    I think this is the most reliable way, especially if you have nested views with their own sheet modifiers. It seems that the outermost sheet in a view tree takes precedence, thus using `.sheet(item:)` wouldn't work either in such cases. – bcause Jun 06 '20 at 17:58
  • 1
    After testing many different methods (ie children views with sheet modifiers, switch case in sheet modifier, throwing extra sheet modifiers in navigation buttons) this seems to be functional and the most reliable as of iOS 14 Beta 2 – Ever Uribe Jul 15 '20 at 18:57
  • 2
    I wrote a small library off this answer that greatly simplifies the syntax https://github.com/davdroman/MultiSheet – David Roman Oct 22 '20 at 22:29
  • This is a brilliant solution. Thanks a ton! – Nate Apr 14 '21 at 06:55
  • Amazing. Also this works with FullScreenCover. Best answer. – Biks Sep 21 '21 at 10:39
31

Maybe I missed the point, but you can achieve it either with a single call to .sheet(), or multiple calls.:

Multiple .sheet() approach:

import SwiftUI

struct MultipleSheets: View {
    @State private var sheet1 = false
    @State private var sheet2 = false
    @State private var sheet3 = false

    var body: some View {
        VStack {

            Button(action: {
                self.sheet1 = true
            }, label: { Text("Show Modal #1") })
            .sheet(isPresented: $sheet1, content: { Sheet1() })

            Button(action: {
                self.sheet2 = true
            }, label: { Text("Show Modal #2") })
            .sheet(isPresented: $sheet2, content: { Sheet2() })

            Button(action: {
                self.sheet3 = true
            }, label: { Text("Show Modal #3") })
            .sheet(isPresented: $sheet3, content: { Sheet3() })

        }
    }
}

struct Sheet1: View {
    var body: some View {
        Text("This is Sheet #1")
    }
}

struct Sheet2: View {
    var body: some View {
        Text("This is Sheet #2")
    }
}

struct Sheet3: View {
    var body: some View {
        Text("This is Sheet #3")
    }
}

Single .sheet() approach:

struct MultipleSheets: View {
    @State private var showModal = false
    @State private var modalSelection = 1

    var body: some View {
        VStack {

            Button(action: {
                self.modalSelection = 1
                self.showModal = true
            }, label: { Text("Show Modal #1") })

            Button(action: {
                self.modalSelection = 2
                self.showModal = true
            }, label: { Text("Show Modal #2") })

            Button(action: {
                self.modalSelection = 3
                self.showModal = true
            }, label: { Text("Show Modal #3") })

        }
        .sheet(isPresented: $showModal, content: {
            if self.modalSelection == 1 {
                Sheet1()
            }

            if self.modalSelection == 2 {
                Sheet2()
            }

            if self.modalSelection == 3 {
                Sheet3()
            }
        })

    }
}

struct Sheet1: View {
    var body: some View {
        Text("This is Sheet #1")
    }
}

struct Sheet2: View {
    var body: some View {
        Text("This is Sheet #2")
    }
}

struct Sheet3: View {
    var body: some View {
        Text("This is Sheet #3")
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • 4
    Hmm, the first solution isn't working for me. Once the first modal is shown, none of the other buttons work to show the other modals – keegan3d Jul 19 '19 at 14:48
  • That's weird. It works for me. Are you by any chance inside a NavigationView? or List? I think I remember seeing that problem under those conditions. And also, does the second solution work for you? – kontiki Jul 19 '19 at 14:53
  • 4
    This works for me too. However the problem seems to be when you call `.sheet()` on a parent view that has any child views already calling `.sheet()`. So in the first example @kontiki posted, calling `.sheet()` on the `VStack` doesn't allow for `.sheet()` to be called for the `Button` views – Liam Jul 19 '19 at 22:48
  • 2
    This works and does not seem to be a work-around solution. It never dawned on me to use the .sheet modifier on the buttons themselves. – P. Ent Nov 11 '19 at 20:03
  • 7
    This no longer works on iOS 14. In first case, the last sheet modifier in the view will override all others (ie third button sheet). In second case, the default sheet (ie modalSelection of 1) will load on the first button tap, even if modalSelection of 2 is selected. – Ever Uribe Jul 15 '20 at 18:51
16

I'm not sure whether this was always possible, but in Xcode 11.3.1 there is an overload of .sheet() for exactly this use case (https://developer.apple.com/documentation/swiftui/view/3352792-sheet). You can call it with an Identifiable item instead of a bool:

struct ModalA: View {

    var body: some View {
        Text("Hello, World! (A)")
    }

}

struct ModalB: View {

    var body: some View {
        Text("Hello, World! (B)")
    }

}

struct MyContentView: View {

    enum Sheet: Hashable, Identifiable {

        case a
        case b

        var id: Int {
            return self.hashValue
        }

    }

    @State var activeSheet: Sheet? = nil

    var body: some View {
        VStack(spacing: 42) {
            Button(action: {
                self.activeSheet = .a
            }) {
                Text("Hello, World! (A)")
            }
            Button(action: {
                self.activeSheet = .b
            }) {
                Text("Hello, World! (B)")
            }
        }
            .sheet(item: $activeSheet) { item in
                if item == .a {
                    ModalA()
                } else if item == .b {
                    ModalB()
                }
            }
    }

}
cargath
  • 832
  • 8
  • 18
2

I personally would mimic some NavigationLink API. Then you can create a hashable enum and decide which modal sheet you want to present.

extension View {
  func sheet<Content, Tag>(
    tag: Tag,
    selection: Binding<Tag?>,
    content: @escaping () -> Content
  ) -> some View where Content: View, Tag: Hashable {
    let binding = Binding(
      get: {
        selection.wrappedValue == tag
      },
      set: { isPresented in
        if isPresented {
          selection.wrappedValue = tag
        } else {
          selection.wrappedValue = .none
        }
      }
    )
    return background(EmptyView().sheet(isPresented: binding, content: content))
  }
}

enum ActiveSheet: Hashable {
  case first
  case second
}

struct First: View {
  var body: some View {
    Text("frist")
  }
}

struct Second: View {
  var body: some View {
    Text("second")
  }
}

struct TestView: View {
  @State
  private var _activeSheet: ActiveSheet?

  var body: some View {
    print(_activeSheet as Any)
    return VStack
      {
        Button("first") {
          self._activeSheet = .first
        }
        Button("second") {
          self._activeSheet = .second
        }
      }
      .sheet(tag: .first, selection: $_activeSheet) {
        First()
      }
      .sheet(tag: .second, selection: $_activeSheet) {
        Second()
      }
  }
}
DevAndArtist
  • 4,971
  • 1
  • 23
  • 48
1

I wrote a library off plivesey's answer that greatly simplifies the syntax:

.multiSheet {
    $0.sheet(isPresented: $sheetAPresented) { Text("Sheet A") }
    $0.sheet(isPresented: $sheetBPresented) { Text("Sheet B") }
    $0.sheet(isPresented: $sheetCPresented) { Text("Sheet C") }
}
David Roman
  • 2,548
  • 2
  • 16
  • 16
0

I solved this by creating an observable SheetContext that holds and manages the state. I then only need a single context instance and can tell it to present any view as a sheet. I prefer this to the "active view" binding approach, since you can use this context in multiple ways.

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

Daniel Saidi
  • 6,079
  • 4
  • 27
  • 29
0

I think i found THE solution. It's complicated so here is the teaser how to use it:

Button(action: {
    showModal.wrappedValue = ShowModal {
        AnyView( TheViewYouWantToPresent() )
    }
})

Now you can define at the button level what you want to present. And the presenting view does not need to know anything. So you call this on the presenting view.

.background(EmptyView().show($showModal))

We call it on the background so the main view does not need to get updated, when $showModal changes.

Ok so what do we need to get this to work?

1: The ShowModal class:

public enum ModalType{
    case sheet, fullscreen
}
public struct ShowModal: Identifiable {
    public let id = ""
    public let modalType: ModalType
    public let content: () -> AnyView
    
    public init (modalType: ModalType = .sheet, @ViewBuilder content: @escaping () -> AnyView){
        self.modalType = modalType
        self.content = content
    }
}

Ignore id we just need it for Identifiable. With modalType we can present the view as sheet or fullscreen. And content is the passed view, that will be shown in the modal.

2: A ShowModal binding which stores the information for presenting views:

@State var showModal: ShowModal? = nil

And we need to add it to the environment of the view thats responsible for presentation. So we have easy access to it down the viewstack:

VStack{
    InnerViewsThatWantToPresentModalViews()
}
.environment(\.showModal, $showModal)
.background(EmptyView().show($showModal))

In the last line we call .show(). Which is responsible for presentation.

Keep in mind that you have to create @State var showModal and add it to the environment again in a view thats shown modal and wants to present another modal.

4: To use .show we need to extend view:

public extension View {
    func show(_ modal: Binding<ShowModal?>) -> some View {
        modifier(VM_Show(modal))
    }
}

And add a viewModifier that handles the information passed in $showModal

public struct VM_Show: ViewModifier {
    var modal: Binding<ShowModal?>

    public init(_ modal: Binding<ShowModal?>) {
        self.modal = modal
    }
    
    public func body(content: Content) -> some View {
        guard let modalType = modal.wrappedValue?.modalType else{ return AnyView(content) }
        switch modalType {
        case .sheet:
            return AnyView(
                content.sheet(item: modal){ modal in
                    modal.content()
                }
            )
        case .fullscreen:
            return AnyView(
                content.fullScreenCover(item: modal) { modal in
                    modal.content()
                }
            )
        }
    }
}

4: Last we need to set showModal in views that want to present a modal:

Get the variable with: @Environment(\.showModal) var showModal. And set it like this:

Button(action: {
    showModal.wrappedValue = ShowModal(modalType: .fullscreen) {
        AnyView( TheViewYouWantToPresent() )
    }
})

In the view that defined $showModal you set it without wrappedValue: $showModal = ShowModal{...}

Ruben Helsloot
  • 12,582
  • 6
  • 26
  • 49
Andreas
  • 1,295
  • 1
  • 11
  • 13
0

As an alternative, simply putting a clear pixel somewhere in your layout might work for you:

Color.clear.frame(width: 1, height: 1, alignment: .center).sheet(isPresented: $showMySheet, content: {
     MySheetView();
})

Add as many pixels as necessary.

smakus
  • 1,107
  • 10
  • 11