SwiftUI #State not triggering update when value changed by separate method - ios

I have this (simpilied) section of code for a SwiftUI display:
struct ContentView: View {
private var errorMessage: String?
#State private var showErrors: Bool = false
var errorAlert: Alert {
Alert(title: Text("Error!"),
message: Text(errorMessage ?? "oops!"),
dismissButton: .default(Text("Ok")))
}
init() {}
var body: some View {
VStack {
Text("Hello, World!")
Button(action: {
self.showErrors.toggle()
}) {
Text("Do it!")
}
}
.alert(isPresented: $showErrors) { errorAlert }
}
mutating func display(errors: [String]) {
errorMessage = errors.joined(separator: "\n")
showErrors.toggle()
}
}
When the view is displayed and I tape the "Do it!" button then the alert is displayed as expected.
However if I call the display(errors:...) function the error message is set, but the display does not put up an alert.
I'm guessing this is something to do with the button being inside the view and the function being outside, but I'm at a loss as to how to fix it. It should be easy considering the amount of functionality that any app would have that needs to update a display like this.

Ok, some more reading and a refactor switched to using an observable view model like this:
class ContentViewModel: ObservableObject {
var message: String? = nil {
didSet {
displayMessage = message != nil
}
}
#Published var displayMessage: Bool = false
}
struct ContentView: View {
#ObservedObject private var viewModel: ContentViewModel
var errorAlert: Alert {
Alert(title: Text("Error!"), message: Text(viewModel.message ?? "oops!"), dismissButton: .default(Text("Ok")))
}
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
Button(action: {
self.viewModel.displayMessage.toggle()
}) {
Text("Do it!")
}
}
.alert(isPresented: $viewModel.displayMessage) { errorAlert }
}
}
Which is now working as expected. So the takeaway from this is that using observable view models is more useful even in simpler code like this.

Related

Why does this SwiftUI alert show so eagerly?

