Categories
Software

Binding to a Dictionary

SwiftUI is mostly awesome, but sometimes in the corners things get a bit messy. In my recent work on the multi-lingual flash cards app, I encountered one such corner.

I am planning to include languages that use writing systems other than the good ol’ Western Alphabet. These include:

  • ਜਪਾਨੀ (Japanese)
  • 旁遮普语 (Punjabi)
  • китайський (Chinese)
  • ウクライナ語 (Ukrainian)

Just for fun, the above list is: Japanese (in Punjabi), Punjabi (in Chinese), Chinese (in Ukrainian), and Ukrainian (in Japanese)

When using the app, I want to give users the choice to see these words in their native script or in the ‘Roman’ alphabet. But I didn’t immediately how to implement this ‘feature.’

I saw two challenges:

  1. How to store the differently scripted versions of the same language
  2. How to map user preferences to the list of languages to use when quizzing users.

Problem #1: Storing the Languages

I ended up creating multiple localizations for each languages. For Japanese I used ‘ja’ and ‘ja-JP.’ The ‘ja’ localization stores the kana (and possibly kanji) version of the flash card content. The ‘ja-JP’ localization stores the romaji version of the content.

To be honest, I don’t LOVE this implementation option, but I really didn’t see anything better. Try not to judge me too harshly!

Problem #2: Mapping the user preferences to the languages list

Thanks to my solution to problem #1, the internal list of available languages will now look something like: en, fr, ja, ja-JP. But we never want to show users this list. Instead we will want to show them either ja or ja-JP. Depending on a user’s preferences, their language list will either be: en, fr, ja OR en, fr, ja-JP.

For each language with local script or western/roman alphabet options, the user will set a bool preference value. The bool preference values will be used to create a set of excluded languages. Here is the logic for the case where Japanese is the only multi-script language.

    var scriptExcludedLanguages: [Language] {
        let ja: Language = useNativeScript ? .ja_roman : .ja_nonRoman
        return [ja]
    }

The app can then remove the scriptExcludedLanguages to generate the list of languages available to the current user with the following code:

    var allLanguages: Set<Language> {
        let result = Language.allLanguages.subtracting(scriptExcludedLanguages)
        return result
    }

In the course of implementing this feature, I uncovered one other piece that ended up being non-obvious.

Problem #3 Binding the UI and UserDefaults for the Dictionary of Bools

First I defined the type:
typealias ScriptPickers = [String: Bool]
And then in the picker view added the following AppStorage property:
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary
This lead to the following cryptic compiler error:
No exact matches in call to initializer 
It turned out the fix for this was to make ScriptPickers conform to RawRepresentable. Here’s what that looks like:

extension ScriptPickers: RawRepresentable where Key == String, Value == Bool {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),  
            let result = try? JSONDecoder().decode(ScriptPickers.self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),   
              let result = String(data: data, encoding: .utf8) 
        else {
            return "{}"  // empty Dictionary respresented as String
        }
        return result
    }

}
// hat tip: actw https://stackoverflow.com/questions/65382590/how-to-use-appstorage-for-a-dictionary-of-strings-string-string 

But there was still the need to map the UI toggles to the ScriptPickers dictionary. Each language toggle needs a binding to the corresponding entry in dictionary. Here is the basic structure for doing that.

struct ContentView: View {
    
    @AppStorage("scriptPickers") var scriptPickers:  ScriptPickers = ScriptPickers.defaultDictionary

    var body: some View {
        VStack {
            ForEach(scriptPickers.keys.sorted(), id: \.self) { key in
                Toggle(key, isOn: binding(for: key))
            }
        }
        .padding()
    }
    private func binding(for key: String) -> Binding<Bool> {
        return .init(
            get: { self.scriptPickers[key, default: false] },
            set: { self.scriptPickers[key] = $0 })
    }

}

I am no noticing that some languages don’t just have roman and non-roman options. Punjabi for example can be expressed in Gurmukhi, Shamukhi, and the roman alphabet. Japanese can be expressed in Kanji (pictograms), Kana (non-Roman alphabets), and Romaji (using the Roman alphabet.)
So in the future, it feels like this code may need to be extended. Also, it feels like the languages should be expressed as an enum, rather than as strings.

Leave a Reply

Your email address will not be published. Required fields are marked *