Phantom59
Phantom59

Reputation: 1119

sheet not showing when presented from button in Menu - SwiftUI

Still some what new to SwiftUI. Now I'm trying to present a sheet from a button in a Menu. I can reproduce the issue with the sample code below:

import SwiftUI

struct SheetView: View {
  @Environment(\.presentationMode) var presentationMode
  
  var body: some View {
    Button("Press to dismiss") {
      presentationMode.wrappedValue.dismiss()
    }
    .font(.title)
    .padding()
    .background(Color.black)
  }
}

struct TestButtonInMenu: View {
  @State private var showingSheet = false
  
  var body: some View {
    Button("Show Sheet") {
      showingSheet.toggle()
    }
    .sheet(isPresented: $showingSheet) {
      SheetView()
    }
  }
}

enum SampleEnum: String, CaseIterable {
  case one, two, three, four
}

struct ContentView: View {
  var body: some View {
    Form {
      Section {
        VStack  {
          ForEach(SampleEnum.allCases, id:\.self) { id in
            Menu("\(Text(id.rawValue))") {
              TestButtonInMenu()
            }
          }
        }
      }
    }
  }
}

I've tried different sheet initializers but they don't make a difference.

What am I missing? Is this possible in SwiftUI?

Upvotes: 6

Views: 3393

Answers (2)

Andrei G.
Andrei G.

Reputation: 1557

I came across this issue recently and found a way to solve it, although I haven't tested it extensively.

The issue here is two-fold:

  1. The Menu is in a loop, which in itself may cause redraw issues.
  2. Because the menu gets hidden once a button is pressed, there is no way for the .sheet inside the button to display (as part of a button that is in a menu that is no longer visible).

To make the buttons work in a menu, the sheet would have to be declared outside the Menu, and then a binding would need to be passed to the button to control the sheet. This would defy the purpose of what's been attempted here, which is to have a self-sufficient button that requires minimal setup in order to be added to a view.

The method I came up with allow buttons to set the view that should appear in a sheet, but without using a .sheet modifier. Instead, the buttons are used to configure the content of sheet and pass it to an observable class property:

//Define the content that should appear in the sheet here
let sheetContent = AnyView(
    SheetView(text:  "Dismiss sheet (\(text))")
)

//Button that sets the property of the observable class
Button(text) {
    sheetObserver.updateSheetContent(to: sheetContent)
}

This is the @Observable class:

//Observable class for showing sheet content dynamically
@Observable
class SheetObserver {
    
    //Properties
    var sheetContent: AnyView?
    var showContent: Bool = false
    
    //Singleton
    static let manager = SheetObserver()
    private init() {}
    
    func updateSheetContent(to content: AnyView) {
        sheetContent = content
        showContent.toggle() // Toggle to show the sheet
    }
}

Since the buttons don't have a .sheet modifier, it is added instead to a ViewModifier that observes the value of the observable class property and displays the sheet as needed:

struct DynamicSheetObserverModifier: ViewModifier {
    
    //Parameters
    var enabled: Bool = true
    
    //Bindings
    @Bindable var sheetObserver = SheetObserver.manager
    
    @State private var showSheet = false
    
    //Body content
    func body(content: Content) -> some View {
        
        Group {
            if enabled {
                content
                    .sheet(isPresented: $sheetObserver.showContent) {
                        if let sheetContent = sheetObserver.sheetContent {
                            sheetContent
                        }
                    }
            }
            else {
                content
            }
        }
    }
}

Then, to make everything work, a view extension modifier can be added to any stable view that contains the buttons or the menu, to gain support for displaying sheets anytime the observable property changes:

extension View {
    
    //Convenience function for adding observable support for showing sheets
    func dynamicSheetObserver(enabled: Bool = true) -> some View {
        self
            .modifier(DynamicSheetObserverModifier(enabled: enabled))
    }
}

Although this method also requires an additional step on top of adding the button, it is much simpler to add a single .dynamicSheetObserver() to a view than to create a State and add a fully configured .sheet for every button that is required.

The working code below contains some additional menu sections in order to show the difference between the method used by the OP and the one I used.

Here's the full code:

import SwiftUI

struct SheetView: View {
    
    //Parameters
    let text: String
    
    //Environment values
    @Environment(\.dismiss) var dismiss
    
    //Body
    var body: some View {
        Button(text) {
            dismiss()
        }
        .buttonStyle(.borderedProminent)
        .padding()
    }
}

struct TestButton: View {
    
    //Parameters
    let text: String
    
    //State values
    @State private var showingSheet = false
    
    //Body
    var body: some View {
        Button(text) {
            showingSheet.toggle()
        }
        .buttonStyle(.borderedProminent)

        .sheet(isPresented: $showingSheet) {
            SheetView(text: "Dismiss sheet (\(text))")
        }
    }
}

