SwiftUI Issue with State with Toggle/Sheet - swift

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!

Related

SwiftUI detect edit mode

I've returned to iOS development after a while and I'm rebuilding my Objective-C app from scratch in SwiftUI.
One of the things I want to do is use the default Edit Mode to allow entries in a List (backed by Core Data on CloudKit) to switch between a NavigationLink to a detail view and an edit view.
The main approach seems to be to handle it through a if statement that detects edit mode. The Apple documentation provides the following snippet for this approach on this developer page: https://developer.apple.com/documentation/swiftui/editmode
#Environment(\.editMode) private var editMode
#State private var name = "Maria Ruiz"
var body: some View {
Form {
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text(name)
}
}
.animation(nil, value: editMode?.wrappedValue)
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
However, this does not work (I've embedded the snippet in a NavigationView as assumed).
Is this a bug in Xcode 13.4.1? iOS 15.5? Or am I doing something wrong?
Update1:
Based on Asperi's answer I came up with the following generic view to handle my situation:
import SwiftUI
struct EditableRow: View {
#if os(iOS)
#Environment(\.editMode) private var editMode
#endif
#State var rowView: AnyView
#State var detailView: AnyView
#State var editView: AnyView
var body: some View {
NavigationLink{
if(editMode?.wrappedValue.isEditing == true){
editView
}
else{
detailView
}
}label: {
rowView
}
}
}
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
EditButton()
EditableRow(rowView: AnyView(Text("Row")), detailView: AnyView(Text("Detail")), editView: AnyView(Text("Edit")))
}
}
}
The preview works as expected, but this works partially in my real app. When I implement this the NavigationLink works when not in Edit Mode, but doesn't do anything when in Edit Mode. I also tried putting the whole NavigationLink in the if statement but that had the same result.
Any idea why this isn't working?
Update2:
Something happens when it's inside a List. When I change the preview to this is shows the behavior I'm getting:
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
EditableRow(rowView: AnyView(GroupRow(title: "Title", subTitle: "Subtitle", type: GroupType.personal)), detailView: AnyView(EntryList()), editView: AnyView(Text("Edit")))
}
.navigationBarItems(trailing:
HStack{
#if os(iOS)
EditButton()
#endif
}
)
}
}
}
Update3:
Found this answer: SwiftUI - EditMode and PresentationMode Environment
This claims the default EditButton is broken, which seems to be true. Replacing the default button with a custom one works (be sure to add a withAnimation{} block to get all the behavior from the stock button.
But it still doesn't work for my NavigationLink...
Update4:
Ok, tried passing an "isEditing" Bool to the above View, not to depend on the Environment variable being available. This works as long as the View (a ForEach within a List in my case) isn't in "Editing Mode" whatever happens at that point breaks any NavigationLink it seems.
Update5:
Basically my conclusion is that the default Edit Mode is meant to edit the "List Object" as a whole enabling moving and deleting of rows. In this mode Apple feels that editing the rows themselves isn't something you'd want to do. I can see this perspective.
If, however, you still want to enable a NavigationLink from a row in Edit Mode, this answer should help:
How to make SwiftUI NavigationLink work in edit mode?
Asperi's answer does cover why the detection doesn't work. I did find that Edit Mode detection does work better when setting the edit mode manually and not using the default EditButton, see the answer above for details.
It is on same level so environment is not visible, because it is activated for sub-views.
A possible solution is to separate dependent part into standalone view, like
Form {
InternalView()
}
.toolbar {
EditButton()
}
Tested with Xcode 13.4 / iOS 15.5
Test module on GitHub
#Asperi's answer worked well for me. However I wanted to still be able to access the editMode in the same hierarchy. As a workaround I created the following:
Usage
struct ContentView: View {
#State
private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
Form {
if editMode.isEditing == true {
Color.red
} else {
Color.blue
}
}
.editModeFix($editMode)
.toolbar {
EditButton()
}
}
}
}
Implementation
extension View {
func editModeFix(_ editMode: Binding<EditMode>) -> some View {
modifier(EditModeFixViewModifier(editMode: editMode))
}
}
private struct EditModeFixView: View {
#Environment(\.editMode)
private var editModeEnvironment
#Binding
var editMode: EditMode
var body: some View {
Color.clear
.onChange(of: editModeEnvironment?.wrappedValue) { editModeEnvironment in
if let editModeEnvironment = editModeEnvironment {
editMode = editModeEnvironment
}
}
.onChange(of: editMode) {
editModeEnvironment?.wrappedValue = $0
}
}
}
private struct EditModeFixViewModifier: ViewModifier {
#Binding
var editMode: EditMode
func body(content: Content) -> some View {
content
.overlay {
EditModeFixView(editMode: $editMode)
}
}
}
I've got it to work by using a .simultaneousGesture on the EditButton and playing with a #State wrapper.
struct EditingFix: View {
#Environment(\.editMode) var editMode
#State var showDeleteButton = false
var body: some View {
Text("hello")
.toolbar(content: {
if showDeleteButton {
ToolbarItem(placement: .navigationBarLeading, content: {
Label("Remove selected", systemImage: "trash")
.foregroundColor(.red)
})
}
ToolbarItem(placement: .navigationBarTrailing, content: {
EditButton()
.simultaneousGesture(TapGesture().onEnded({
showDeleteButton.toggle()
}))
})
})
.onChange(of: showDeleteButton, perform: { isEditing in
editMode?.wrappedValue = isEditing ? .active : .inactive
})
.animation(.default, value: editMode?.wrappedValue) // Restore the default smooth animation for list selection and others
}
I can definitly say that EditButton is not using the same EditMode environment as what we get when invoking #Environment(\.editMode) var editMode. So we have to do it all ourselves if we want to get the benefit of the EditButton. Mainly the localized Edit text that it displays in my case.
Alternatively
The above method led to some weird behavior where the EditButton editMode seemed to conflict in some situation with the #Environment(\.editMode) var editMode. I'd advise you use your own logic for editing using the reliable .environment(\.editMode, $editMode). This way you can do whatever you want with the binding that control editing.
struct EditingFix: View {
#State var editMode: EditMode = .inactive
#State var isEditing = false
var body: some View {
VStack {
if editMode.isEditing {
Text("Hello")
}
Text("World")
Button("Toggle hello", action: {
isEditing.toggle()
})
}
.environment(\.editMode, $editMode)
.onChange(of: isEditing, perform: { isEditing in
editMode = isEditing ? .active : .inactive
})
.animation(.default, value: editMode)
}
}

SwiftUI - present sheet programatically

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

SwiftUI allow Toggle only on certain conditions

My app has paid features. I have a Toggle that the user can switch. If the user has not paid, then when the user tries to toggle the Toggle a sheet should be brought up (and the toggle should not be activated). If the user has paid, then the toggle can be toggled on and off without problems.
I can't understand how a simple struct (PaidFeature) can have access to an observable object (Model). How do I code that in SwiftUI?
class Model: ObservableObject {
#Published var hasUserPaid: Bool = false
}
struct PaidFeature {
var isEnabled = false
}
struct ContentView: View {
#State private var feature = PaidFeature()
#EnvironmentObject var model: Model
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}
}
}
Using Xcode 12, you can listen for changes of the toggle button using .onChange modifier, and whenever user toggles the button, you can toggle it back to the last state, and instead show a purchase this sheet if the user is not a premium user.
.onChange(self.feature.isEnabled) { isEnabled in
if isEnabled && shouldShowPurchaseSheet {
// toggle back if you want the button to go to the inactive state
// show the sheet
}
}
You could add a disabled modifier to the Toggle.
struct ContentView: View {
#State private var feature = PaidFeature()
#EnvironmentObject var model: Model
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}.disabled(!model.hasUserPaid)
}
}
I assume it should be as
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}
.disabled(!model.hasUserPaid) // << here !!
}
Update: demo of alternate variant with showing sheet. Tested with Xcode 12 / iOS 14
For simplicity all of demo all states are kept in view
struct DemoView: View {
#State private var feature = false
#State private var paid = false
#State private var showPurchase = false
var body: some View {
Toggle(isOn: $feature) {
Text("Hello, World!")
}
.allowsHitTesting(paid)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
if !self.paid {
self.showPurchase = true
}
})
.sheet(isPresented: $showPurchase) {
Text("Purchase this")
.onDisappear {
self.paid = true
}
}
}
}

