SwiftUI Combine: Nested Observed-Objects

Viewed 1027

What is the best approach to have swiftUI still update based on nested observed objects?

The following example shows what I mean with nested observed objects. The balls array of the ball manager is a published property that contains an array of observable objects, each with a published property itself (the color string).

Unfortunately, when tapping one of the balls it dos not update the balls name, nor does it receive an update. So I might have messed up how combine was ment to work in that case?

import SwiftUI

class Ball: Identifiable, ObservableObject {
    let id: UUID
    @Published var color: String
    init(ofColor color: String) {
        self.id = UUID()
       self.color = color
    }
}

class BallManager: ObservableObject {
    @Published var balls: [Ball]
    init() {
        self.balls = []
    }
}

struct Arena: View {
   @StateObject var bm = BallManager()

    var body: some View {
        VStack(spacing: 20) {
            ForEach(bm.balls) { ball in
                Text(ball.color)
                    .onTapGesture {
                        changeBall(ball)
                    }
            }
        }
        .onAppear(perform: createBalls)
        .onReceive(bm.$balls, perform: {
            print("ball update: \($0)")
        })
    }
    
    func createBalls() {
        for i in 1..<4 {
            bm.balls.append(Ball(ofColor: "c\(i)"))
        }
    }
    
    func changeBall(_ ball: Ball) {
        ball.color = "cx"
    }
}
4 Answers

When a Ball in the balls array changes, you can call objectWillChange.send() to update the ObservableObject.

The follow should work for you:

class BallManager: ObservableObject {
    @Published var balls: [Ball] {
        didSet { setCancellables() }
    }
    let ballPublisher = PassthroughSubject<Ball, Never>()
    private var cancellables = [AnyCancellable]()
    
    init() {
        self.balls = []
    }
    
    private func setCancellables() {
        cancellables = balls.map { ball in
            ball.objectWillChange.sink { [weak self] in
                guard let self = self else { return }
                self.objectWillChange.send()
                self.ballPublisher.send(ball)
            }
        }
    }
}

And get changes with:

.onReceive(bm.ballPublisher) { ball in
    print("ball update:", ball.id, ball.color)
}

Note: If the initial value of balls was passed in and not always an empty array, you should also call setCancellables() in the init.

You just create a BallView and Observe it and make changes from there. You have to Observe each ObservableObject directly

struct Arena: View {
    @StateObject var bm = BallManager()
    
    var body: some View {
        VStack(spacing: 20) {
            ForEach(bm.balls) { ball in
                BallView(ball: ball)
            }
        }
        .onAppear(perform: createBalls)
        .onReceive(bm.$balls, perform: {
            print("ball update: \($0)")
        })
    }
    
    func createBalls() {
        for i in 1..<4 {
            bm.balls.append(Ball(ofColor: "c\(i)"))
        }
    }
    
    
}
struct BallView: View {
    @ObservedObject var ball: Ball
    
    var body: some View {
        Text(ball.color)
            .onTapGesture {
                changeBall(ball)
            }
    }
    func changeBall(_ ball: Ball) {
        ball.color = "cx"
    }
}

You do not need nested ObserverObjects for this example:

Model should be a simple struct:

struct Ball: Identifiable {
    let id: UUID
    let color: String
    init(id: UUID = UUID(),
         color: String) {
        self.id = id
        self.color = color
    }
}

ViewModel should handle all the logic, that's why I have moved all the functions that manipulate balls here and made the array of balls private set. Because calling changeBall replaces one struct in the array with another one objectWillChange is fired an the view gets updated and onReceive gets triggered.

class BallManager: ObservableObject {
    @Published private (set) var balls = [Ball]()
    
    func changeBall(_ ball: Ball) {
        guard let index = balls.firstIndex(where: { $0.id == ball.id }) else { return }
        balls[index] = Ball(id: ball.id, color: "cx")
    }
    
    func createBalls() {
        for i in 1..<4 {
            balls.append(Ball(color: "c\(i)"))
        }
    }
}

The View should just communicate user intentions to the ViewModel:

struct Arena: View {

   @StateObject var ballManager = BallManager()

    var body: some View {
        VStack(spacing: 20) {
            ForEach(ballManager.balls) { ball in
                Text(ball.color)
                    .onTapGesture {
                        ballManager.changeBall(ball)
                    }
            }
        }
        .onAppear(perform: ballManager.createBalls)
        .onReceive(ballManager.$balls) {
            print("ball update: \($0)")
        }
    }
}

Model

The Ball is a model and could be a struct or a class (struct is usually recommended, you can find more information here)

ViewModel

Usually you use ObservableObject as a ViewModel or component that manages the data. It is usually common to set a default value for the balls (models), so you can set an empty array. Then you can populate the models with a network request from a database or storage.

The BallManager can be renamed to BallViewModel

The BallViewModel has a function that changes the underlying model based on the index in the ForEach component. The id: \.self basically renders the ball (model) for the current index.

Proposed solution

The following changes will work for achieving what you want to do

struct Ball: Identifiable {
    let id: UUID
    var color: String
    init(ofColor color: String) {
        self.id = UUID()
        self.color = color
    }
}

class BallViewModel: ObservableObject {
    @Published var balls: [Ball] = []

    func changeBallColor(in index: Int, color: String) {
        balls[index] = Ball(ofColor: color)
    }
}

struct Arena: View {
   @StateObject var bm = BallViewModel()

    var body: some View {
        VStack(spacing: 20) {
            ForEach(bm.balls.indices, id: \.self) { index in
                Button(bm.balls[index].color) {
                    bm.changeBallColor(in: index, color: "cx")
                }
            }
        }
        .onAppear(perform: createBalls)
        .onReceive(bm.$balls, perform: {
            print("ball update: \($0)")
        })
    }

    func createBalls() {
        for i in 1..<4 {
            bm.balls.append(Ball(ofColor: "c\(i)"))
        }
    }
}
Related