swiftuiios17

Observable Toggle Communication Failure


I have an app that will play a confirmation tone if a toggle switch is set. Because the tone may be played for different actions throughout the app, I store the toggle status in the SoundManager class.

I am updating this code from ObservableObject to @Observable (there were no errors in the ObservableObject version). When I build the new code for @Observable, the error

Cannot find '$sound' in scope

appears at the UtilView toggle switch. If I remove the dollar sign I get the error

Cannot convert value 'soundStatus' of type 'Bool' to expected type 'Binding', use wrapper instead. Insert $

In either case, I still have an error.

Several examples on Stackoverflow (pre @Observable) with the same error for the binding suggested using .wrappedValue or .enumerated(). It doesn't seem like I can use a standard binding since I'm checking the toggle status in multiple places throughout the app.

Here is the piece of code that activated the confirmation tone:

@Environment(SoundManager.self) private var sound

      if sound.soundStatus {
           sound.playSound(sound: .confirmTone)
      }
struct UtilView: View {
    
    @Environment(SoundManager.self) private var sound
    
    var body: some View {
        
        VStack (alignment: .leading) {
            
            List {
                // menu here
                
                Toggle(isOn: $sound.soundStatus,     // <– Error here
                       label: { Text("App Sound Confirmations") }
                )
                .toggleStyle(SwitchToggleStyle())
            }
        }
        .listStyle(.plain)
    }
}

This is the soundManager class:

import AVKit
import SwiftUI

@Observable class SoundManager {
// class SoundManager: ObservableObject {
    
    @AppStorage(StorageKeys.sound.rawValue) var soundStatus: Bool = false
        
    var player: AVAudioPlayer?
    var session: AVAudioSession = .sharedInstance()
    
    func playSound(sound: SoundOption) {
        
        guard let url = Bundle.main.url(forResource: sound.rawValue, withExtension: ".wav") else { return }
        
        do {
            try session.setActive(false)
            try session.setCategory(.playback)
            player = try AVAudioPlayer(contentsOf: url)
            player?.setVolume( 0.2, fadeDuration: 0)
            try session.setActive(true)
            player?.play()
        } 
            catch let error {
                
            #if DEBUG
            print("Error playing sound. \(error.localizedDescription)")
            #endif
        }
    }
}


Solution

  • The @Observable class SoundManager cannot observe @AppStorage directly. Based on this post Swift: Ist there any way using @AppStorage with @Observable? you could use the following example code.

    Note also the important use of @Bindable var sound = sound to remove the error Cannot find '$sound' in scope.

    See also Managing model data in your app for how to use Bindable.

    struct ContentView: View {
        @State private var sound = SoundManager() // <--- here
        
        var body: some View {
            UtilView()
                .environment(sound) // <--- here
        }
    }
    
    struct UtilView: View {
        @Environment(SoundManager.self) private var sound
        
        var body: some View {
            @Bindable var sound = sound // <--- here
            VStack (alignment: .leading) {
                Text("soundStatus: \(sound.soundStatus)") // <--- for testing
                List {
                    // menu here
                    Toggle(isOn: $sound.soundStatus,
                           label: { Text("App Sound Confirmations") }
                    )
                    .toggleStyle(SwitchToggleStyle())
                }
            }
            .listStyle(.plain)
        }
    }
    
    
    @Observable class SoundManager {
    
        // --- here
        var soundStatus: Bool {
            get {
                access(keyPath: \.soundStatus)
                return UserDefaults.standard.bool(forKey: "sound")  // StorageKeys.sound.rawValue
            }
            set {
                withMutation(keyPath: \.soundStatus) {
                    UserDefaults.standard.setValue(newValue, forKey: "sound")  // StorageKeys.sound.rawValue
                }
            }
        }
        // ....
    }