swiftswiftuicombineuiviewrepresentable

How to observe multiple changes in UIViewRepresentable?


Continuing from my previous question, what is the best way to observe several state changes from a UIViewRepresentable? Previously, I required to only observe changes to one property userWon. However, as new functionality was introduced in the app, I need to now observe new changes. Assuming these could increase even more, is the best way to do this by adding more State properties and binding them to the UIViewRepresentable? It feels somewhat hacky at this point to add more and more bindings and onChange modifiers to observe for changes. Example code is as follows:

struct GameView: View {
    @State private var userWon: Bool = false
    @State private var propA: Bool = false
    @State private var propB: Int = 0
    @State private var propC: Int = 0
    ...
    ...
    ...
    @State private var propZ: Int = 0
    @StateObject private var gameService = GameService()
    
    var body: some View {
        ZStack {
            VStack {
                GameViewRepresentable(userWon: $userWon,
                                      propA: $propA,
                                      propB: $propB,
                                      propC: $propC,
                                      ...
                                      propZ: $propZ) // <- Add more bindings??
                    .onChange(of: userWon) { newValue in
                        if newValue {
                           gameService.updateUserScore()
                        }
                    }
                    .onChange(of: propA) { newValue in
                           gameService.doSomethingA()
                    }
                    .onChange(of: propB) { newValue in
                           gameService.doSomethingB()
                    }
                    .onChange(of: propZ) { newValue in
                           gameService.doSomethingZ()
                    }
            }
            .clipped()
        }
    }
}

struct GameViewRepresentable: UIViewRepresentable {
    typealias UIViewType = GameView
    @Binding var userWon: Bool
    
    func makeUIView(context: Context) -> GameView {
        let gameView = GameView()
        gameView.delegate = context.coordinator
        return gameView
    }

    func updateUIView(_ uiView: GameView, context: Context) {
    }

    func makeCoordinator() -> GameViewCoordinator {
        GameViewCoordinator(self)
    }

    class GameViewCoordinator: GameViewDelegate  {
        var parent: GameViewRepresentable
        
        init(_ parent: GameViewRepresentable) {
            self.parent = parent
        }

        func userWon() {
            self.parent.userWon = true
        }
    }
}

Alternate approach that I can think of is:

  1. Create an envelope object/model in gameService that contains the aforementioned properties to be observed and pass that as binding to the UIViewRepresentable.
  2. Have gameService observe this enveloped object, probably using Combine? for changes but I'm not sure how to do this or if it'd work at all. Any help is appreciated on how to do this efficiently.

Solution

  • You can move all the @State vars into your GameService as @Published vars.

    class GameService: ObservableObject {
        @Published var someGameState = 1
        @Published var anotherGameState = 1
    
        func doSomething() {
            print(someGameState)
        }
        func doSomethingElse() {
            print(anotherGameState)
        }
    }
    

    Then you can pass this to GameViewRepresentable through an @ObservedObject, to avoid writing out all the states the game view needs.

    struct ContentView: View {
        @StateObject var game = GameService()
        
        var body: some View {
            GameViewRepresentable(game: game)
        }
    }
    
    struct GameViewRepresentable: UIViewRepresentable {
        @ObservedObject var game: GameService
        
        // ...
    }
    

    Since this is a UIViewRepresentable, I think you would likely be updating game through its properties' setters (rather than say, a Binding produced from the projected value of ObservedObject). In that case, you can just use didSet to detect the changes:

    @Published var someGameState = 1 {
        didSet { self.doSomething() }
    }
    @Published var anotherGameState = 1 {
        didSet { self.doSomethingElse() }
    }
    

    Otherwise, changes can be detected using onReceive:

    // add .dropFirst() if you don't want the initial value to trigger this
    .onReceive(game.$someGameState) { _ in
        game.doSomething()
    }
    .onReceive(game.$anotherGameState) { _ in
        game.doSomethingElse()
    }