swiftui why can't optional be assigned?

Why can't optional be assigned?
Index has been allocated, but still no value is displayed
Help me, Thank you!
struct TestView: View {
#State private var index: Int? = nil
#State private var show: Bool = false
var body: some View {
VStack {
Text("Hello, world!")
.onTapGesture {
self.index = 1
self.show = true
print(self.index as Any)
}
}
.fullScreenCover(isPresented: $show) {
if let index = self.index {
Text("value:\(index)")
} else {
Text("not value")
}
}
}
}
Xcode Version 12.0 beta 2
SwiftUI relies upon the #State variable causing the body getter to be recalculated when it changes. For this to work, the body getter must depend in certain definite ways on the #State variable. The problem in your code is that it doesn't.
To see this, we can reduce your code to a simpler example:
struct ContentView: View {
#State var message = "Hey"
#State var show: Bool = false
var body: some View {
VStack {
Button("Test") {
message = "Ho"
show = true
}
}
.sheet(isPresented: $show) {Text(message)}
}
}
We change message to Ho, but when the sheet is presented, it still says Hey. This is because nothing happened to make the body recalculate. You might say: What about the phrase Text(message)? Yes, but that's in a closure; it has already been calculated, and message has already been captured.
To see that what I'm saying is right, just add a Text displaying message directly to the main interface:
struct ContentView: View {
#State var message = "Hey"
#State var show: Bool = false
var body: some View {
VStack {
Button("Test") {
message = "Ho"
show = true
}
Text(message)
}
.sheet(isPresented: $show) {Text(message)}
}
}
Now your code works! Of course, we are also displaying an unwanted Text in the interface, but the point is, that plain and simple Text(message), not in a closure, is sufficient to cause the whole body getter to be recalculated when message changes. So we have correctly explained the phenomenon you're asking about.
So what's the real solution? How can we get the content closure to operate as we expect without adding an extra Text to the interface? One way is like this:
struct ContentView: View {
#State var message = "Hey"
#State var show: Bool = false
var body: some View {
VStack {
Button("Test") {
message = "Ho"
show = true
}
}
.sheet(isPresented: $show) {[message] in Text(message)}
}
}
By including message in the capture list for our closure, we make the body getter depend on the message variable, and now the code behaves as desired.

