6

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"
    }
}
Leo
  • 1,508
  • 13
  • 27

4 Answers4

4

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.

George
  • 25,988
  • 10
  • 79
  • 133
  • thx for your answer, but this left some issues / open questions: .onReceive(bm.$balls) never gets triggered if a ball within balls changes. I also tried .onReceive(bm.objectWillChange) which of course get's triggered but does hold the old values at that point. Do you know how to tweak it further? – Leo Mar 31 '21 at 10:14
  • @lenny Fixed! You can now use the `ballPublisher` to receive the new `Ball`. – George Mar 31 '21 at 14:52
  • thx for the edit, now receive works but the ball instance in onReceive still has the old value instead of the new value. So if I edit it n times, the ball instance made available by onReceive has the values from the n-1 th edit. WHich kinda makes sense, because the publisher publishes before the object actually changes, but there must be an easy way to get the new values? If you know how to accomplish that last detail I'm happy to accept your answer. Anywasy thx for all the help so far and for using a combine way as I asked for ;) – Leo Apr 01 '21 at 18:06
  • @lenny I'm relatively new, so thanks for bearing with me! I'm a bit confused by what you mean by "the ball instance in onReceive still has the old value instead of the new value". In my testing, I can't reproduce this (`.onReceive` is called instantly, and when printing the `color` for example it reflects the new value). Sorry I'm quite new to Combine, could definitely be something I have caused. For me, inside where the `.send(ball)` is the `color` is the old value, just as you described. However, in the `onReceive` it is the newest value. – George Apr 01 '21 at 20:30
  • 1
    you're absolutely right, the issue was caused on my side. I had the change done on a different thread (not the main thread) and thus the discribed delayed behaviour occured. So all I had to change `ball.objectWillChange.sink` to `ball.objectWillChange.receive(on: DispatchQueue.main).sink` and now it works like a charm :) Why do we need the `[weak self] in \n guard let self = self else { return }` part btw? – Leo Apr 01 '21 at 21:04
  • 1
    @lenny Glad it works! Also, that `[weak self]` prevents any memory leaks in the closure, as `self` will be retained otherwise. More info [here](https://stackoverflow.com/a/59972188/9607863). – George Apr 01 '21 at 21:33
  • For the use case I'm facing, this solution is perfect, thanks @George – Hamlet Minjares Feb 01 '22 at 23:39
  • need to `import Combine` for cancellables – soundflix Mar 12 '23 at 13:20
3

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"
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • 1
    thx, that's a very interesting approach to my issue. I was looking for an combine-like way here because I like to have it in one view, but it's quite nice to see how easy things can get in swiftUI just by spliting views down to their smalles. – Leo Apr 01 '21 at 21:06
1

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)")
        }
    }
}
LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
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)"))
        }
    }
}
  • thx for the answer, my example above was indeed just ment to be an example, so unfortunately in my particular case "Ball" is actually a CBPeripheral and thus has to be a class. But I do appreciate your effort! – Leo Mar 31 '21 at 10:16