Combine + SwiftUI Observing Changes

Overview:

  • I have a class called Player and a class called Song.
  • Player contains a Song
  • A view is showing the song title

Aim:

  • When I change the player.song.title, the view needs to be updated.

Problem:

  • When the song's attributes changes, it wouldn't update the view automatically. Only when a new song is assigned will the changes be reflected.

My Attempts:

  • I have made 2 attempts (code below), both work as expected.

Questions:

  • Is there a better way to do it ? (It seems like a common problem, one would encounter.)
  • Are my attempts reasonable and is attempt 2 better?
  • Or is there something fundamentally flawed with my design? (I was hoping to have the song inside the player because it represented the current song).

Original Code (view wouldn't be updated):

Model

import Foundation
import Combine

class Player : ObservableObject {
    
    @Published var duration = 0
    @Published var song     : Song
    
    init(song: Song) {
        self.song = song
    }
}

class Song : ObservableObject {
    
    @Published var id     : Int
    @Published var title  : String
    @Published var artist : String
    
    init(id: Int,
         title: String,
         artist: String) {

        self.id     = id
        self.title  = title
        self.artist = artist
    }
}

View:

import SwiftUI

struct ContentView: View {
    
    @ObservedObject var player : Player
    
    var body: some View {
        
        VStack {
            Text(String(player.duration))
            Text(player.song.title)
            Text(player.song.artist)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        
        let song = Song(id: 1, title: "title1", artist: "artist1")
        let player = Player(song: song)
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            player.song.title = "title2"
        }
        
        return ContentView(player: player)
    }
}

Attempt1 - Using CombineLatest

Problem: It is not very scalable as the number of properties in a song increases.

class Player : ObservableObject {
    
    @Published var duration = 0
    @Published var song     : Song
    private var songChangeCanceller : AnyCancellable?
    
    init(song: Song) {
        self.song = song
        
        songChangeCanceller = song.$title.combineLatest(song.$artist, song.$id).sink { _, _, _ in
            self.objectWillChange.send()
        }
    }
}

Attemp2: Uses objectWillChange.sink

class Player : ObservableObject {
    
    @Published var duration = 0
    @Published var song     : Song

    private var songChangeCanceller : AnyCancellable?
    private var songAttributesChangeCanceller : AnyCancellable?

    init(song: Song) {
        self.song = song
        
        songChangeCanceller = $song.sink { newSong in
            
            self.songAttributesChangeCanceller = newSong.objectWillChange.sink { _ in
                self.objectWillChange.send()
            }
        }
    }
}

It turns out there are simple solutions thanks to Stackoverflow answer

Option 1:

Make Song a struct that way mutating any of the properties would mutate the instance and that would trigger Player.objectWillChange automatically

Option 2:

Create a separate view for Song and let it accept song as an Observed Object which can be passed from the main view

Code:

import SwiftUI

struct ContentView: View {
    @ObservedObject var player : Player

    var body: some View {
        VStack {
            Text(String(player.duration))
            SongDetailsView(song: player.song)
        }
    }
}

struct SongDetailsView: View {
    @ObservedObject var song : Song
    var body: some View {
        VStack {
            Text(song.title)
            Text(song.artist)
        }
    }
}