keegan3d
keegan3d

Reputation: 11275

SwiftUI: Support multiple modals

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")
    }
}

Upvotes: 40

Views: 12546

Answers (8)

smakus
smakus

Reputation: 1417

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.

Upvotes: 0

Andreas
Andreas

Reputation: 1335

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{...}

Upvotes: 0

user1922543
user1922543

Reputation:

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") }
}

Upvotes: 1

Daniel Saidi
Daniel Saidi

Reputation: 6187

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

Upvotes: 0

cargath
cargath

Reputation: 842

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()
                }
            }
    }

}

Upvotes: 16

DevAndArtist
DevAndArtist

Reputation: 5149

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()
      }
  }
}

Upvotes: 1

plivesey
plivesey

Reputation: 2367

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.

Upvotes: 42

kontiki
kontiki

Reputation: 40519

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")
    }
}

Upvotes: 31

Related Questions