SwiftUI - present sheet programatically - modal-dialog

I would like to have a modal sheet appear with several options for the user to choose from. (The share sheet is a perfect example.) When the user makes a selection, the option sheet disappears and a second sheet appears with the selected option. In the share sheet example, if the user selects print, the share sheet slides down and the print sheet pops up.
I can get the option sheet to appear easily enough. But I haven't figured out how to get the second sheet to appear. I tried attaching the sheet to an empty view and then used UserDefaults to set the bool that activates the second sheet. Nothing.
First Sheet
Button(action: {
UserDefaults.standard.set(true, forKey: showSelectedOption)
showOptionForm = true
}) {
Image(systemName: "square.and.arrow.up")
}
.sheet(isPresented: $showOptionForm) {
OptionView().environment(\.managedObjectContext, self.moc)
})
SecondSheet
EmptyView()
.sheet(isPresented: $showSelectedOption) {
SelectedOptionView().environment(\.managedObjectContext, self.moc)
}
I tried setting the bool shown below in .onAppear, but it does not get called when a modal sheet is dismissed. Is there a way to tell when a view is no longer being covered by a sheet? In UIKit it would have been presentationControllerDidDismiss(_:). Of course, this is assuming that my idea to attach the second sheet to an empty view is even workable.
let showSelectedOption = UserDefaults.standard.bool(forKey: "showSelectedOption")

Here is demo of possible approach - you activate second sheet in onDismiss of first one. Tested with Xcode 12 / iOS 14.
struct DemoTwoSheets: View {
#State private var firstSheet = false
#State private var secondSheet = false
var body: some View {
VStack {
Button("Tap") { self.firstSheet = true }
.sheet(isPresented: $firstSheet, onDismiss: {
self.secondSheet = true
}) {
Text("First sheet")
}
EmptyView()
.sheet(isPresented: $secondSheet) {
Text("Second sheet")
}
}
}
}
Update:
Here is an alternate which works for SwiftUI 1.0 as well. Tested with Xcode 11.4 / iOS 13.4 and Xcode 12b5 / iOS 14.
struct DemoTwoSheets: View {
#State private var firstSheet = false
#State private var secondSheet = false
var body: some View {
VStack {
Button("Tap") { self.firstSheet = true }
.sheet(isPresented: $firstSheet, onDismiss: {
self.secondSheet = true
}) {
Text("First sheet")
}
.background(Color.clear
.sheet(isPresented: $secondSheet) {
Text("Second sheet")
})
}
}
}

Related

SwiftUI Issue with State with Toggle/Sheet

