Search code examples
swiftswiftuideclarative

SwiftUI: Change @State variable through a function called externally?


So maybe I'm misunderstanding how SwiftUI works, but I've been trying to do this for over an hour and still can't figure it out.

struct ContentView: View, AKMIDIListener {
    @State var keyOn: Bool = false

    var key: Rectangle = Rectangle()

    var body: some View {
        VStack() {
            Text("Foo")
            key
                .fill(keyOn ? Color.red : Color.white)
                .frame(width: 30, height: 60)
        }
        .frame(width: 400, height: 400, alignment: .center)
    }

    func receivedMIDINoteOn(noteNumber: MIDINoteNumber, velocity: MIDIVelocity, channel: MIDIChannel, portID: MIDIUniqueID? = nil, offset: MIDITimeStamp = 0) {
        print("foo")
        keyOn.toggle()
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

So the idea is really simple. I have an external midi keyboard using AudioKit. When a key on the keyboard is pressed, the rectangle should change from white to red.

The receivedMIDINoteOn function is being called and 'foo' is printed to the console, and despite keyOn.toggle() appearing in the same function, this still won't work.

What's the proper way to do this?

Thanks


Solution

  • Yes, you are thinking of it slightly wrong. @State is typically for internal state changes. Have a button that your View directly references? Use @State. @Binding should be used when you don't (or shouldn't, at least) own the state. Typically, I use this when I have a parent view who should be influencing or be influenced by a subview.

    But what you are likely looking for, is @ObservedObject. This allows an external object to publish changes and your View subscribes to those changes. So if you have some midi listening object, make it an ObservableObject.

    final class MidiListener: ObservableObject, AKMIDIListener {
      // 66 key keyboard, for example
      @Published var pressedKeys: [Bool] = Array(repeating: false, count: 66)
    
      init() {
        // set up whatever private storage/delegation you need here
      }
    
      func receivedMIDINoteOn(noteNumber: MIDINoteNumber, velocity: MIDIVelocity, channel: MIDIChannel, portID: MIDIUniqueID? = nil, offset: MIDITimeStamp = 0) {
        // how you determine which key(s) should be pressed is up to you. if this is monophonic the following will suffice while if it's poly, you'll need to do more work
        DispatchQueue.main.async {
          self.pressedKeys[Int(noteNumber)] = true
        }
      }
    }
    

    Now in your view:

    struct KeyboardView: View {
      @ObservedObject private var viewModel = MidiListener()
    
      var body: some View {
        HStack {
          ForEach(0..<viewModel.pressedKeys.count) { index in
            Rectangle().fill(viewModel.pressedKeys[index] ? Color.red : Color.white)
          }
        }
      }
    }
    

    But what would be even better is to wrap your listening in a custom Combine.Publisher that posts these events. I will leave that as a separate question, how to do that.