Home > database >  Is there some thing like React.useEffect in SwiftUI?
Is there some thing like React.useEffect in SwiftUI?

Time:11-01

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:

  1. Create an underlying view whose responsibility is to render pixels and also create (private) data for this view which it needs to render accordingly.
  2. 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)
    }
}
  • Related