TextField with animation crashes app and looses focus

Minimal reproducible example:
In SceneDelegate.swift:
let contentView = Container()
In ContentView.swift:
struct SwiftUIView: View {
#State var text: String = ""
var body: some View {
VStack {
CustomTextFieldView(text: $text)
}
}
}
struct Container: View {
#State var bool: Bool = false
#State var text: String = ""
var body: some View {
Button(action: {
self.bool.toggle()
}) {
Text("Sheet!")
}
.sheet(isPresented: $bool) {
SwiftUIView()
}
}
}
In CustomTextField.swift:
struct CustomTextFieldView: View {
#Binding var text: String
#State var editing: Bool = false
var body: some View {
Group {
if self.editing {
textField
.background(Color.red)
} else {
ZStack(alignment: .leading) {
textField
.background(Color.green)
Text("Placeholder")
}
}
}
.onTapGesture {
withAnimation {
self.editing = true
}
}
}
var textField: some View {
TextField("", text: $text)
}
Problem:
After running the above code and focusing the text field, the app crashes. Some things I noticed:
If I remove the withAnimation code, or the ZStack in CustomTextField file, the app doesn't crash, but the TextField looses focus.
If I remove the VStack in SwiftUIView, the app doesn't crash, but the TextField looses focus.
If I use a NavigationLink or present the TextField without a sheet, the app doesn't crash, but the TextField looses focus.
Questions:
Is this a problem in the current version of SwiftUI?
Is there a solution to this problem using SwiftUI? I want to stay out of
ViewRepresentables as much as possible.
How can I keep the focus of the TextField after the body is recalculated because of a change in state?
How can I keep the focus of the TextField after the body is
recalculated because of a change in state?
You have two of them. Two different TextField could not be in editing state at the same time.
The approach suggested by Asperi is the only possible.
The reason, why your code crash is not easy explain, but expected in current SwiftUI.
You have to understand, that Group is not a standard container, it just like a "block" on which you can apply some modifiers. Removing Group and using wraping body in ViewBuilder
struct CustomTextFieldView: View {
#Binding var text: String
#State var editing: Bool = false
#ViewBuilder
var body: some View {
if self.editing {
TextField("", text: $text)
.background(Color.red)
.onTapGesture {
self.editing.toggle()
}
} else {
ZStack(alignment: .leading) {
TextField("", text: $text)
.background(Color.green)
.onTapGesture {
self.editing.toggle()
}
}
}
}
}
the code will stop to crash, but there is other issue, the keyboard will dismiss immediately. That is due the tap gesture applied.
So, believe or not, you have to use ONE TextField ONLY.
struct CustomTextFieldView: View {
#Binding var text: String
#State var editing = false
var textField: some View {
TextField("", text: $text, onEditingChanged: { edit in
self.editing = edit
})
}
var body: some View {
textField.background(editing ? Color.green : Color.red)
}
}
Use this custom text field elsewhere in your code, as you want
Try the following for CustomTextFieldView (tested & works with Xcode 11.2 / iOS 13.2)
struct CustomTextFieldView: View {
#Binding var text: String
#State var editing: Bool = false
var body: some View {
TextField("", text: $text)
.background(self.editing ? Color.red : Color.green)
.onTapGesture {
withAnimation {
self.editing = true
}
}
}
}
How can I keep the focus of the TextField after the body is recalculated because of a change in state?
You don't loose focus, you just remove entire text field, so the solution is not replace text field, but modify its property, ie background. It's ok to put it into ZStack, but keep it one.