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:
- The Menu is in a loop, which in itself may cause redraw issues.
- 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.
