SwiftUI pop view upon observable event - swiftui

I am trying to pop a SwiftUI view upon a particular event from an observed object. How can I do this? This code does not work because I can't refer to self inside the sink method.
struct MyView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var observable: MyObservable
init() {
observable.$state.sink { state in // !! Escaping closure captures mutating 'self' parameter !!
presentationMode.wrappedValue.dismiss()
}
}
}

You don't need publisher here and definitely should not do this in init because at least environment is not available there (even you'd solve all others errors).
You just need to observe changes of state in regular way, like below
var body: some View {
Text("Some view here")
.onChange(of: observable.state) { newState in
// depending on newState your decision here
presentationMode.wrappedValue.dismiss()
}
}

Related

Is this the right way for using #ObservedObject and #EnvironmentObject?

Cow you give me some confirmation about my understanding about #ObservedObject and #EnvironmentObject?
In my mind, using an #ObservedObject is useful when we send data "in line" between views that are sequenced, just like in "prepare for" in UIKit while using #EnvironmentObject is more like "singleton" in UIKit. My question is, is my code making the right use of these two teniques? Is this the way are applied in real development?
my model used as brain for funcions (IE urls sessions, other data manipulations)
class ModelClass_ViaObservedObject: ObservableObject {
#Published var isOn: Bool = true
}
class ModelClass_ViaEnvironment: ObservableObject {
#Published var message: String = "default"
}
my main view
struct ContentView: View {
//way to send data in views step by step
#StateObject var modelClass_ViaObservedObject = ModelClass_ViaObservedObject()
//way to share data more or less like a singleton
#StateObject var modelClass_ViaEnvironment = ModelClass_ViaEnvironment()
var myBackgroundColorView: Color {
if modelClass_ViaObservedObject.isOn {
return Color.green
} else {
return Color.red
}
}
var body: some View {
NavigationView {
ZStack {
myBackgroundColorView
VStack {
NavigationLink(destination:
SecondView(modelClass_viaObservedObject: modelClass_ViaObservedObject)
) {
Text("Go to secondary view")
.padding()
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 1)
)
}
Text("text received from second view: \(modelClass_ViaEnvironment.message)")
}
}
.navigationTitle("Titolo")
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(modelClass_ViaEnvironment)
}
}
my second view
struct SecondView: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var modelClass_viaObservedObject: ModelClass_ViaObservedObject
//global data in environment, not sent step by step view by view
#EnvironmentObject var modelClass_ViaEnvironment: ModelClass_ViaEnvironment
var body: some View {
VStack(spacing: 5) {
Text("Second View")
Button("change bool for everyone") {
modelClass_viaObservedObject.isOn.toggle()
dismiss()
}
TextField("send back", text: $modelClass_ViaEnvironment.message)
Text(modelClass_ViaEnvironment.message)
}
}
}
No, we use #State for view data like if a toggle isOn, which can either be a single value itself or a custom struct containing multiple values and mutating funcs. We pass it down the View hierarchy by declaring a let in the child View or use #Binding var if we need write access. Regardless of if we declare it let or #Binding whenever a different value is passed in to the child View's init, SwiftUI will call body automatically (as long as it is actually accessed in body that is).
#StateObject is for when a single value or a custom struct won't do and we need a reference type instead for view data, i.e. if persisting or syncing data (not using the new async/await though because we use .task for that). The object is init before body is called (usually before it is about to appear) and deinit when the View is no longer needed (usually after it disappears).
#EnvironmentObject is usually for the store object that holds model structs in #Published properties and is responsible for saving or syncing,. The difference is the model data is not tied to any particular View, like #State and #StateObject are for view data. This object is usually a singleton, one for the app and one with sample data for when previewing, because it should never be deinit. The advantage of #EnvironmentObject over #ObservedObject is we don't need to pass it down through each View as a let that don't need the object when we only need it further down the hierarchy. Note the reason it has to be passed down as a let and not #ObservedObject is then body would be needlessly called in the intermediate Views because SwiftUI's dependency tracking doesn't work for objects only value types.
Here is some sample code:
struct MyConfig {
var isOn = false
var message = ""
mutating func reset() {
isOn = false
message = ""
}
}
struct MyView: View {
#State var config = MyConfig() // grouping vars into their struct makes use of value semantics to track changes (a change to any of its properties is detected as a change to the struct itself) and offers testability.
var body: some View {
HStack {
ViewThatOnlyReads(config: config)
ViewThatWrites(config: $config)
}
}
}
struct ViewThatOnlyReads: View {
let config: MyConfig
var body: some View {
Text(config.isOn ? "It's on" : "It's off")
}
}
struct ViewThatWrites: View {
#Binding var config: MyConfig
var body: some View {
Toggle("Is On", isOn: $config.isOn)
}
}