Intro
Takes this simple view as an example.
#State private var isOn: Bool = false
#State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
A simple VStack displays a Button that presents a Sheet, and a Toggle that modifies a local property. VStack has a Sheet modifier applied to it, that simply displays property modified by the Toggle.
Sounds simple, but there are issues in certain conditions.
Different App Runs
App Run 1 (No Bug):
Don't press Toggle (set to false)
Open Sheet
Text shows "false" and console logs "false"
App Run 2 (Bug):
Press Toggle (true)
Open Sheet
Text shows "false" and console logs "true"
App Run 3 (No Bug):
Press Toggle (true)
Open Sheet
Close Sheet
Press Toggle (false)
Press Toggle (true)
Open Sheet
Text shows "true" and console logs "true"
In the second run, Text in Sheet displays "false", while console logs "true". But closing the sheet and re-toggling the Toggle fixes the issue.
Also, console logs the following warning:
invalid mode 'kCFRunLoopCommonModes' provided to CFRunLoopRunSpecific - break on _CFRunLoopError_RunCalledWithInvalidMode to debug. This message will only appear once per execution.
Weird Fix
Adding same Text inside the VStack as well seems to fix the issue.
#State private var isOn: Bool = false
#State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
Text(String(isOn)) // <--
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
Problem can also be fixed by using onChange modifier.
#State private var isOn: Bool = false
#State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
.onChange(of: isOn, perform: { _ in }) // <--
}
Other UI Components
I have two custom made Toggle and BottomSheet components that are build in SwiftUI from scratch. I have also used them in the test.
Using native Toggle with my BottomSheet causes problem.
Using my Toggle with native Sheet DOESN'T cause problem.
Using my Toggle with my Sheet DOESN'T cause problem.
Swapping out native Toggle with native Button also causes the same issue:
#State private var isOn: Bool = false
#State private var isPresented: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { isPresented = true })
Button("Toggle", action: { isOn.toggle() }) // <--
})
.padding()
.sheet(isPresented: $isPresented, content: {
Text(String(isOn))
.onAppear(perform: { print("> \(isOn)") })
})
}
Update
As suggested in the comments, using Sheet init with Binding item seems so solve the issue:
private struct Sheet: Identifiable {
let id: UUID = .init()
let isOn: Bool
}
#State private var presentedSheet: Sheet?
#State private var isOn: Bool = false
var body: some View {
VStack(content: {
Button("Present", action: { presentedSheet = .init(isOn: isOn) })
Toggle("Lorem Ipsum", isOn: $isOn)
})
.padding()
.sheet(item: $presentedSheet, content: { sheet in
Text(String(sheet.isOn))
})
}
However, as other older threads suggested, this may be a bug in SwiftUI, introduced in 2.0.
Another way of fixing the issue that doesn't require creating a new object and doing additional bookkeeping is just leaving an empty onChange modifier: .onChange(of: isOn, perform: { _ in }).
extension View {
func bindToModalContext<V>(
_ value: V
) -> some View
where V : Equatable
{
self
.onChange(of: value, perform: { _ in })
}
}
Other threads:
SwiftUI #State and .sheet() ios13 vs ios14
https://www.reddit.com/r/SwiftUI/comments/l744cb/running_into_state_issues_using_sheets/
https://developer.apple.com/forums/thread/661777
https://developer.apple.com/forums/thread/659660
The sample code demonstrating the issue can be simplified to this:
struct SheetTestView: View {
#State private var isPresented: Bool = false
var body: some View {
Button("Present") {
isPresented = true
}
.sheet(isPresented: $isPresented) {
Text(isPresented.description)
}
}
}
The thing you have to understand first is part of SwiftUI's magic is dependency tracking. Where body is only called if it actually uses a var that changes. Unfortunately that behavior does not extend to the code within blocks passed to view modifiers like sheet. So in the code above, SwiftUI is asking itself does any of the Views inside body display the value of isPresented? And that answer is no, so when the value of isPresented is changed, it does not need to call body and it doesn't.
So when body is called the first time, the block that is created for the sheet is using the original value of isPresented which is false. When the Button is pressed and isPresented is set to true, the block is called immediately and thus it still is using the value false.
Now you understand what is happening, a workaround is to make body actually use the value of isPresented, e.g.
struct SheetTestView: View {
#State private var isPresented: Bool = false
var body: some View {
Button("Present (isPresented: \(isPresented))") { // now body has a dependency on it
isPresented = true
}
.sheet(isPresented: $isPresented) {
Text(isPresented.description)
}
}
}
Now SwiftUI, does detect a dependency for body on the value of isPresented so the behaviour is now different. When the Button is pressed, instead of the sheet block being called immediately, actual body is called first, and thus a new sheet block is created and this one now uses the new value of isPresented which is true and the problem is fixed.
This workaround may be undesirable, so a way to ensure the problem doesn't happen is to use a feature of Swift called a capture list, this makes a dependency on the value of isPresented without actually having to display it, e.g.
struct SheetTestView: View {
#State private var isPresented: Bool = false
var body: some View {
Button("Present (isPresented: \(isPresented))") {
isPresented = true
}
.sheet(isPresented: $isPresented) { [isPresented] in // capture list
Text(isPresented.description)
}
}
}
This trick does make body depend on isPresented in SwiftUI's eyes, so body is called when the Button action changes isPresented and then a new block is created that is passed to sheet and does have the correct value, problem solved!

Show bottom sheet after button press swiftui