The alert shows when it shouldn't
I'm incorporating Apple's HIG to my app. As such, I want to show the user an alert when an important piece of data is about to be deleted.
MRE - your pals
In this sample app user manages one's friendships.
While cleaning one's social circle, user might accidentally delete a cool Dude. When this happens, i.e. when there is at least one Dude with isCool == true, the app shall show an alert. If that's not the case, it should just delete all the dudes denoted in selectedDudesIDs.
The problem
Currently, the app achieves its main goal - does not allow to delete a cool Dude without confirmation. However, for some reason, an empty alert is being shown to the user when not cool Dudes are selected.
MRE code
That's the code for a Swift Playground that illustrates the issue, "batteries included".
import SwiftUI
import PlaygroundSupport
struct TestView: View {
#State private var selectedDudesIDs = Set<Dude.ID>()
#State private var editMode: EditMode = .inactive
#State private var dudes: [Dude] = [Dude(name: "John", isCool: false), Dude(name: "Paul", isCool: true)]
// MARK: deleting alert
/// A boolean flag initiating deletion.
///
/// Its property observer determines whether there are any cool dudes, i.d. `dude.isCool == true` among the ones to be deleted.
#State private var isDeletingDudes: Bool = false {
willSet {
let coolDudesAmongDeleted = dudes.filter { dude in
selectedDudesIDs.contains(dude.id)
}.filter { dude in
dude.isCool
}
if !coolDudesAmongDeleted.isEmpty {
coolDudesToBeDeletedCount = coolDudesAmongDeleted.count
}
}
}
#State private var coolDudesToBeDeletedCount: Int?
/// Removes dudes with selected IDs from your `dudes`.
func endFriendship() {
dudes = dudes.filter { dude in
!selectedDudesIDs.contains(dude.id)
}
}
var body: some View {
VStack {
HStack {
EditButton()
Spacer()
}
Text("Your pals")
.font(.title)
List(dudes, selection: $selectedDudesIDs) { dude in
Text(dude.name)
}
if editMode.isEditing && !selectedDudesIDs.isEmpty {
Button(role: .destructive) {
isDeletingDudes = true
} label: {
Text("They ain't my pals no more")
}
}
}
.environment(\.editMode, $editMode)
// gimme some proportions
.padding()
.frame(minWidth: 500*0.9, minHeight: 500*1.6)
.alert("End Friendship", isPresented: $isDeletingDudes, presenting: coolDudesToBeDeletedCount) { count in
Button(role: .destructive) {
endFriendship()
coolDudesToBeDeletedCount = nil
} label: {
Text("End Friendships")
}
Button(role: .cancel) {
coolDudesToBeDeletedCount = nil
} label: {
Text("Cancel")
}
} message: { _ in
Text("You're are about to end friendship with at least one cool dude.")
}
}
}
struct Dude: Identifiable {
var id: String { self.name }
let name: String
var isCool: Bool
}
let view = TestView()
PlaygroundPage.current.setLiveView(view)
MRE in action
A 27 seconds long clip illustrating the current behavior.
Here, the user knows two Dudes - John is just a friend and Paul is a cool friend. The alert should not be shown when deleting John.
Why do I think it's not my fault
The documentation of alert property wrapper reads: For the alert to appear, both isPresented must be true and data must not be nil.
In this case, the alert is shown despite data (coolDudesToBeDeletedCount in this case) being nil. I've inspected it using a property observer on this variable and it's nil until one actually selects a cool Dude.
Also, the data parameter is of type T?, which is a generic Optional - and Int? definitely fits the role.
Wrap up
Is there a fault in my program's design or are the docs wrong? Either way, how could I achieve the result I'm after?
How to show the alert only when it's necessary?
Yeah, it looks like the doc is misleading. only isPresented controls the visibility of the alert and presenting is to call the closure. if presenting is nil then the closure code will not execute.
workaround: create a var showAlert to control the visibility of the alert.
import SwiftUI
struct Dude: Identifiable {
var id: String { self.name }
let name: String
var isCool: Bool
}
struct ContentView: View {
#State private var selectedDudesIDs = Set<Dude.ID>()
#State private var editMode: EditMode = .inactive
#State private var dudes: [Dude] = [Dude(name: "John", isCool: false), Dude(name: "Paul", isCool: true)]
// MARK: deleting alert
/// A boolean flag initiating deletion.
///
/// Its property observer determines whether there are any cool dudes, i.d. `dude.isCool == true` among the ones to be deleted.
#State private var isDeletingDudes: Bool = false {
willSet {
let coolDudesAmongDeleted = dudes.filter { dude in
selectedDudesIDs.contains(dude.id)
}.filter { dude in
dude.isCool
}
print(coolDudesAmongDeleted)
if !coolDudesAmongDeleted.isEmpty {
coolDudesToBeDeletedCount = coolDudesAmongDeleted.count
showAlert = true
}else{
showAlert = false
endFriendship()
}
}
}
#State private var showAlert: Bool = false
#State private var coolDudesToBeDeletedCount: Int?
/// Removes dudes with selected IDs from your `dudes`.
func endFriendship() {
dudes = dudes.filter { dude in
!selectedDudesIDs.contains(dude.id)
}
}
var body: some View {
VStack {
HStack {
EditButton()
.padding(.leading, 24)
Spacer()
}
Text("Your pals")
.font(.title)
List(dudes, selection: $selectedDudesIDs) { dude in
Text(dude.name)
}
if editMode.isEditing && !selectedDudesIDs.isEmpty {
Button(role: .destructive) {
isDeletingDudes = true
} label: {
Text("They ain't my pals no more")
}
}
}
.environment(\.editMode, $editMode)
// gimme some proportions
.padding()
.frame(minWidth: 500*0.9, minHeight: 500*1.6)
.alert("End Friendship", isPresented: $showAlert, presenting: coolDudesToBeDeletedCount) { count in
Button(role: .destructive) {
endFriendship()
coolDudesToBeDeletedCount = nil
} label: {
Text("End Friendships")
}
Button(role: .cancel) {
coolDudesToBeDeletedCount = nil
} label: {
Text("Cancel")
}
} message: { _ in
Text("You're are about to end friendship with at least one cool dude.")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI view parameter does not update as expected

I am curious why this .fullScreenCover display of a view does not update properly with a passed-in parameter unless the parameter is using the #Binding property wrapper. Is this a bug or intended behavior? Is this the fact that the view shown by the fullScreenCover is not lazily generated?
import SwiftUI
struct ContentView: View {
#State private var showFullScreen = false
#State private var message = "Initial Message"
var body: some View {
VStack {
Button {
self.message = "new message"
showFullScreen = true
} label: {
Text("Show Full Screen")
}
}.fullScreenCover(isPresented: $showFullScreen) {
TestView(text: message)
}
}
}
struct TestView: View {
var text: String
var body: some View {
Text(text)
}
}
There is a different fullScreenCover for passing in dynamic data, e.g.
import SwiftUI
struct CoverData: Identifiable {
var id: String {
return message
}
let message: String
}
struct FullScreenCoverTestView: View {
#State private var coverData: CoverData?
var body: some View {
VStack {
Button {
coverData = CoverData(message: "new message")
} label: {
Text("Show Full Screen")
}
}
.fullScreenCover(item: $coverData, onDismiss: didDismiss) { item in
TestView(text: item.message)
.onTapGesture {
coverData = nil
}
}
}
func didDismiss() {
// Handle the dismissing action.
}
}
struct TestView: View {
let text: String
var body: some View {
Text(text)
}
}
More info and an example in the docs:
https://developer.apple.com/documentation/SwiftUI/AnyView/fullScreenCover(item:onDismiss:content:)

How can I dynamically build a View for SwiftUI and present it?

I've included stubbed code samples. I'm not sure how to get this presentation to work. My expectation is that when the sheet presentation closure is evaluated, aDependency should be non-nil. However, what is happening is that aDependency is being treated as nil, and TheNextView never gets put on screen.
How can I model this such that TheNextView is shown? What am I missing here?
struct ADependency {}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ATestView_PresentationOccursButNextViewNotShown: View {
#State private var aDependency: ADependency?
#State private var isPresenting = false
#State private var wantsPresent = false {
didSet {
aDependency = model.buildDependencyForNextExperience()
isPresenting = true
}
}
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
wantsPresent = true
}
.sheet(isPresented: $isPresenting, content: {
if let dependency = aDependency {
// Never executed
TheNextView(aDependency: dependency)
}
})
}
}
struct TheNextView: View {
let aDependency: ADependency
init(aDependency: ADependency) {
self.aDependency = aDependency
}
var body: some View {
Text("Next Screen")
}
}
This is a common problem in iOS 14. The sheet(isPresented:) gets evaluated on first render and then does not correctly update.
To get around this, you can use sheet(item:). The only catch is your item has to conform to Identifiable.
The following version of your code works:
struct ADependency : Identifiable {
var id = UUID()
}
struct AModel {
func buildDependencyForNextExperience() -> ADependency? {
return ADependency()
}
}
struct ContentView: View {
#State private var aDependency: ADependency?
private let model = AModel()
var body: some View {
Text("Tap to present")
.onTapGesture {
aDependency = model.buildDependencyForNextExperience()
}
.sheet(item: $aDependency, content: { (item) in
TheNextView(aDependency: item)
})
}
}