EnvironmentObject causes unrelated ObservedObject to reset

I am not quite sure I understand what is going on here as I am experimenting with an EnvironmentObject in SwiftUI.
I recreated my problem with a small example below, but to summarize: I have a ContentView, ContentViewModel, and a StateController. The ContentView holds a TextField that binds with the ContentViewModel. This works as expected. However, if I update a value in the StateController (which to me should be completely unrelated to the ContentViewModel) the text in the TextField is rest.
Can someone explain to me why this is happening, and how you could update a state on an EnvironmentObject without having SwiftUI redraw unrelated parts?
App.swift
#main
struct EnvironmentTestApp: App {
#ObservedObject var stateController = StateController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(stateController)
}
}
}
ContentView.swift
struct ContentView: View {
#ObservedObject private var viewModel = ContentViewModel()
#EnvironmentObject private var stateController: StateController
var body: some View {
HStack {
TextField("Username", text: $viewModel.username)
Button("Update state") {
stateController.validated = true
}
}
}
}
ContentViewModel.swift
class ContentViewModel: ObservableObject {
#Published var username = ""
}
StateController.swift
class StateController: ObservableObject {
#Published var validated = false
}
Like lorem-ipsum pointed out, you should use #StateObject.
A good rule of thumb is to use #StateObject every time you init a viewModel, but use #ObservedObject when you are passing in a viewModel that has already been init.

SwiftUI clean up ContentView

I'm trying to simplify the ContentView within a project and I'm struggling to understand how to move #State based logic into its own file and have ContentView adapt to any changes. Currently I have dynamic views that display themselves based on #Binding actions which I'm passing the $binding down the view hierarchy to have buttons toggle the bool values.
Here's my current attempt. I'm not sure how in SwiftUI to change the view state of SheetPresenter from a nested view without passing the $binding all the way down the view stack. Ideally I'd like it to look like ContentView.overlay(sheetPresenter($isOpen, $present).
Also, I'm learning SwiftUI so if this isn't the best approach please provide guidance.
class SheetPresenter: ObservableObject {
#Published var present: Present = .none
#State var isOpen: Bool = false
enum Present {
case none, login, register
}
#ViewBuilder
func makeView(with presenter: Present) -> some View {
switch presenter {
case .none:
EmptyView()
case .login:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
LoginScreen()
}
case .register:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
RegisterScreen()
}
}
}
}
if you don't want to pass $binding all the way down the view you can create a StateObject variable in the top view and pass it with .environmentObject(). and access it from any view with EnvironmentObject
struct testApp: App {
#StateObject var s1: sViewModel = sViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(s1)
}
}
}
You are correct this is not the best approach, however it is a common mistake. In SwiftUI we actually use #State for transient data owned by the view. This means using a value type like a struct, not classes. This is explained at 4:18 in Data Essentials in SwiftUI from WWDC 2020.
EditorConfig can maintain invariants on its properties and be tested
independently. And because EditorConfig is a value type, any change to
a property of EditorConfig, like its progress, is visible as a change
to EditorConfig itself.
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct BookView: View {
#State private var editorConfig = EditorConfig()
func presentEditor() { editorConfig.present(…) }
var body: some View {
…
Button(action: presentEditor) { … }
…
}
}
Then you just use $editorConfig.isEditorPresented as the boolean binding in .sheet or .overlay.
Worth also taking a look at sheet(item:onDismiss:content:) which makes it much simpler to show an item because no boolean is required it uses an optional #State which you can set to nil to dismiss.

