I'm new to SwiftUI and was wondering if there is a concept similar to React.useEffect in SwiftUI.
Below is my code for listening keyboard events on macos.
import SwiftUI
import PlaygroundSupport
struct ContentView : View {
var hello: String
@State var monitor: Any?
@State var text = ""
init(hello: String) {
self.hello = hello
print("ContentView init")
}
var body: some View {
VStack{
Text(hello)
.padding()
TextField("input", text: $text)
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
print(hello)
return nil
}
}
}
}
struct MainView: View {
@State var hello: String = "h"
var body: some View {
ContentView(hello: hello)
Button(action: {
hello = "_h"
}) {
Text("tap me")
}
}
}
PlaygroundPage.current.setLiveView(MainView())
The playground output is as follows
ContentView init
h
h
ContentView init
h
h
h
Since onAppear trigger only once, even ContentView init multiple times. So the event callback here always prints the first captured value ("h").
So where should I add event listener and where should I remove it?
CodePudding user response:
In React, you use useEffect
from within a Component in order to declare a task or operation which causes side effects outside the rendering phase.
Well, SwiftUI is not exactly React, and there are problems and use cases which you would solve in a complete different approach. But, when trying to find something similar:
In SwiftUI you could call any function which is called from any "action" closure, for example from a SwiftUI Button
. This function can modify @State
variables, without disrupting the rendering phase.
Or you can use the Task Modifier, i.e. calling .task { ... }
for a SwiftUI view, which comes probably closest.
Personally, I would not declare to use any task or operation which causes side effects to the AppState or Model within a SwiftUI View's body function. Rather, I prefer to send actions (aka "Intent", "Event") from the user to a Model or a ViewModel, or a Finite State Automaton. These events then get processed in a pure function, call it "update()", which performs the "logic", and this pure function may declare "Effects". These effects will then be called outside this pure update function, cause there side effects wherever they need to, and return a result which is materialised as an event, which itself gets fed into the pure update function again. That update function produces a "view state", which the view needs to render.
Now, I want to clarify some potential misconceptions:
"Since onAppear trigger only once, even ContentView init multiple times"
onAppear
This can be actually called several times for a view which you identify on the screen as a "view".
Usually, it is not always without issues to utilise onAppear
for performing some sort of initialisation or setup. There are approaches to avoid these problem altogether, though.
"ContentView init"
You are better off viewing a SwiftUI View as a "function" (what?)
With that "function" you achieve two things:
- Create an underlying view whose responsibility is to render pixels and also create (private) data for this view which it needs to render accordingly.
- Modify this data or attributes of this underlying view.
For either action, you have to call the SwiftUI View's initialiser. When either action is done, the SwiftUI View (a struct!) will diminish again. Usually, the struct's value, the SwiftUI View resides on the stack only temporarily.
Variables declared as @State
and friends, are associated to the underlying view which is responsible to render the pixels. Their lifetime is bound to this renderable view which you can perceive on the screen.
Now, looking at your code, it should work as expected. You created a private @State
variable for the event handler object. This seems to be the right approach. However, @State
is meant as a private variable where a change would cause the view to render differently. Your event handler object is actually an "Any", i.e. a reference. This reference never changes: it will be setup at onAppear
then it never changes anymore, except onAppear
will be called again for the same underlying renderable view. There is probably a better solution than using @State
and onAppear
for your event handler object (see below later).
Now, when you want to render the event's value (aka mask
as NSEvent.EventTypeMask
) then you need another @State
variable in your SwiftUI View of this type, which you set/update in the notification handler. The variable should be a struct or enum, not a reference!
SwiftUI then notifies the changes to this variable and in turn will call the body function where you explicitly render this value. Note, that you can update a @State
variable from any thread.
Problems
According the documentation "You must call removeMonitor(_:) to stop the monitor."
Unfortunately, your @State
variable which holds the reference to the event handler object will not call removeMonitor(_:)
when the underlying renderable view gets deallocated.
Bummer!
What you have to do is, changing your design. What you need to do is to introduce a "Model" which is an ObservableObject
. It should publish a value (a representation of what you receive in the notification handler) which will be rendered in the SwiftUI view accordingly.
This Model should also receive an event (say a function will be called for the Model from the SwiftUI view) when the view appears, where the Model then creates the event handler object, unless it has been created already (which completely solves your onAppear
issues). Alternatively, just create the event handler once and only once in the Model's initialiser - which is arguable the better solution.
When the event handler's notification handler will be called, you update the published value of your Model accordingly.
Integrating the Model - an ObservableObject - properly into a SwiftUI view is a standard pattern in SwiftUI. Please look for help on SO, if you are uncertain how to accomplish this.
Now, since the Model is a class value, you can ensure to call removeMonitor(_:)
in its deinit
function.
Headstart
import SwiftUI
final class EventHandlerModel: ObservableObject {
private var monitor: Any!
@Published private(set) var viewState: String = ""
init() {
monitor = NSEvent.addLocalMonitorForEvents(
matching: .keyDown
) { event in
assert(Thread.isMainThread)
self.viewState = "\(event)"
return event
}
}
deinit {
guard let monitor = self.monitor else {
return
}
NSEvent.removeMonitor(monitor)
}
}
struct ContentView: View {
@StateObject private var model = EventHandlerModel()
var body: some View {
Text(verbatim: model.viewState)
}
}