I'm trying to add to my app bottom sheet with responsive height which I can set programmatically. For this purpose I'm trying to use this video. Here is code of my view controller:
struct SecondView: View {
#State var cardShown = false
#State var cardDismissal = false
var body: some View {
Button {
cardShown.toggle()
cardDismissal.toggle()
} label: {
Text("Show card")
.bold()
.foregroundColor(Color.white)
.background(Color.red)
.frame(width: 200, height: 50)
}
BottomCard(cardShown: $cardShown, cardDismissal: $cardDismissal) {
CardContent()
}
}
}
struct CardContent:View{
var body: some View{
Text("some text")
}
}
struct BottomCard<Content:View>:View{
#Binding var cardShown:Bool
#Binding var cardDismissal:Bool
let content:Content
init(cardShown:Binding<Bool> , cardDismissal:Binding<Bool>, #ViewBuilder content: () -> Content){
_cardShown = cardShown
_cardDismissal = cardDismissal
self.content = content()
}
var body: some View{
ZStack{
//Dimmed
GeometryReader{ _ in
EmptyView()
}
.background(Color.red.opacity(0.2))
.opacity(cardShown ? 1 : 0)
.animation(.easeIn)
.onTapGesture {
cardShown.toggle()
}
// Card
VStack{
Spacer()
VStack{
content
}
}
.edgesIgnoringSafeArea(.all)
}
}
}
but after pressing the button I don't see any pushed bottom menu. I checked and it seems that I have similar code to this video but on the video bottom sheet appears. Maybe I missed something important for menu showing. The main purpose is to show bottom menu with responsive height which will wrap elements and will be able to change menu height. I tried to use .sheet() but this element has stable height as I see. I know that from the ios 15+ we will have some solutions for this problem but I would like to create something more stable and convenient :)
iOS 16
We can have native SwiftUI resizable sheet (like UIKit). This is possible with the new .presentationDetents() modifier.
.sheet(isPresented: $showBudget) {
BudgetView()
.presentationDetents([.height(250), .medium])
.presentationDragIndicator(.visible)
}
Demo:
This is what I got when running your code
I got this after some adjustments to bottom card
struct BottomCard<Content:View>:View{
#Binding var cardShown:Bool
#Binding var cardDismissal:Bool
let content:Content
init(cardShown:Binding<Bool> , cardDismissal:Binding<Bool>, #ViewBuilder content: () -> Content){
_cardShown = cardShown
_cardDismissal = cardDismissal
self.content = content()
}
var body: some View{
ZStack{
//Dimmed
GeometryReader{ _ in
EmptyView()
}
.background(Color.red.opacity(0.2))
.animation(.easeIn)
.onTapGesture {
cardShown.toggle()
}
// Card
VStack{
Spacer()
VStack{
content
}
Spacer()
}
}.edgesIgnoringSafeArea(.all)
.opacity(cardShown ? 1 : 0)
}
}
So you just need to set the height!
what you want to do is to have a card that only exists when there is a certain standard met.
If you want to push up a card from the bottom then you can make a view of a card and put it at the bottom of a Zstack view using a geometry reader and then make a button that only allows for that card to exist when the button is pressed INSTEAD of trying to hire it by changing its opacity. Also, make sure you move the dismissal button to the inside of the cad you have.
Heres an example you can try :
struct SecondView: View {
#State var cardShown = false
var body: some View {
GeometryReader{
ZStack {
ZStack{
// I would also suggest getting used to physically making your
//button and then giving them functionality using a "Gesture"
Text("Show Button")
.background(Rectangle())
.onTapGesture{
let animation = Animation.spring()
withAnimation(animation){
self.cardShown.toggle
}
}
}
ZStack {
if cardShown == true{
BottomCard(cardShown: $cardShown) {
CardContent()
}
}
// here you can change how far up the card comes after the button
//is pushed by changing the "0"
.offset(cardShown == false ? geometry.size.height : 0)
}
}
}
}
}
Also, you don't need to have a variable for the card being shown and a variable for the card being dismissed. Just have one "cardShown" variable and make it so that when it is TRUE the card is shown and when it is FALSE (after hitting the button on the card or hitting the initial button again.) the card goes away.
iOS 16.0+
iPadOS 16.0+
macOS 13.0+
Mac Catalyst 16.0+
tvOS 16.0+
watchOS 9.0+
Use presentationDetents(_:)
struct ContentView: View {
#State private var isBottomSheetVisible = false
var body: some View {
Button("View Settings") {
isBottomSheetVisible = true
}
.sheet(isPresented: $isBottomSheetVisible) {
Text("Bottom Sheet")
.presentationDetents([.height(250), .medium])
.presentationDragIndicator(.visible)
}
}
}