When a property value is changed in a class via #Published, some of the observedObjects wont get updated?

These are all the codes.
the SurveyQuestion class
class SurveyQuestion: ObservableObject {
#Published var text: String
init() {
print("Initialising now ...")
self.text = "HELLO"
changeText()
}
func changeText() {
print("Changing Text Now from \(text).. ")
if self.text == "HELLO"{
self.text = "BYE"
}
else{
self.text = "HELLO"
}
print("to \(self.text) \n")
}
}
SubView.swift
struct SubView: View {
#ObservedObject var someOtherClass = SurveyQuestion()
var body: some View {
Text("Text now is \(someOtherClass.text)")
}
}
ContentView.swift
struct ContentView: View {
#ObservedObject var someClass = SurveyQuestion()
var body: some View {
VStack{
Button(action: {
print("Changing Text Now !")
self.someClass.changeText()
}) {
Text("Change Text ")
}
Text("Text now is \(someClass.text)")
SubView()
}
}
}
Whenever I click 'changeText' button, it changes the text in Text("Text now is (someClass.text)") but not Subview(). They should all be updated with the same text change.
Any idea what went wrong here?
To make it work you should use same instance of ObservableObject, like below
struct SubView: View {
#ObservedObject var someOtherClass: SurveyQuestion // to be injected
var body: some View {
Text("Text now is \(someOtherClass.text)")
}
}
struct ContentView: View {
#ObservedObject var someClass = SurveyQuestion() // created
var body: some View {
VStack{
Button(action: {
print("Changing Text Now !")
self.someClass.changeText()
}) {
Text("Change Text ")
}
Text("Text now is \(someClass.text)")
SubView(someOtherClass: someClass) // << injected
}
}
}
Your someClass and someOtherClass are two completely different objects. Changing one has no effect on the other. If this was supposed to be a singleton observable that could affect different views simultaneously, you wanted an environment object.

How to convert Binding<Error?> to Binding<Bool>

Is there any handy way to convert Binding<Error?> to Binding<Bool>? I'm having view model that has one of the property #published var error: Error?. I would like to show an alert if there is an error. But Alert accepts only Binding<Bool> as a parameter.
I was able to accomplish this by using introducing another property but this seems to me like not the most efficient way.
Code with the additional property
class SampleViewModel: ObservableObject {
#Published
var error: Error? {
didSet {
isError = error != nil
}
}
#Published
var isError: Bool = false
}
struct SampleView: View {
#ObservedObject
var viewModel: SampleViewModel
var body: some View {
EmptyView().alert(isPresented: $viewModel.isError) { () -> Alert in
Alert(title: Text("Error"), message: Text(viewModel.error!.localizedDescription), dismissButton: .default(Text("OK")))
}
}
}
Use Binding<Bool>.constant($viewModel.error.wrappedValue != nil)
class SampleViewModel: ObservableObject {
#Published
var error: Error?
}
struct SampleView: View {
#ObservedObject
var viewModel: SampleViewModel
var body: some View {
let isError = Binding<Bool>.constant($viewModel.error.wrappedValue != nil)
return EmptyView().alert(isPresented: isError) { () -> Alert in
Alert(title: Text("Error"), message: Text(viewModel.error!.localizedDescription), dismissButton: .default(Text("OK")))
}
}
}

Resources