schmidt9
schmidt9

Reputation: 4528

SwiftUI: toggle buttons state inside List

I try to implement a simple player app with a tracks list where each row has a play button, and if I press Play for one track, current playing track (if any) should stop playing (in this case Play button should change icon). Here I got some questions - how do I identify current playing track (where Play button is in playing mode), how can I reset it to initial state so that only one track plays at a time.

In this example multiple buttons can be switched to playing state, not exclusively one as desired

import SwiftUI

class Audio: ObservableObject, Identifiable {
    let id = UUID()
    var title: String
    @Published var isPlaying = false {
        didSet {
            print(isPlaying)
        }
    }
    
    init(title: String) {
        self.title = title
    }
}

class AudiosFetcher: ObservableObject {
    
    @Published var audios = [Audio]()
    
    func fetchAudios() {
        audios = [
            Audio(title: "track 1"),
            Audio(title: "track 2"),
            Audio(title: "track 3")
        ]
    }
    
}

struct ListRow: View {
    
    @ObservedObject var audio: Audio
    
    var body: some View {
        HStack {
            Button(action: {
                audio.isPlaying.toggle()
            }) {
                Image(systemName: audio.isPlaying ? "pause.circle" : "play.circle")
            }
            .buttonStyle(BorderlessButtonStyle())
            .font(.largeTitle)
            
            Text(audio.title)
        }
    }
}

struct ContentView: View {
    
    @ObservedObject var audiosFetcher = AudiosFetcher()
    
    var body: some View {
        List(audiosFetcher.audios, id: \.id) { audio in
            ListRow(audio: audio)
        }.onAppear {
            audiosFetcher.fetchAudios()
        }
    }
}

Update: solution

Thanks to @Yrb's answer, we can do it like this. Maybe AudiosFetcher is not the best place to hold current playing audio, but it works and can be extracted to a separate entity if needed

struct ContentView: View {
    // The intialization of the ObservableObject should be a @StateObject,
    // not an @ObservedObject.
    @StateObject var audiosFetcher = AudiosFetcher()
    
    var body: some View {
        List($audiosFetcher.audios) { audio in
            ListRow(audiosFetcher: audiosFetcher, audio: audio)
        }
        .onAppear {
            audiosFetcher.fetchAudios()
        }
    }
}

struct ListRow: View {
    
    @StateObject var audiosFetcher: AudiosFetcher
    @Binding var audio: Audio
    
    var body: some View {
        HStack {
            Button(action: {
                audiosFetcher.playingAudio = (audiosFetcher.playingAudio == audio ? nil : audio)
            }) {
                Image(systemName: audiosFetcher.playingAudio == audio ? "pause.circle" : "play.circle")
            }
            .buttonStyle(BorderlessButtonStyle())
            .font(.largeTitle)
            
            Text(audio.title)
        }
    }
}

struct Audio: Identifiable, Equatable {
    let id = UUID()
    var title: String
    
    init(title: String) {
        self.title = title
    }
}

class AudiosFetcher: ObservableObject {
    
    @Published var audios = [Audio]()
    @Published var playingAudio: Audio?
    
    func fetchAudios() {
        audios = [
            Audio(title: "track 1"),
            Audio(title: "track 2"),
            Audio(title: "track 3")
        ]
    }
}

Upvotes: 0

Views: 449

Answers (2)

Yrb
Yrb

Reputation: 9675

I reworked a few things. First of all, since you only want one audio, at most, playing I pulled the playing state out of the data model Audio. I also turned it into a struct, as that is the preferred choice for SwiftUI. I moved the playing state into the view model. In order to simplify the code, I actually removed ListRow, though you could inject the view model into ListRow. This gives you your source of truth for which audio is playing. It will be nil if no audio is playing. The controls are keyed off of this value:

struct ContentView: View {
    // The intialization of the ObservableObject should be a @StateObject,
    // not an @ObservedObject.
    @StateObject var audiosFetcher = AudiosFetcher()
    
    var body: some View {
        List($audiosFetcher.audios) { $audio in
            HStack {
                Button(action: {
                    audiosFetcher.isPlaying = (audiosFetcher.isPlaying == audio ? nil : audio)
                }) {
                    Image(systemName: audiosFetcher.isPlaying == audio ? "pause.circle" : "play.circle")
                }
                .buttonStyle(BorderlessButtonStyle())
                .font(.largeTitle)
                
                Text(audio.title)
            }
        }
        .onAppear {
            audiosFetcher.fetchAudios()
        }
    }
}

struct Audio: Identifiable, Equatable {
    let id = UUID()
    var title: String
    
    init(title: String) {
        self.title = title
    }
}

class AudiosFetcher: ObservableObject {
    
    @Published var audios = [Audio]()
    @Published var isPlaying: Audio?
    
    func fetchAudios() {
        audios = [
            Audio(title: "track 1"),
            Audio(title: "track 2"),
            Audio(title: "track 3")
        ]
    }
}

Upvotes: 1

you could try something like this to reset all audios initial state, and just play one track at a time:

class Audio: ObservableObject, Identifiable {
    let id = UUID()
    var title: String
    @Published var isPlaying = false {
        didSet {
            print(title + " isPlaying: \(isPlaying)")
        }
    }
    
    init(title: String) {
        self.title = title
    }
}

class AudiosFetcher: ObservableObject {
    @Published var audios = [Audio]()
    
    func fetchAudios() {
        audios = [
            Audio(title: "track 1"),
            Audio(title: "track 2"),
            Audio(title: "track 3")
        ]
    }
    
    // -- here
    func toggleAudio(_ audio: Audio) {
       let inState = audio.isPlaying
       audios.forEach{ $0.isPlaying = false} // <-- turn all off
       audio.isPlaying = !inState  // <-- only toggle this one
    }
}

struct ContentView: View {
    @StateObject var audiosFetcher = AudiosFetcher() // <-- here
    
    var body: some View {
        List(audiosFetcher.audios, id: \.id) { audio in
            ListRow(audio: audio)
        }.onAppear {
            audiosFetcher.fetchAudios()
        }
        .environmentObject(audiosFetcher) // <-- here
    }
}

struct ListRow: View {
    @EnvironmentObject var audiosFetcher: AudiosFetcher // <-- here
    @ObservedObject var audio: Audio
    
    var body: some View {
        HStack {
            Button(action: {
                audiosFetcher.toggleAudio(audio) // <-- here
            }) {
                Image(systemName: audio.isPlaying ? "pause.circle" : "play.circle")
            }
            .buttonStyle(BorderlessButtonStyle())
            .font(.largeTitle)
            Text(audio.title)
        }
    }
}
 

Upvotes: 1

Related Questions