SwiftUI: Sheet gets dismissed immediately after being presented

I want to have a fullscreen SwiftUI View with a button in the Navigation Bar, which presents a SwiftUI Sheet above.
Unfortunately, the Compiler says: "Currently, only presenting a single sheet is supported.
The next sheet will be presented when the currently presented sheet gets dismissed."
This is my Code:
struct ContentView: View {
var body: some View {
EmptyView().fullScreenCover(isPresented: .constant(true), content: {
FullScreenView.init()
})
}
}
struct FullScreenView: View{
var body: some View {
NavigationView{
MasterView()
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}
struct MasterView: View {
#State private var showingSheet = false
var body: some View {
Form {
Section(header: Text("Header")) {
NavigationLink(destination: UIKitView()) { Text("Hey") }
}
}
.navigationBarItems(trailing:
HStack {
// First Try: Use a Button
Button("Plus"){
showingSheet = true
}.sheet(isPresented: $showingSheet){
AddContentView()
}
// Second Try: Use NavigationLink
NavigationLink(
destination: AddContentView(),
label: {
Image(systemName: "plus.square.fill")
})
})
}
}
The Problem
I want to show the SwiftUI View in Fullscreen, so I use fullScreenCover(...). With this first "Sheet", I cannot present a second sheet, my AddContentView() View. Is there any way how I can fix this? I really want to have this sheet above :(
Thanks for any help!!
Feel free to ask for other code or if there are ambiguities. :)
The error message says that the sheet cannot be displayed at the same time(Do not overlap sheets), so if you want to go to view and again to another view, you have to use a NavigationLink and only at the end .sheet()
.sheet(isPresented: $showingSheet){
AddContentView()
}
or fullScreenCover()
.fullScreenCover(isPresented: $showingSheet){
AddContentView()
}
Edited: Sheet is not overlapped twice in this code.
import SwiftUI
struct ContentView: View {
#State private var showingSheet = false
var body: some View {
NavigationView{
Form {
Section(header: Text("Header")) {
NavigationLink(destination: EmptyView()) { Text("Hey") }
}
}
.navigationBarItems(trailing:
HStack {
// First Try: Use a Button
Button("Plus"){
showingSheet = true
}.sheet(isPresented: $showingSheet){
EmptyView()
}
// Second Try: Use NavigationLink
NavigationLink(
destination: EmptyView(),
label: {
Image(systemName: "plus.square.fill")
})
})
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
}
}
p.s. fullScreenCover() belongs to the sheet

SwiftUI - Navigation bar button not clickable after sheet has been presented

I have just started using SwiftUI a couple of weeks ago and i'm learning. Today I ran into a into an issue.
When I present a sheet with a navigationBarItems-button and then dismiss the ModalView and return to the ContentView I find myself unable to click on the navigationBarItems-button again.
My code is as follows:
struct ContentView: View {
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
}
)
}
}
}
struct ModalView: View {
#Environment(\.presentationMode) var presentation
var body: some View {
VStack {
Button(action: {
self.presentation.wrappedValue.dismiss()
}) {
Text("Dismiss")
}
}
}
}
I think this happens because the presentationMode is not inherited from the presenter view, so the presenter didn't know that the modal is already closed. You can fix this by adding presentationMode to presenter, in this case to ContentView.
struct ContentView: View {
#Environment(\.presentationMode) var presentation
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
}
)
}
}
}
Tested on Xcode 12.5.
Here is the full working
example.
This seems to be a bug in SwiftUI. I am also still seeing this issue with Xcode 11.5 / iOS 13.5.1. The navigationBarMode didn't make a difference.
I filed an issue with Apple:
FB7641003 - Taps on a navigationBarItem Button presenting a sheet sometimes not recognized
You can use the attached example project SwiftUISheet (also available via https://github.com/ralfebert/SwiftUISheet) to reproduce the issue. It just presents a sheet from a navigation bar button.
Run the app and tap repeatedly on the 'plus' button in the nav bar. When the sheet pops up, dismiss it by sliding it down. Only some taps to the button will be handled, often a tap is ignored.
Tested on Xcode 11.4 (11E146) with iOS 13.4 (17E255).
Very hacky but this worked for me:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
.frame(height: 96, alignment: .trailing)
}
I'm still seeing this issue with Xcode 13 RC and iOS 15. Unfortunately the solutions above didn't work for me. What I ended up doing is adding a small Text view to the toolbar whose content changes depending on the value of the .showingSheet property.
struct ContentView: View {
#State private var showingSheet = false
var body: some View {
NavigationView {
VStack {
Text("Content view")
Text("Swift UI")
}
.sheet(isPresented: $showingSheet) {
Text("This is a sheet")
}
.navigationTitle("Example")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
// Text view workaround for SwiftUI bug
// Keep toolbar items tappable after dismissing sheet
Text(showingSheet ? " " : "").hidden()
Button(action: {
self.showingSheet = true
}) {
Label("Show Sheet", systemImage: "plus.square")
}
}
}
}
}
}
I realize it's not ideal but it's the first thing that worked for me. My guess is that having the Text view's content change depending on the .showingSheet property forces SwiftUI to fully refresh the toolbar group.
So far, I can still observe the disorder of navi buttons right after dismissing its presented sheet.
FYI, I am using a UINavigationController wrapper instead as workaround. It works well.
Unfortunately, I sure that the more that kind of bugs, the farther away the time of using SwiftUI widely by the ios dev guys. Because those are too basic to ignore.
Very hacky but this worked for me:
I had the same problem. this solution worked for me.
struct ContentView: View {
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
// this is a workaround
.frame(height: 96, alignment: .trailing)
}
)
}
}
}
Only #adamwjohnson5's answer worked for me. I don't like doing it but it's the only solution that works as of Xcode 13.1 and iOS 15.0. Here is my code for anyone interested in seeing iOS 15.0 targeted code:
var body: some View {
NavigationView {
mainContentView
.navigationTitle(viewModel.navigationTitle)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
PlusButton {
viewModel.showAddDialog.toggle()
}
.frame(height: 96, alignment: .trailing) // Workaroud, credit: https://stackoverflow.com/a/62209223/5421557
.confirmationDialog("CatalogView.Add.DialogTitle", isPresented: $viewModel.showAddDialog, titleVisibility: .visible) {
Button("Program") {
viewModel.navigateToAddProgramView.toggle()
}
Button("Exercise") {
viewModel.navigateToAddExerciseView.toggle()
}
}
}
}
.sheet(isPresented: $viewModel.navigateToAddProgramView, onDismiss: nil) {
Text("Add Program View")
}
.sheet(isPresented: $viewModel.navigateToAddExerciseView, onDismiss: nil) {
AddEditExerciseView(viewModel: AddEditExerciseViewModel())
}
}
.navigationViewStyle(.stack)
}

