poniedziałek, 23 maja 2016

[DSP2016] iOS prosto z poligonu odc.7 (ustawienia aplikacji)

Od ostatniego razu jestem bogatszy o kolejne informacje związane z przetwarzaniem dźwięku w iOS, ale… zostawimy jeszcze na jakiś czas ten temat i dziś pokażemy jak robi się ustawienia mobilnej appki u Apple’a. W aplikacji dla systemu Android jakiś czas temu zrobiłem panel ustawień do komunikacji z Raspberry Pi. Dziś opowiemy jak udało mi się w iOS osiągnąć poniższą zakładkę, dostępną z poziomu systemowej aplikacji Settings / Ustawienia:

IMG_0034

Gdy potrzebujemy szybko wgryźć się w temat, mogę polecić obejrzenie kawałka któregoś ze szkoleń:

Jeśli chcemy bardziej całościowo zapoznać się z tematem warto przeczytać oficjalny przewodnik Preferences and Settings Programming Guide. Jest całkiem przystępny, a jego część Implementing an iOS Settings Bundle nawet bardzo przystępna. Poza tym natrafiłem na kilka innych przydatnych linków w sieci:

Generalnie jednak nie jest to wszystko bardzo złożone. Tworzymy w projekcie Xcode nowy plik typu Settings Bundle, co generuje nam plik Root.plist opisujący strukturę UI zakładki (dostępnej z poziomu systemowej appki Settings). Za budowę graficznego interfejsu odpowiada sam system. Format pliku jak wskazuje rozszerzenie jest taki sam jak w przypadku innych *.plist w XCode, możemy tam przechowywać elementy kilku typów prostych, jak również słowniki, co pozwala tworzyć struktury hierarchiczne.  Poprzez klikanie w edytorze Xcode doprowadziłem zawartość Root.plist do postaci:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>StringsTable</key>
    <string>Root</string>
    <key>PreferenceSpecifiers</key>
    <array>
        <dict>
            <key>Type</key>
            <string>PSToggleSwitchSpecifier</string>
            <key>Title</key>
            <string>UseExternalLighting</string>
            <key>Key</key>
            <string>use_remote_device_preference</string>
            <key>DefaultValue</key>
            <false/>
        </dict>
        <dict>
            <key>AutocorrectionType</key>
            <string>No</string>
            <key>KeyboardType</key>
            <string>URL</string>
            <key>Type</key>
            <string>PSTextFieldSpecifier</string>
            <key>Title</key>
            <string>Host</string>
            <key>Key</key>
            <string>remote_device_host_preference</string>
        </dict>
        <dict>
            <key>KeyboardType</key>
            <string>NumberPad</string>
            <key>DefaultValue</key>
            <string>8181</string>
            <key>Type</key>
            <string>PSTextFieldSpecifier</string>
            <key>Title</key>
            <string>Port</string>
            <key>Key</key>
            <string>remote_device_port_preference</string>
        </dict>
    </array>
</dict>
</plist>

Zapomniałem dodać, że kreator wygenerował też plik językowy Root.strings (w Settings.bundle/en.lproj). Doprowadziłem jego zawartość do postaci:

"UseExternalLighting" = "Use external lighting";
"Host" = "Host";
"Port" = "Port";

Pytanie za dwanaście groszy. Jak dodać tłumaczenia tego pliku dla innego języka np. polskiego?  XCode jakoś nam specjalnie swoim interfejsem nie pomaga, tak jak pomagał przy lokalizacji innych plików. Skorzystałem z rady ze stackoverflow i po prostu utworzyłem za pomocą Xcode folder pl.lproj w Settings.bundle. Następnie już bez Xcode, a przy użyciu zwykłego Findera (odpowiednik Windows Eksploator) wszedłem do pakietu Settings.bundle (nie klikamy w niego, bo wyskakuje jakaś konsola, szukamy odpowiedniej opcji w menu podręcznym) i skopiowałem plik Root.strings z en.lproj do pl.lproj, po czym przetłumaczyłem frazy na polski.

Patrząc na systemowe ustawienia sieci Wi-Fi myślałem, że zrobię podobnie, czyli bedę ukrywał/deaktywował host i port przy wyłączonym switch. Niestety wg. jednego wątku na stackoverflow czy innego nie jest to udokumentowana funkcjonalność. W Android mamy wygodną konfigurację na poziomie XML, w iOS nie mamy takiej otwartości. Nie mamy też otwartości, jeśli chodzi o umieszczanie własnych komponentów (wg. wątku na stackoverflow), jak to jest możliwe w Android. W przypadku liczbowego portu w iOS korzystam z pola tekstowego, tylko rodzaj klawiatury ustawiłem na Number Pad (patrz stackoverflow).

