2

I have ContentView.swift

struct ContentView: View {
    @State var seconds: String = "60"

    init(_ appDelegate: AppDelegate) {
        launchTimer()
    }
    
    func launchTimer() {
        let timer = DispatchTimeInterval.seconds(5)
        let currentDispatchWorkItem = DispatchWorkItem {
            print("in timer", "seconds", seconds) // Always `"60"`
            print("in timer", "$seconds.wrappedValue", $seconds.wrappedValue) // Always `"60"`

            launchTimer()
        }
        
        DispatchQueue.main.asyncAfter(deadline: .now() + timer, execute: currentDispatchWorkItem)
    }

    var body: some View {
        TextField("60", text: $seconds)
    }
    
    func getSeconds() -> String {
        return $seconds.wrappedValue;
    }
}

And AppDelegate.swift

class AppDelegate: NSObject, NSApplicationDelegate {
    var contentView = ContentView()

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        launchTimer()
    }

    
    func launchTimer() {
        print(contentView.getSeconds()) // Always `"60"`

        let timer = DispatchTimeInterval.seconds(Int(contentView.getSeconds()) ?? 0)
        
        DispatchQueue.main.asyncAfter(deadline: .now() + timer) {
            self.launchTimer()
        }
    }
    

    @objc func showPreferenceWindow(_ sender: Any?) {
        if(window != nil) {
            window.close()
        }
        
        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        window.isReleasedWhenClosed = false
        window.contentView = NSHostingView(rootView: contentView)
        
        window.center()
        window.makeKeyAndOrderFront(nil)
        NSApplication.shared.activate(ignoringOtherApps: true)
    }
}

contentView.getSeconds() always returns 60, even if I modify the value of the TexField in my content view (by manually typing in the text field).

How does one get the wrapped value/real value of a state variable from the app delegate?