NavigationLink isActive does not work inside navigationBarItems(trailing:) modifier

I am using the newest versions of Xcode (11 Beta 16) and macOS (10.15 Beta 6)
I am trying to create two views. From the first one view you should be able to navigate to the second one view via the trailing navigation bar item and to navigate back you should be able to use the system generated back button (which works) and additionally a trailing navigation bar button (which has some additional functionality like saving the data but this is not important for my problem).
Option 1 does work but if you comment out option 1 and uncomment option 2 (my wanted layout) the done button just does not navigate back.
struct ContentView1: View {
#State var show = false
var body: some View {
NavigationView {
Form {
Text("View 1")
// Option 1 that does work
NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
Text("Move")
}
}
.navigationBarTitle(Text("Title"))
// Option 2 that does NOT work
// .navigationBarItems(trailing: NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
// Text("Move")
// })
}
}
}
struct ContentView2: View {
#Binding var show: Bool
var body: some View {
Form {
Text("View 2")
Text(show.description)
}
.navigationBarItems(trailing: Button(action: {
self.show = false
}, label: {
Text("Done")
}))
}
}
Any suggestions how to fix that?
Option 2 plays nicely with presentationMode:
struct ContentView2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form {
Text("View 2")
}
.navigationBarItems(trailing: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Done")
}))
}
}