4

I have an issue where the ViewModel would re-initialize when view is updated.

I have 2 views, SongListView and PlayerView which share an object of Player. When player's Playing state is changed (isPlaying == true), the viewModel in SongListView resets and becomes empty array. Due to which, the list on my view becomes empty.

enter image description here

SongListView:

struct SongListView: View {

    @ObservedObject var model: SongListViewModel = SongListViewModel() // This resets when player.isPlaying is set to true

    @ObservedObject var player: Player

    var body: some View {

        List(model.songs, id: \.id) { song in
            Button(action: {
                self.player.play(link: song.link)
            }) {
                TitleRowView(title: song)
            }
        }
        .onAppear {
            self.model.get()
        }
        .navigationBarTitle(Text("Songs"), displayMode: .inline)
    }
}

SongListViewModel:

class SongListViewModel: ObservableObject {

    @Published var songs: [Song] = [Song(id: 2, name: "ish", link: "ishm")] // When I tap the row, the songs var is re-initialized

    func get() {

        guard let url = URL(string: "apiPath") else { return }

        URLSession(configuration: URLSessionConfiguration.default).dataTask(with: url) {data, response, error 
               // Some more code
               self.songs = data
        }.resume()
    }
}

PlayerView:

struct PlayerView: View {

    @ObservedObject var player: Player

    var body: some View {
        HStack {
            Button(action: {
                if self.player.isPlaying {
                    self.player.pause()
                } else {
                    self.player.play()
                }
            }) {
                // This change causes the viewModel to reset to empty array
                if self.player.isPlaying { 
                    Image(systemName: "pause.fill")
                        .resizable()
                } else {
                    Image(systemName: "play.fill")
                        .resizable()
                }
            }
        }
    }
}

Player:

class Player : ObservableObject
{

    @Published var isPlaying: Bool = false

    private var player: AVPlayer?

    // This method is called when the user taps on a row in List
    func play(link: String) {
        guard let url = URL(string: link) else { return }
        let playerItem = AVPlayerItem(url: url)
        player = AVPlayer(playerItem: playerItem)
        player?.play()
        isPlaying = true // If I comment this line, the songs list in viewModel does not changes
    }    
}

Thanks in Advance!

UPDATE: Still doesn't work

struct SongListView: View {

    @ObservedObject var model: SongListViewModel

    var body: some View {
        // View 
    }
}

struct CategoryListView: View {
    var categoryData : [Category]
    @ObservedObject var player: Player

    var body: some View {
        List(categoryData, id: \.id) { category in
            if category.id == 3 {
                NavigationLink(destination: SongListView(model: SongListViewModel(), player: self.player)) {
                    TitleRowView(title: category)
                }
            }

        }
    }
}
Ishmeet
  • 707
  • 6
  • 18
  • 1
    Did you try to use an EnvironmentObject? This might fix your problem because I might think your whole View is reinitializing itself somehow.. – Tobias Hesselink Dec 22 '19 at 20:54
  • @TobiasHesselink I tried that, doesn't work – Ishmeet Dec 22 '19 at 21:53
  • @TobiasHesselink using `EnvironmentObject` works perfectly. I was doing another mistake and initializing the SongListViewModel within the struct as mentioned by Paulw11 below. Can you add this as in answer so that I can mark it as answer below. Thanks – Ishmeet Dec 22 '19 at 22:57
  • On contrary, now I'm seeing this issue with every 2nd or 3rd tap. `SongListViewModel.init()` everytime the view is updated – Ishmeet Dec 22 '19 at 23:08
  • Similar issue here keyboard appearance resets all the properties to initial values, using StateObject works but app supports iOS 13 so will have to try EnvironmentObject – Lukasz D Jan 04 '22 at 21:04

4 Answers4

7

Using @StateObject instead of @ObservedObject worked for me.

A property marked as @StateObject will keep its initially assigned ObservedObject instance as long as the view is needed, even when the struct gets recreated by SwiftUI.

This allows you to maintain the state of your ObservedObject data.

6

SwiftUI views are structs, and therefore, immutable. When you update the state and cause the view to redraw, it actually creates a new instance of the view.

In your SongListView you have

@ObservedObject var model: SongListViewModel = SongListViewModel()

This means that each time your SongListView is re-drawn (which includes any time that player.isPlaying is changed), you are initialising model with a new instance of SongListViewModel.

You should remove the default value and supply the model via a parameter to the initialiser of SongListView -

@ObservedObject var model: SongListViewModel
Paulw11
  • 108,386
  • 14
  • 159
  • 186
4

Use @StateObject instead! ObservedObject seems to be recreating the entire object every time.

danylo.net
  • 253
  • 3
  • 7
  • Yes on complex views even keyboard causes ObservedObject to resets it's properties, it's insane. I end up using EnvironmentObject as my app is iOS13. Bu it's a waste as I don't need to share data between views. – Lukasz D Jan 04 '22 at 22:04
0

So finally I was able to fix this issue by removing @ObservedObject property wrapper on player: Player. I am not sure why this works. Seems like having more than one ObservedObject in a view causes this problem. Now my code looks like this:

struct SongListView: View {

    @ObservedObject var model: SongListViewModel

    @State var player: Player

    var body: some View {

        List(model.songs, id: \.id) { song in
            Button(action: {
                self.player.play(link: song.link)
            }) {
                TitleRowView(title: song)
            }
        }
        .onAppear {
            self.model.get()
        }
        .navigationBarTitle(Text("Songs"), displayMode: .inline)
    }
}

struct CategoryListView: View {
    var categoryData : [Category]
    @ObservedObject var player: Player
    let viewModel = SongListViewModel()

    var body: some View {
        List(categoryData, id: \.id) { category in

            if category.id == 3 {
                NavigationLink(destination: SongListView(player: self.player).environmentObject(self.viewModel)) {
                    TitleRowView(title: category)
                }
            }

        }
    }
}
Ishmeet
  • 707
  • 6
  • 18
  • This should not be the problem in my opinion. You can have more than 1 ObservableObjects in one view. – Tobias Hesselink Dec 26 '19 at 09:29
  • I believe that too. Not sure why this worked but any other solution didn't. However, I did realize I didn't need my player object to be observable in this view. – Ishmeet Dec 26 '19 at 18:02
  • 3
    This worked because @State is held on a separate memory address managed by SwiftUI and associated to your View but not to its drawing cycle. When you view redraws the data stay the same and it's not affected by your view redrawing and resetting its values – Pacu Apr 21 '20 at 20:08
  • I am with @Ishmeet on this one. ObservedObject, regardless of how it is initialized, causes the entire ViewStack to refresh on anything that causes objectWillChange. – Paul D. Oct 03 '20 at 21:44