Wróćmy jednak to bardziej zasadniczych rzeczy. Okazuje się, że w iOS nawet domyślne wartości nie działają do końca jakbyśmy tego oczekiwali i musimy napisać całkiem spory kawałek kodu, by zainicjować nimi na dzień dobry ustawienia  aplikacji. Wzorując się na oficjalnym samplu AppPrefs: Storing and Retrieving User Preferences w Objective-C stworzyłem następujący kod Swift w AppDelegate:

   func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
       
        self.populateRegistrationDomain()
       
        // Override point for customization after application launch.
        return true
    }

   func populateRegistrationDomain() {
        let settingsBundleURL = NSBundle.mainBundle().URLForResource("Settings", withExtension: "bundle")
       
        let appDefaults = loadDefaultsFromSettingsPage("Root.plist", inSettingsBundleAtURL: settingsBundleURL!)
       
        let defaults = NSUserDefaults.standardUserDefaults()
        defaults.registerDefaults(appDefaults!)
        defaults.synchronize()
    }    
    
    func loadDefaultsFromSettingsPage(plistName: String, inSettingsBundleAtURL settingsBundleURL: NSURL) -> [String:AnyObject]? {
        let settingsDict = NSDictionary(contentsOfURL: settingsBundleURL.URLByAppendingPathComponent(plistName))
       
        if settingsDict == nil {
            return nil;
        }
       
        let prefSpecifierArray = settingsDict!.valueForKey("PreferenceSpecifiers") as? [[String:AnyObject]]
       
        if prefSpecifierArray == nil {
            return nil;
        }
       
        var keyValuePairs: [String:AnyObject] = [:]
       
        for prefItem in prefSpecifierArray! {
            let prefItemType = prefItem["Type"] as? String
            let prefItemKey = prefItem["Key"] as? String
            let prefItemDefaultValue = prefItem["DefaultValue"] as? String
           
            if prefItemType == "PSChildPaneSpecifier" {
                let prefItemFile = prefItem["File"] as? String
                if let childPageKeyValuePairs = loadDefaultsFromSettingsPage(prefItemFile!, inSettingsBundleAtURL: settingsBundleURL) {
                    keyValuePairs += childPageKeyValuePairs
                }
            }
            else if prefItemKey != nil && prefItemDefaultValue != nil {
                keyValuePairs[prefItemKey!] = prefItemDefaultValue
            }
        }
       
        return keyValuePairs
    }

Pasuje dorzucić nieco komentarza. Generalnie zczytujemy plik XML do słownika, który potem przekazujemy już do metody registerDefaults z API. Wywoływana jest też metoda synchronize (fizyczne wymuszenie zapisu w pliku bez czekania aż system to sam zrobi za jakiś czas), której nie powinniśmy nadużywać. W kodzie można zobaczyć, że słowniki są mergowane za pomocą operatora +=. Jego definicja przedstawia się następująco:

func += <K, V> (inout left: [K:V], right: [K:V]) {
    for (k, v) in right {
        left.updateValue(v, forKey: k)
    }
}

A gdzie w ViewController czytamy wartości ustawień i jak dowiemy się, że zostały zmienione po powrocie aplikacji z tła?  Poniższy kod stanowi na to odpowiedź:

override func viewWillAppear(animated: Bool) {
       super.viewWillAppear(animated)
      
       self.defaultsChanged()
      
       NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ViewController.defaultsChanged),

       name: NSUserDefaultsDidChangeNotification, object: nil)
   }
   
override func viewWillDisappear(animated: Bool) {
       super.viewWillDisappear(animated)
      
       NSNotificationCenter.defaultCenter().removeObserver(self, name: NSUserDefaultsDidChangeNotification, object: nil)
   }

func defaultsChanged() {
        let defaults = NSUserDefaults.standardUserDefaults()
        let useRemoteDevice = defaults.boolForKey("use_remote_device_preference")
        let host = defaults.stringForKey("remote_device_host_preference")
        let port = defaults.integerForKey("remote_device_port_preference")

        //test
        //self.song.text = "\(useRemoteDevice) \(host) \(port)"
    }

Korzystamy tutaj z NSNotificationCenter subskrybując się na komunikat NSUserDefaultsDidChangeNotification. Metoda defaultsChanged na obecny moment została stworzona tylko w celach testowych (w miejscu tytułu aktualnie odtwarzanej piosenki wyświetlałem sobie wartości parametrów z konfiguracji).

Brak komentarzy: