Categories
Software

How to listen for changes in an @AppStorage value

For my flash cards app, I’ve added a few user settings that I’ve discussed in other posts. For example, users can choose whether they want to use the same ask language for all cards (e.g. Always first show words in Japanese). They can also choose whether they want to use かな or Romaji for Japanese. They can also choose a subset of the cards they want to use (eg numbers, months/daysOfTheWeek etc.)

Any time one of these settings gets changed by the user, the app’s LanguageManager singleton needs to update the list of available words that match the settings. (for example, if a given word does not have a French translation and the user specifies fr as their only answer language, that card can’t be shown to the user.)

Strictly speaking, available cards could be recalculated on demand. (ie each time a user switches to a new card) But that would be suboptimal, given the available cards only changes when one of the settings changes. But in the spirit of saving cycles, I feel like finding a way to calculate available cards only when the value changes is a worthy cause.

For a starting point, I have the following code where the UI is setting the UserDefault value.

struct CardPickerView: View {
    
    @AppStorage("cardPile") var cardPile: CardPile = .allRandom
    var body: some View {
        VStack {
            Picker("Card Pile", selection: cardPile) {
                ForEach(CardPile.allCases,
                        id: \.self) { 
                    Text($0.title)
                        .tag($0.rawValue)
                }
            }
        }
    }
}

And the following code where LanguageManager updates availableCards

class LanguagesManager {
    static let shared = LanguagesManager()

    // The UserDefaults values that will impact which cards/words can be used
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary
    @AppStorage("languageChoice") var languageChoice: LanguageChoice = .all
    @AppStorage("cardPile") var cardPile: CardPile = .allRandom

    // All the words
    let words: Words

    // The words that match the criteria
    var availableWords: Words

    func updateAvailableWords() {
        var result = words.filtered(by: cardPile.wordList)
        let askLanguages = languageChoice.askLanguages
        let answerLanguages = languageChoice.answerLanguages
        result = result.matching(askIdentifiers: askLanguages, answerIdentifiers: answerLanguages)
        availableWords = result
    }
}

This code mostly works as expected, however when a user updates scriptPickers or languageChoice or cardPile, availableWords doesn’t get updated. <frownyFace!!> My first thought was to use didSet (like this)

    @AppStorage("cardPile") var cardPile: CardPile = .allRandom {
        didSet { updateAvailableWords() }
    }

Sadly didSet on @AppStorage only works in very specific situations for me. Based on what I saw, it works if:

  1. your code is in a View
  2. you explicitly update the value (eg. self.cardPile = .all)

Your mileage may vary. Bottom line: didSet in LanguagesManager was not getting called for me.

It would have been possible to add calls to updateAvailableWords() in the UI code any time one of the related settings changed. But relying on the UI code to keep LanguagesManager fresh. Just. Felt. Wrong.

I also tried to use combine to subscribe to the @AppStorage values, but was not able to get that working. <anotherFrownyFace>

The solution I ended up using was to create a UserDefaults extension that would create a Binding<> for a specific UserDefaults key value. Anytime this value changes, it will fire a notfication.

extension UserDefaults {
    func cardPileBinding(for defaultsKey: String) -> Binding<CardPile> {
        return Binding {
            let rawValue = self.object(forKey: defaultsKey) as? Int ?? 0
            return CardPile(rawValue: rawValue) ?? .allRandom
        } set: { newValue in
            self.setValue(newValue.rawValue, forKey: defaultsKey)
            NotificationCenter.default.post(name: .userDefaultsChanged, object: defaultsKey)
        }
    }
}

extension Notification.Name {
    static let userDefaultsChanged = Notification.Name(rawValue: "user.defaults.changed"
}

Next, I used these bindings in the UI code where I’d previously been using @AppStorage.

struct CardPickerView: View {
    let cardPileBinding: Binding<CardPile>
    init(userDefaults: UserDefaults = .standard) {
        self.cardPileBinding = userDefaults.cardPileBinding(for: "cardPile")
    }
    var body: some View {
        VStack {
            Picker("Card Pile", selection: cardPileBinding) {
                ForEach(CardPile.allCases,
                        id: \.self) { 
                    Text($0.title)
                        .tag($0.rawValue)
                }
            }
        }
    }
}

Then I updated LanguagesManager to listen for this notification.

class LanguagesManager {
    private var subscriptions = Set<AnyCancellable>()
    init() {
        // a bunch of unrelated init stuff....

        NotificationCenter.default.publisher(for: .userDefaultsChanged)
            .sink(receiveValue: { notification in
                self.updateAvailableWords()
            })
            .store(in: &subscriptions)
    }

And this meets my needs, insofar as the UI code doesn’t need to explicitly fiddle with LanguagesManager state any time a user changes their prefs. I don’t like that the UI code needs to use this special binding to do the updating. I also don’t like that the UserDefaults extension needs to define multiple functions to return the different types of Bindings. If you all know of a way to get around this (generics?) I’m all ears!

I also don’t like that the getters and setters in the Binding values need to translate to and from rawValue. @AppSupport magically handled the translations between rawValue and encodedValue.

And one last problem with this approach, for at least one of my settings UI inplementations, calling the setter in the binding didn’t trigger an update to the UI. (it’s on my todo list to investigate this.) I got around this problem by adding a ‘dummy’ @AppStorage var in the View. Adding this dummy value seems to force the view to redraw when the UserDefault value changes.

    @AppStorage("cardPile") var cardPileDummy: CardPile = .allRandom