SwiftUI Navigation: why is Timer.publish() in View body breaking nav stack

SwiftUI n00b here. I'm trying some very simple navigation using NavigationView and NavigationLink. In the sample below, I've isolated to a 3 level nav. The 1st level is just a link to the 2nd, the 2nd to the 3rd, and the 3rd level is a text input box.
In the 2nd level view builder, I have a
private let timer = Timer.publish(every: 2, on: .main, in: .common)
and when I navigate to the 3rd level, as soon as I start typing into the text box, I get navigated back to the 2nd level.
Why?
A likely clue that I don't understand. The print(Self._printChanges()) in the 2nd level shows
NavLevel2: #self changed.
immediately when I start typing into the 3rd level text box.
When I remove this timer declaration, the problem goes away. Alternatively, when I modify the #EnvironmentObject I'm using in the 3rd level to just be #State, the problem goes away.
So trying to understand what's going on here, if this is a bug, and if it's not a bug, why does it behave this way.
Here's the full ContentView building code that repos this
import SwiftUI
class AuthDataModel: ObservableObject {
#Published var someValue: String = ""
}
struct NavLevel3: View {
#EnvironmentObject var model: AuthDataModel
var body: some View {
print(Self._printChanges())
return TextField("Level 3: Type Something", text: $model.someValue)
// Replacing above with this fixes everything, even when the
// below timer is still in place.
// (put this decl instead of #EnvironmentObject above
// #State var fff: String = ""
// )
// return TextField("Level 3: Type Something", text: $fff)
}
}
struct NavLevel2: View {
// LOOK HERE!!!! Removing this declaration fixes everything.
private let timer = Timer.publish(every: 2, on: .main, in: .common)
var body: some View {
print(Self._printChanges())
return NavigationLink(
destination: NavLevel3()
) { Text("Level 2") }
}
}
struct ContentView: View {
#StateObject private var model = AuthDataModel()
var body: some View {
print(Self._printChanges())
return NavigationView {
NavigationLink(destination: NavLevel2())
{
Text("Level 1")
}
}
.environmentObject(model)
}
}
First, if you remove #StateObject from model declaration in ContentView, it will work.
You should not set the whole model as a State for the root view.
If you do, on each change of any published property, your whole hierarchy will be reconstructed. You will agree that if you type changes in the text field, you don't want the complete UI to rebuild at each letter.
Now, about the behaviour you describe, that's weird.
Given what's said above, it looks like when you type, the whole view is reconstructed, as expected since your model is a #State object, but reconstruction is broken by this unmanaged timer.. I have no real clue to explain it, but I have a rule to avoid it ;)
Rule:
You should not make timers in view builders. Remember swiftUI views are builders and not 'views' as we used to represent before. The concrete view object is returned by the 'body' function.
If you put a break on timer creation, you will notice your timer is called as soon as the root view is displayed. ( from NavigationLink(destination: NavLevel2())
That's probably not what you expect.
If you move your timer creation in the body, it will work, because the timer is then created when the view is created.
var body: some View {
var timer = Timer.publish(every: 2, on: .main, in: .common)
print(Self._printChanges())
return NavigationLink(
destination: NavLevel3()
) { Text("Level 2") }
}
However, it is usually not the right way neither.
You should create the timer:
in the .appear handler, keep the reference,
and cancel the timer in .disappear handler.
in a .task handler that is reserved for asynchronous tasks.
I personally only declare wrapped values ( #State, #Binding, .. ) in view builders structs, or very simple primitives variables ( Bool, Int, .. ) that I use as conditions when building the view.
I keep all functional stuffs in the body or in handlers.
To stop going back to the previous view when you type in the TextField add .navigationViewStyle(.stack) to the NavigationView
in ContentView.
Here is the code I used to test my answer:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var model = AuthDataModel()
var body: some View {
NavigationView {
NavigationLink(destination: NavLevel2()){
Text("Level 1")
}
}.navigationViewStyle(.stack) // <--- here the important bit
.environmentObject(model)
}
}
class AuthDataModel: ObservableObject {
#Published var someValue: String = ""
}
struct NavLevel3: View {
#EnvironmentObject var model: AuthDataModel
var body: some View {
TextField("Level 3: Type Something", text: $model.someValue)
}
}
struct NavLevel2: View {
#EnvironmentObject var model: AuthDataModel
#State var tickCount: Int = 0 // <-- for testing
private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
var body: some View {
NavigationLink(destination: NavLevel3()) {
Text("Level 2 tick: \(tickCount)")
}
.onReceive(timer) { val in // <-- for testing
tickCount += 1
}
}
}

SwiftUI #State variables aren't updated

I basically have the same code as in this question. The problem I have is that when the tapGesture event happens, the sheet shows (the sheet code is called) but debug shows that showUserEditor is false (in that case, how is the sheet showing...?) and that selectedUserId is still nil (and therefore crashes on unwrapping it...)
The view:
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUserId : NSManagedObjectID? = nil
#State private var showUserEditor = false
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUserId = user.objectID
self.showUserEditor = true
}
}
}
}.sheet(isPresented: $showUserEditor) {
UserEditorView(userId: self.selectedUserId!)
}
}
}
If you want, I can publish the editor and the row but they seem irrelevant to the question as the magic should happen in the view.
So, I still haven't figured out WHY the code posted in the question didn't work, with a pointer from #loremipsum I got a working code by using another .sheet() method, one that takes an optional Item and not a boolean flag. The code now looks like this and works, but still if anyone can explain why the posted code didn't work I'd appreciate it.
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}.onDelete(perform: deleteItems)
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in
UserEditorView(user: user)
}
}
}
struct == immutable and SwiftUI decides when the struct gets init and reloaded
Working with code that depends on SwiftUI updating non-wrapped variables at a very specific time is not recommended. You have no control over this process.
To make your first setup work you need to use SwiftUI wrappers for the variables
.sheet(isPresented: $showUserEditor) {
//struct == immutable SwiftUI wrappers load the entire struct when there are changes
//With your original setup this variable gets created/set when the body is loaded so the orginal value of nil is what is seen in the next View
UserEditorView1(userId: $selectedUserId)
}
struct UserEditorView1: View {
//This is what you orginal View likely looks like it won't work because of the struct being immutable and SwiftUI controlling when the struct is reloaded
//let userId: NSManagedObjectID? <---- Depends on specific reload steps
//To make it work you would use a SwiftUI wrapper so the variable gets updated when SwiftUI descides to update it which is invisible to the user
#Binding var userId: NSManagedObjectID?
//This setup though now requres you to go fetch the object somehow and put it into the View so you can edit it.
//It is unnecessary though because SwiftUI provides the .sheet init with item where the item that is set gets passed directly vs waiting for the SwiftUi update no optionals
var body: some View {
Text(userId?.description ?? "nil userId")
}
}
Your answer code doesn't work because your parameter is optional and Binding does not like optionals
struct UserEditorView2: View {
//This is the setup that you posted in the Answer code and it doesn't work becaue of the ? Bindings do not like nil. You have to create wrappers to compensate for this
//But unecessary because all CoreData objects are ObservableObjects so you dont need Binding here the Binding is built-in the object for editing the variables
#Binding var user: User?
var body: some View {
TextField("nickname", text: $user.nickname)
}
}
Now for working code with an easily editable CoreData Object
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
//Your list view would use the CoreData object to trigger a sheet when the new value is available. When nil there will not be a sheet available for showing
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in //This gives you a non-optional user so you don't have to compensate for nil in the next View
UserEditorView3(user: user)
}
}
}
Then the View in the sheet would look like this
struct UserEditorView3: View {
//I mentioned the ObservedObject in my comment
#ObservedObject var user: User
var body: some View {
//If your nickname is a String? you have to compensate for that optional but it is much simpler to do it from here
TextField("nickname", text: $user.nickname.bound)
}
}
//This comes from another very popular SO question (couldn't find it to quote it) that I could not find and is necessary when CoreData does not let you define a variable as non-optional and you want to use Binding for editing
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
public var bound: String {
get {
//This just give you an empty String when the variable is nil
return _bound ?? ""
}
set {
_bound = newValue.isEmpty ? nil : newValue
}
}
}