Maxime Dupré
  • 5,319
  • 7
  • 38
  • 72
  • 3
    Make a ViewModel and share it somehow instead. SwiftUI Views aren’t meant to be accessed that way. – lorem ipsum Sep 21 '21 at 16:33
  • ViewModel, I'll look into that, thanks. Do you have an explanation as to why my original example would not work/can you point to some documentation that would explain why? Or can you elaborate on "SwiftUI Views aren’t meant to be accessed that way.". From someone that's new to Swift, what I'm doing seems pretty logical lol – Maxime Dupré Sep 21 '21 at 16:43
  • 2
    Here is an approach https://stackoverflow.com/a/69055669/12299030 – Asperi Sep 21 '21 at 16:46
  • 2
    @MaximeDupré Views in SwiftUI are transitive -- you can't hold references to them and expect them to persist. You don't show in your code how you're getting `contentView` in your App Delegate, but it definitely won't be the same instance that's being rendered on the screen. In general, parent views/objects shouldn't try to reference things that child views own -- data should always be passed from the parent down to the child. – jnpdx Sep 21 '21 at 16:47
  • @jnpdx Shouldn't they persist as long as the `contentView` instance exists? Why is my instance of `contentView` not the instance that is being rendered on the screen ? "data should always be passed from the parent down to the child." -> how does the child ever communicate back changes to the parent then? – Maxime Dupré Sep 21 '21 at 16:55
  • @Asperi Thanks I'll try using a `DataModel` as recommended by this question. Seems to be similar to the first suggestion in the comments (`ViewModel`). – Maxime Dupré Sep 21 '21 at 16:57
  • Every time you write `ContentView()` it's a *new* instance. You may have that in your App Delegate, but I guarantee you also have it in your view hierarchy. Also, even then, those views are structs and are *transative* -- they'll get rebuilt by SwiftUI *many* times. Regarding the child communicating to the parent, the child can modify instances owned by the parent via a binding, a reference type (class), etc. See the link that @Asperi provided. – jnpdx Sep 21 '21 at 16:57
  • I've got only one `ContentView` instance, which is instantiated when AppDelegate is initialized. My project is very small and simple at the moment. Interesting though, that SwiftUI rebuilds views (and the included structs) many times - but I don't see how that would cause the wrapped value to be incorrect (or discarded or reset), as it would also cause the binding to the `TextField` to be incorrect. But most likely you are right since my stuff is visibly not working lol! – Maxime Dupré Sep 21 '21 at 17:03
  • > One of the most important differences between structures and classes is that structures are always copied when they’re passed around in your code, but classes are passed by reference. - That seems to be a potential explanation – Maxime Dupré Sep 21 '21 at 17:14
  • @MaximeDupré I don't know exactly why but if I had to guess it has something to do with the inner workings of the wrappers. In this case `@State` says that it should be a `private` variable and should only be accessed in the `body` or functions called by it. [Apple.](https://developer.apple.com/documentation/swiftui/state) says it isn't meant to be called in any other way. – lorem ipsum Sep 21 '21 at 17:15
  • @loremipsum Thanks for the answer. I don't think this is the explanation. The doc states that "You should only access a state property from inside the view’s body, **or from methods called by it**. For this reason, declare your state properties as private, to prevent clients of your view from accessing them. **It is safe to mutate state properties from any thread.**". While it is true that my state var isn't private, I'm accessing the value via a public function of the view `getSeconds`. But I might be totally wrong. Thanks for the input. – Maxime Dupré Sep 21 '21 at 17:20
  • You are misreading what Apple is saying. "or from methods called by it" means either a method internal to the struct OR you have to pass the `@State var` into an external method being called from within the body of the struct. The reason it is not automatically private is because you can instantiate the struct and set the `@State var` at the same time. Your `ContentView()` could be called as `ContentView(seconds: "60")` for exactly the same effect as the initial value you put in your code. – Yrb Sep 21 '21 at 19:14
  • 1
    Edit your question to show us the code that puts a `ContentView` on screen. – rob mayoff Sep 21 '21 at 19:28
  • @robmayoff Done – Maxime Dupré Sep 22 '21 at 16:16
  • OK but then take my advice: you are really hamstringing yourself by using a combination of AppKit (sorry I said UIKit before) and SwiftUI. You should be using the modern SwiftUI where there is no AppDelegate. – matt Sep 22 '21 at 17:10
  • @matt Yeah, and correct me know if I'm wrong, but SwiftUI lifecycle is only available in macOS 11 and I'm stuck at 10.15 (my Mac can't go over that), which means I don't really have the choice. Or should I go all-in with AppKit instead? What do you think is the best scenario in my case? – Maxime Dupré Sep 22 '21 at 17:15
  • 1
    Ah, _now_ you are really asking an opinion-type question. You're asking me (I think) whether a complete beginner, wanting to learn to write a Mac app (not an iOS app), should go with Swift AppKit or SwiftUI. I have no idea. :) But SwiftUI is a fun choice for sure; however, I really think you should take yourself through Apple's interactive tutorials first. https://developer.apple.com/tutorials/swiftui And you should watch some of the WWDC SwiftUI videos too. https://developer.apple.com/videos/wwdc2021?q=swiftui – matt Sep 22 '21 at 17:19
  • @matt That's kind of what I'm asking. I personally would like to use SwiftUI, so my question is more: Is it possible to use SwiftUI exclusively when running macOS 10.15, or am I forced to use a mix of SwiftUI and AppKit, because some features, like SwiftUI lifecycle, are not available on macOS 10.15? – Maxime Dupré Sep 22 '21 at 17:28
  • I asked a very similar question a while ago: https://stackoverflow.com/q/66973097/14351818 – aheze Sep 24 '21 at 14:53
  • @aheze That's very interesting and good to know. However, I don't think it answers why `$seconds.wrappedValue` is always 60 in the timer that is **inside** the `ContentView` – Maxime Dupré Sep 24 '21 at 15:23

4 Answers4

2

@State/@StateObject is tricky business, what happens is that SwiftUI connects the state values to a certain view instance from the UI hierarchy.

In your case, the @State var seconds: String = "60" is connected to the view below (simplified scheme):

NSWindow
  -> NSHostingView
    -> ContentView <----- this is where the @State usage is valid

And as others said, ContentView being a struct, it's a value type, meaning that its contents are copied in all usage sites, so instead of having a unique instance, like a class has, you end up with multiple instances.

And only one of those instances, the copy SwiftUI makes when it adds the view to the UI tree, is the one that is connected to the text field, and gets updated.

To make things even funnier, the State property wrapper is also a struct, meaning that it "suffers" from the same symptoms as the view.

That's one of the perks of using SwiftUI, in contrast with UIKit, you don't care at all about the view instances.

Now, if you want to use the value of seconds from AppDelegate, or any other place, you will have to circulate the data storage instead of the view.

For starters, change the view to receive a @Binding instead

struct ContentView: View {
    @Binding var seconds: String = "60"

    // the rest of the view code remains the same

Then create an ObservableObject that stores the data, and inject its $seconds binding when creating the view:

class AppData: ObservableObject {
    @Published var seconds: String = "60"
}

class AppDelegate {
    let appData = AppData()

    // ... other code left out for clarity

    @objc func showPreferenceWindow(_ sender: Any?) {
        // ... 
        let contentView = ContentView(seconds: appData.$seconds
        window.contentView = NSHostingView(rootView: contentView)
        // ... 
    }
}

SwiftUI is more about the data than the view itself, so if you want to access the same data in multiple places, make sure you circulate the storage instead of the view the data is attached to. And also make sure that the source of truth of that data is a class, as object references are guaranteed to point to the same memory location (assuming of course the reference doesn't change).

Cristik
  • 30,989
  • 25
  • 91
  • 127
  • (1/2) Thanks for the answer. "To make things even funnier, the State property wrapper is also a struct, meaning that it "suffers" from the same symptoms as the view." -> . Is this the reason why it always prints `"60"` in the timer of my `ContentView`? Also, then, why is the "copy" of my state variable `seconds` that is passed to the `TextField` the "good" copy: `TextField("60", text: $seconds)`? Aren't the copy that is passed to the `TextField` and the copy that is connected to the struct instance two different copies? – Maxime Dupré Sep 24 '21 at 19:30
  • (2/2) And how is it that I can pass two different copies to two different `TextField`s, yet those text fields will be synchronous and seemingly possess the same binding (one that is different than the one in the timer, for example)? – Maxime Dupré Sep 24 '21 at 19:30
  • Note that you're passing `$seconds` (notice the dollar sign) to the textfield, this creates a binding which operates on the same storage as the `seconds` property (`seconds` which is actually property wrapped by the @State property wrapper) – Cristik Sep 24 '21 at 19:32
  • Yes, but I'm also using `$seconds` in the `DispatchQueue`/timer (the one **inside** my `ContentView`), yet the value is always `"60"`, even after the text field has been modified. Furthermore, isn't `$seconds` just a shorthand for the state variable `projectedValue` -> `seconds.projectedValue`, both `seconds` (`State`) and `seconds.projectedValue` (`Binding`) structs, meaning that those are different copies every time I reference them in my code? – Maxime Dupré Sep 24 '21 at 19:37
  • Yes, but that's another `$second`, corresponding to the instance copy. Only SwiftUI can reliably use `@State`, as it knows to use the same storage for the view copy from the UI hierarchy. Point to be taken here is that you should not access `@State` from any other context than the view itself that needs to render stuff on the screen. – Cristik Sep 24 '21 at 19:39
  • Use `ObservableObject`, or circulate a `Binding` if you really need that. – Cristik Sep 24 '21 at 19:40
  • "Yes, but that's another $second, corresponding to the instance copy." -> Just to be clear, the `$seconds`, which is inside the timer of the `ContentView` itself is corresponding to an instance copy? How is it not corresponding to `self.$seconds` since it is the instance itself? Furthermore, wouldn't each copy of the `ContentView` be printing a value in the timer (the timer is called in `ContentView.init`? One of them would surely print the right value lol. Point taken, I'm just trying to understand deeper – Maxime Dupré Sep 24 '21 at 19:48
  • I'd just like to reiterate I'm talking about the timer in `ContentView`, not the one in `AppDelegate` – Maxime Dupré Sep 24 '21 at 19:49
  • @MaximeDupré The only `$seconds` that is updated by the textfield is the one from the copy SwiftUI makes when you sent `contentView` to the `NSHostingView`. And you don't have access to that copy. – Cristik Sep 24 '21 at 19:49
  • 1
    @MaximeDupré It's all about value types vs reference types, try making `ContentView` a class instead of a struct, and see if things start changing. – Cristik Sep 24 '21 at 19:51
1

Your ContentView (as well as any other SwiftUI view) is a struct, ie. value type, so in every place where you use it - you use a new copy of initially created value, ie.:

class AppDelegate: NSObject, NSApplicationDelegate {
    var contentView = ContentView()       // << 1st copy

    ...
    
    func launchTimer() {
        print(contentView.getSeconds())   // << 2nd copy
        let timer = DispatchTimeInterval.seconds(Int(contentView.getSeconds()) ?? 0)  // << 3d copy
        
    ...

    }
    
    ...

    @objc func showPreferenceWindow(_ sender: Any?) {

    ...
        
        window.contentView = NSHostingView(rootView: contentView)   // << 4th copy
        
    }
}

You should not use any @State externally, because it is valid only inside view's body.

If you need something to share/access in different places then use class confirming to ObservableObject as view model type, and as instances of such class is a reference type then passing it here and there you can use/access same object from different places.

Update:

why the value stays to "60" even inside the ContentView

You call launchTimer in init, but in that place State is not constructed yet, so binding to it is not valid. As was already written above state is valid in body, so you have to set your timer also in body when binding will be ready, say in .onAppear, like below

var body: some View {
    TextField("60", text: $seconds)
         .onAppear {
              launchTimer()
         }
}

Prepared & tested with Xcode 13 / iOS 15

demo

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • I see. That JS background is messing me up :D. I understand your answer, but it does not seem to answer why the value stays to "60" even **inside** the `ContentView` (see edit) – Maxime Dupré Sep 24 '21 at 15:26
  • @MaximeDupré, see updated part – Asperi Sep 24 '21 at 15:52
  • So when is the State constructed? Do you know of some documentation that points to the order of initialization? And how is it possible to get the `projectedValue` (a.k.a using the $ sign on the variable) if the `State` has not been constructed yet? I think you are right in your answer, but I'd still like to understand those questions.. – Maxime Dupré Sep 24 '21 at 16:17
0

add a variable to appDelegate

var seconds : Int? 

then do this in your contentView :

    .onChange(of: seconds) { seconds in
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {return}
        appDelegate.seconds = seconds
    }
  • Thanks for the answer. Do you know why my example does not work though/can you point to some documentation that could explain why? Your solution should work - it seems, however, that `onChange` is only available in macOS 11 and I'm on 10.15. Do you know of another way that would work on 10.15? – Maxime Dupré Sep 21 '21 at 16:41
  • you are creating an instance of ContetView in your AppDelegate , which is not the same instance created by your @main : App . basically you are working with 2 different instances of the ContetView struct , that is why it keeps printing 60 . – keyvan yaghoubian Sep 21 '21 at 18:16
  • @MaximeDupré are you using swiftUI lifecycle or uiKit ? – keyvan yaghoubian Sep 21 '21 at 20:05
  • Oh...I think uiKit, since swiftUI lifecycle is only available in macOS 11 – Maxime Dupré Sep 22 '21 at 16:13
  • Are you sure I'm working with a different instance? I seem to be using the right one (see edit) – Maxime Dupré Sep 22 '21 at 16:25
  • share SceneDelegate code . – keyvan yaghoubian Sep 23 '21 at 07:37
  • Not using `SceneDelegate`. I'm using `AppDelegate` from AppKit, since Swift UI isn't available on 10.15 – Maxime Dupré Sep 23 '21 at 14:36
0

Ended up instantiating the ContentView with the AppDelegate as a param. I added a seconds attribute to the AppDelegate to be modified by the ContentView.

AppDelegate.swift

import SwiftUI

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    var contentView: ContentView?
    var seconds = 60
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        self.contentView = ContentView(self)        

        launchTimer()
    }
    
    func launchTimer() {
        let timer = DispatchTimeInterval.seconds(seconds)
        
        print(timer)
        
        DispatchQueue.main.asyncAfter(deadline: .now() + timer) {
            self.launchTimer()
        }
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    @State var seconds: String = "60"
    var appDelegate: AppDelegate
    
    init(_ appDelegate: AppDelegate) {
        self.appDelegate = appDelegate
    }

    var body: some View {
        TextField("60", text: $seconds, onCommit: {
            self.appDelegate.seconds = Int($seconds.wrappedValue) ?? 0
         })
    }
}

This code hurts my brain and surely there is a better way of doing this . But I'm only so far in my Swift journey, so that will do for now lol. Please post if you have a better solution.

Maxime Dupré
  • 5,319
  • 7
  • 38
  • 72