Reputation: 1119
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
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:
.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.
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.
Upvotes: 0
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