struct DynamicTestButton: View {
    
    //Parameters
    let text: String
    var observerEnabled: Bool = true
    
    //Observables
    let sheetObserver = SheetObserver.manager
    
    //Body
    var body: some View {
        
        //Define the content that should appear in the sheet here
        let sheetContent = AnyView(
            SheetView(text:  "Dismiss sheet (\(text))")
        )
        
        //Button that sets the property of the observable class
        Button(text) {
            sheetObserver.updateSheetContent(to: sheetContent)
        }
        .buttonStyle(.borderedProminent)
        .dynamicSheetObserver(enabled: observerEnabled) // <- the button is also a sheet observer
    }
}

enum SampleEnum: String, CaseIterable {
    case one, two, three, four
}

struct MenuButtonContentView: View {
    var body: some View {
        Form {
            Section {
                TestButton(text: "Normal sheet")
            } footer : {
                Text("*This button is not in a menu")
            }
            
            Section("Normal buttons in Menu") {
                ForEach(SampleEnum.allCases, id:\.self) { id in
                    let text = id.rawValue.capitalized
                    Menu("Menu \(Text(text))") {
                        TestButton(text: "Normal Button - Menu \(text)")
                    }
                }
            }
            
            Section("'Dynamic' buttons in Menu") {
                ForEach(SampleEnum.allCases, id:\.self) { id in
                    let text = id.rawValue.capitalized
                    Menu("Menu \(Text(text))") {
                        DynamicTestButton(text: "Dynamic Button - Menu \(text)" )
                    }
                }
            }
            .tint(.orange)
            
            Section("'Dynamic' button NOT in Menu") {
                                DynamicTestButton(text: "Dynamic sheet", observerEnabled: false )

            }
            .tint(.orange)
        }
        .dynamicSheetObserver() // <- add this to a parent view of the button, but not as part of a loop (or a section that contains a loop)
    }
}

//Preview
#Preview {
    MenuButtonContentView()
}

//Observable class for showing sheet content dynamically
@Observable
class SheetObserver {
    
    //Properties
    var sheetContent: AnyView?
    var showContent: Bool = false
    
    //Singleton
    static let manager = SheetObserver()
    private init() {}
    
    func updateSheetContent(to content: AnyView) {
        sheetContent = content
        showContent.toggle() // Toggle to show the sheet
    }
}


struct DynamicSheetObserverModifier: ViewModifier {
    
    //Parameters
    var enabled: Bool = true
    
    //Bindings
    @Bindable var sheetObserver = SheetObserver.manager
    
    @State private var showSheet = false
    
    //Body content
    func body(content: Content) -> some View {
        
        Group {
            if enabled {
                content
                    .sheet(isPresented: $sheetObserver.showContent) {
                        if let sheetContent = sheetObserver.sheetContent {
                            sheetContent
                        }
                    }
            }
            else {
                content
            }
        }
    }
}

extension View {
    
    //Convenience function for adding observable support for showing sheets
    func dynamicSheetObserver(enabled: Bool = true) -> some View {
        self
            .modifier(DynamicSheetObserverModifier(enabled: enabled))
    }
}

NOTE: To prevent issues when a button is added to a view that already has .dynamicSheetObserver added, there's a bool flag that can be used to disable one or the other so they don't both try to open a sheet at the same time.

enter image description here

Upvotes: 0

Yrb
Yrb

Reputation: 9725

You have a couple of problems with the code. First of all, in your ContentView you have the Menu inside of the ForEach. By doing it that way, you have created four menus with one button each, instead of one menu with four buttons. The point of Menu is to hide the buttons until they are needed.

The second issue is that you are trying to show one sheet off the button that is buried in another view in the menu. The sheet really should be declared in the parent, not a child, and I think you have confused the OS. That being said, I think eventually you intend to call four different sheets from the different buttons, and the answer Asperi pointed you to will help as you will be calling different sheets from the one .sheet. I corrected the code and just brought the button into the main UI and out of its own struct.

struct SheetView: View {
    @Environment(\.presentationMode) var presentationMode
    
    var body: some View {
        Button("Press to dismiss") {
            presentationMode.wrappedValue.dismiss()
        }
        .font(.title)
        .padding()
        .background(Color.black)
    }
}

enum SampleEnum: String, CaseIterable {
    case one, two, three, four
}

struct ContentView: View {
    @State private var showingSheet = false
    
    var body: some View {
        Form {
            Section {
                VStack  {
                    Menu("Show Sheet") {
                        ForEach(SampleEnum.allCases, id:\.self) { id in
                            Button(id.rawValue) {
                                showingSheet.toggle()
                            }
                        }
                    }
                }
            }
        }
        .sheet(isPresented: $showingSheet) {
            SheetView()
        }
    }
}

Upvotes: 1

Related Questions