3

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()
            }
        }
    }
}
user1046037
  • 16,755
  • 12
  • 92
  • 138

2 Answers2

2

Just simplify model as below and update will just work. Tested with Xcode 11.4 / iOS 13.4

class Player : ObservableObject {

    @Published var duration = 0
    @Published var song: Song // published as soon as song.title changed

    init(song: Song) {
        self.song = song
    }
}

struct Song : Identifiable { // value type
    var id     : Int
    var title  : String
    var artist : String
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Totally makes sense, since structs are value types when one of their properties changes, it would be mutated meaning it would result in song being changed. I was wondering if Song was a class how I would go about it – user1046037 Apr 16 '20 at 12:18
  • @user1046037 If Song was a class and you want to observe its changes, you should probably make it an ObservableObject as well and mark the variables with Published. This way, SwiftUI creates a publisher for you and notifies subscribers (e.g. the UI) once the value changes. – Victor Pro Apr 16 '20 at 12:53
  • @VictorPro That has already been done in the original post, please check. I understand why it is not happening just looking for a good solution. – user1046037 Apr 16 '20 at 13:14
1

I was wondering if Song was a class how I would go about it

Ok, if we restrict to classes, then the solution would be in simplified design, as below (yes, I prefer simple solutions). Tested with Xcode 11.4 / iOS 13.4.

// << keep your model "as is" in section "Model", instead modify views

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)
        }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690