Monsieur Météo, suite : interface graphique

Nous allons utiliser notre précédent code et l'inclure dans une mini application pour OS X.

Une simple petite fenêtre que l'on peut garder dans un coin de l'écran et qui indique le temps qu'il fait à l'endroit où se trouve votre machine.

En ajoutant la géolocalisation et une icone à notre ancien projet on obtient tout de suite quelque chose d'intéressant tout en restant simple à développer.

screenshot

Préparation

Nous allons coder avec Swift 3 et pour ce faire il nous faut Xcode 8 (en beta au moment de l'article, à télécharger sur le site d'Apple).

Dans Xcode 8, créez un nouveau projet Application Cocoa sans Storyboard ni rien d'autre (laissez toutes les cases décochées).

Par défaut, Apple nous force à utiliser HTTPS pour se connecter à un serveur. Malheureusement la version gratuite de l'API Open Weather Map est en HTTP.

Il nous faut donc configurer ce projet comme ceci pour contourner la limitation: allez dans la rubrique “info” de votre projet et ajoutez une entrée “App Transport Security Settings”, contenant une entrée “Allow Arbitrary Loads” avec une valeur à “YES”.

Création de l'interface

Dans le projet, cliquez sur MainMenu.xib puis sur Window pour afficher le modèle de fenêtre.

Redimensionnez la fenêtre à votre convenance, pour mon exemple j'ai fait un simple rectangle de 460x60.

Dans les attributs de la fenêtre, décochez Title Bar et cochez Full size content view, ça nous permet de désactiver la barre de titre et les icones tricolores.

Dans la collection d'objets d'Xcode, prenez un ViewController et glissez-le dans les objets du projet.

IB

Faites un nouveau fichier “MainViewController.swift” et créez une classe MainViewController qui hérite de NSViewController.

Donnez à l'objet MainViewController que l'on a créé cette classe perso dans le panneau.

Faites un nouveau fichier “MainView.swift” et créez une classe MainView.swift qui hérite de NSView.

Sélectionnez la vue de la fenêtre et donnez notre classe à cette vue.

La fenêtre est prête, ajoutons de quoi afficher quelque chose.

Ajoutez une ImageView et deux NSTextField (vous pourrez bien sûr tout changer ensuite mais pour ce tuto on va utiliser ça).

Si vous êtes perdus, lisez mon article Swift pour OS X.

Passez en mode “Assistant Editor” (fenêtres divisées) et reliez la vue de la fenêtre à un IBOutlet dans le contrôleur :

// dans MainViewController.swift
@IBOutlet weak var mainView: MainView!

Ca nous permettra de travailler sur la vue à partir du contrôleur.

Et reliez l'imageView et les labels à des outlets dans la classe de la vue :

// dans MainView.swift
@IBOutlet weak var weather: NSTextField!
@IBOutlet weak var temp: NSTextField!
@IBOutlet weak var iconView: NSImageView!

Pour finir, dans “AppDelegate.swift” vous ajoutez ceci :

func applicationWillFinishLaunching(_ notification: Notification) {
    window.titlebarAppearsTransparent = true
    window.isMovableByWindowBackground = true
    window.setFrameUsingName("WeatherMan-Mac")
}

func applicationDidFinishLaunching(_ aNotification: Notification) {
    window.orderFront(nil)
}

func applicationWillTerminate(_ aNotification: Notification) {
    window.saveFrame(usingName: "WeatherMan-Mac")
}

Bon, le templating est terminé, on va pouvoir coder !

Importer notre ancien code

Allez d'abord sur le site de SwiftyJSON et passez sur le branche “swift3” puis téléchargez le zip. Dans le zip, prenez le fichier “SwiftyJSON.swift” et copiez-le dans le projet. N'oubliez pas de cocher “Copier” et de sélectionner la bonne target si ce n'est pas déjà le cas.

De notre précédent tutorial, sélectionnez également la branche “swift3” sur GitHub. Nous allons ignorer les parties spécifiques à la ligne de commande et récupérer le reste.

Copiez tel quels, de l'ancien projet vers celui-ci, les fichiers “CurrentWeather.swift”, “NetworkResult.swift” et “WeatherResult.swift”.

Copiez également “WeatherDescriptionStyle.swift” et ajoutez une option “gui” dans l'enum :

public enum WeatherDescriptionStyle {
    case mini, string, detailed, gui
}

Copiez également le fichier “Extensions.swift”. Nous y avons un array contenant les abréviations pour la direction des vents. Développez-donc les noms en entier comme ceci, ce sera plus agréable à lire une fois affiché :

let compass = ["Nord","Nord Nord-Est","Nord-Est","Est-Nord-Est","Est","Est-Sud-Est","Sud-Est","Sud-Sud-Est","Sud","Sud-Sud-Ouest","Sud-Ouest","Ouest-Sud-Ouest","Ouest","Ouest-Nord-Ouest","Nord-Ouest","Nord-Nord-Ouest"]

Bien sûr c'est en français, comme tout ce qui est affiché dans l'app. (On ne va pas parler d'internationalisation aujourd'hui, c'est un sujet trop vaste.)

Nous importerons un peu plus tard les fichiers “Meteo” et “WeatherDescriptionStyle” car nous y apporterons des modifications plus significatives.

Compilez et exécutez l'app pour vérifier que rien ne bug. Une fenêtre vide devrait s'afficher.

Concept

Au lancement de notre application, Cocoa va instancier notre MainViewController et, lorsque la fenêtre sera prête, va également instancier notre vue MainView.

Le viewController sera responsable du déroulement des opérations qui concernent la vue.

Pour commencer, nous voulons que l'app détecte sa localisation géographique dès son lancement.

Ensuite l'app va télécharger les données de l'API puis les afficher.

On ajoutera juste un timer pour rafraîchir tout ça par exemple toutes les cinq minutes.

On pourrait faire ça dans AppDelegate ou dans un contrôleur spécialisé, mais pour rester simple nous allons le faire dans le viewController, puisque notre app ne fait en réalité que ça: détecter, télécharger puis afficher.

Géolocalisation

Importez CoreLocation et conformez notre viewController au delegate.

Quelque chose comme ça :

import Cocoa
import CoreLocation

class MainViewController: NSViewController, CLLocationManagerDelegate {

    @IBOutlet weak var mainView: MainView!

    let locManager = CLLocationManager()

    override func awakeFromNib() {
        locManager.delegate = self
        locManager.desiredAccuracy = kCLLocationAccuracyBest
    }

}

Interlude : pour nous aider à comprendre le déroulement du control flow, nous allons afficher quelques logs dans la console d'Xcode. Pour nous faciliter la tâche, créez un fichier “Globals.swift” et ajoutez-y cette fonction :

import Foundation

public func log(_ obj: AnyObject) {
    if let error = obj as? NSError {
        NSLog("%@", error.debugDescription)
    } else if let text = obj as? String {
        NSLog("%@", text)
    } else {
        NSLog("%@", "\(obj)")
    }
}

Revenons dans notre viewController.

Comme nous nous conformons au protocole CLLocationManagerDelegate nous disposons de nombreuses méthodes pour nous aider à nous géolocaliser.

Nous devons tout d'abord vérifier que l'utilisateur soit d'accord pour que notre app le géolocalise.

Pour vérifier ce status, on utilise la méthode de delegate didChangeAuthorization. C'est une des nombreuses fonctions qui commencent par “locationManager” :

func locationManager(_ manager: CLLocationManager,
        didChangeAuthorization status: CLAuthorizationStatus)

Cette méthode nous donne le status actuel de notre app. Faisons un switch qui va logger ce status :

func locationManager(_ manager: CLLocationManager,
                     didChangeAuthorization status: CLAuthorizationStatus) {
    switch status {
    case .authorizedAlways:
        log("authorized")
    case .restricted, .denied:
        log("denied")
    case .notDetermined:
        log("state unknown")
    default:
        log("oops, error")
    }
}

Mais comment demander l'autorisation à l'utilisateur ? C'est automatique dès qu'on active la géolocalisation.

On ajoute donc une méthode juste pour ça :

func startMonitoring() {
    locManager.startUpdatingLocation()
}

Que l'on va lancer à partir de awakeFromNib :

override func awakeFromNib() {
    locManager.delegate = self
    locManager.desiredAccuracy = kCLLocationAccuracyBest
    startMonitoring()
}

Profitons-en pour utiliser aussi la méthode déléguée didUpdateTo newLocation :

func locationManager(_ manager: CLLocationManager,
                     didUpdateTo newLocation: CLLocation,
                     from oldLocation: CLLocation) {
    log("location updated")
}

Lancez l'app, vous devriez obtenir immédiatement la boîte de dialogue vous demandant d'autoriser l'application. Cliquez sur “Autoriser” et vous verrez le changement dans les logs. Stoppez l'application.

Mac OS X se souvient de l'état de l'autorisation d'une application, donc une fois autorisée, l'utilisateur n'a plus rien à faire, au prochain lancement ça marchera directement.

Mais nous, ici, nous développons, donc à chaque modification du code, le système considère (à raison) que c'est une application différente, et quand nous faisons un nouveau build et lançons notre nouvelle version à partir d'Xcode, il faut ré-autoriser à chaque fois.

Géolocalisation inversée

On pourrait très bien demander à l'API de nous donner ses infos à partir des coordonnées (longitude et latitude) fournies par CLLocationManager.

Mais d'une part, l'API n'est pas toujours à l'aise pour trouver le nom des villes à partir des coordonnées, et d'autre part, nous avons déjà du code existant qui sait appeller l'API à partir d'un nom de ville et d'un pays mais pas à partir des coordonnées.

Nous allons donc en profiter ici pour utiliser la fonction de “reverseGeocodeLocation” fournie par CLGeocoder : à partir des coordonnées, elle fournit de manière précise le nom de la ville et du pays (entre autres), et nous allons passer ces infos-là à notre API.

Attention : une fois lancé, le monitoring de CLLocationManager s'ajuste en permanence, on pourrait donc se retrouver à faire de très nombreuses requêtes successives à CLGeocoder et à l'API si l'on oublie de gérer ce fait.

Ajoutez donc ceci à la racine du viewController :

let geoCoder = CLGeocoder()

et ajoutez ceci dans didUpdateTo newLocation :

geoCoder.reverseGeocodeLocation(newLocation) { (placemarks, error) in
    if let places = placemarks, place = places.last where error == nil {
        if let pl = place.locality, pc = place.country {
            self.locManager.stopUpdatingLocation()
            log("found \(pl) and \(pc)")
        }
    }
}

Donc : le monitoring commence dès le lancement de l'app et s'arrête dès que l'on a trouvé le nom de la ville et du pays.

Il nous suffit d'ajouter un timer pour faire la même chose toutes les cinq minutes : démarre le monitoring, trouve la ville et le pays puis stop.

override func awakeFromNib() {
    locManager.delegate = self
    locManager.desiredAccuracy = kCLLocationAccuracyBest
    startMonitoring()
    _ = Timer.scheduledTimer(timeInterval: 60 * 5, // 5 minutes
                             target: self,
                             selector: #selector(startMonitoring),
                             userInfo: nil,
                             repeats: true)
}

Lancez l'app et vérifiez dans les logs que tout fonctionne.

Logging

Pour le moment nous avons juste affiché quelques messages très courts dans la console. Pour éviter d'avoir à taper le texte parmi le code et pour éviter les copier-coller, je préconise de placer tous les messages dans une struct ou un enum.

Pour nous, par exemple, vous pouvez utiliser ceci, dans un nouveau fichier “AppStatus.swift” :

enum AppStatus: String {
    case locationAuthorized = "LOCATION SERVICES: AUTHORIZED"
    case locationStatusUnknown = "LOCATION SERVICES: STATUS UNKNOWN"
    case locationUnknownError = "LOCATION SERVICES: DAFUQ?"
    case locationUpdate = "LOCATION SERVICES: NEW LOCATION UPDATE"
    case apiFetching = "FETCHING API RESPONSE"
    case apiReceived = "RECEIVED API RESPONSE"
    case apiInvalid = "INVALID API RESPONSE"
    case apiError = "UNKNOWN API ERROR"
}

Voici ce que ça donne dans notre code :

import Cocoa
import CoreLocation

class MainViewController: NSViewController, CLLocationManagerDelegate {

    @IBOutlet weak var mainView: MainView!

    let locManager = CLLocationManager()
    let geoCoder = CLGeocoder()

    override func awakeFromNib() {
        locManager.delegate = self
        locManager.desiredAccuracy = kCLLocationAccuracyBest
        startMonitoring()
        _ = Timer.scheduledTimer(timeInterval: 60 * 5, // every 5 minutes
                                 target: self,
                                 selector: #selector(startMonitoring),
                                 userInfo: nil,
                                 repeats: true)
    }

    func startMonitoring() {
        locManager.startUpdatingLocation()
    }

    func locationManager(_ manager: CLLocationManager,
                         didChangeAuthorization status: CLAuthorizationStatus) {
        switch status {
        case .authorizedAlways:
            log(AppStatus.locationAuthorized.rawValue)
        case .restricted, .denied:
            log("oops") // on va changer cette partie plus tard
        case .notDetermined:
            log(AppStatus.locationStatusUnknown.rawValue)
        default:
            log(AppStatus.locationUnknownError.rawValue)
        }
    }

    func locationManager(_ manager: CLLocationManager,
                         didUpdateTo newLocation: CLLocation,
                         from oldLocation: CLLocation) {
        log(AppStatus.locationUpdate.rawValue)
        geoCoder.reverseGeocodeLocation(newLocation) { (placemarks, error) in
            if let places = placemarks, place = places.last where error == nil {
                if let pl = place.locality, pc = place.country {
                    self.locManager.stopUpdatingLocation()
                    log("found \(pl) and \(pc)")
                }
            }
        }
    }
}

Networking

Tout d'abord, renommons notre classe Meteo en Weatherman comme ça aurait dû être le cas dans le précédent tuto. ;)

Importez le fichier “Meteo.swift” du précédent tutoriel, puis renommez le fichier et la classe.

Cette classe contient déjà tout ce qu'il faut, nous allons juste ajouter une méthode qui servira à télécharger l'icone du temps qu'il fait (soleil, nuage, etc) :

// dans WeatherMan.swift
public func getIcon(url: URL, completion:(icon: NSImage)->()) {
    URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data where error == nil {
            if let image = NSImage(data: data) {
                completion(icon: image)
            }
        }
    }.resume()
}

Ajoutons maintenant une instance de WeatherMan dans notre viewController et créons une nouvelle méthode getWeather que nous appellerons à partir de reverseGeocodeLocation :

import Cocoa
import CoreLocation

class MainViewController: NSViewController, CLLocationManagerDelegate {

    @IBOutlet weak var mainView: MainView!

    let weatherMan = WeatherMan(appID: "d21991d7851f849bfe8cc24d12c795d0")
    let locManager = CLLocationManager()
    let geoCoder = CLGeocoder()

// {...}

    func locationManager(_ manager: CLLocationManager,
                         didUpdateTo newLocation: CLLocation,
                         from oldLocation: CLLocation) {
        log(AppStatus.locationUpdate.rawValue)
        geoCoder.reverseGeocodeLocation(newLocation) { (placemarks, error) in
            if let places = placemarks, place = places.last where error == nil {
                if let pl = place.locality, pc = place.country {
                    self.locManager.stopUpdatingLocation()
                    self.getWeather(city: pl, country: pc)
                }
            }
        }
    }

    private func getWeather(city: String, country: String) {
        log(AppStatus.apiFetching.rawValue)
        weatherMan.currentWeather(city: city, country: country) { (result) in
            if result.success {
                log(AppStatus.apiReceived.rawValue)
                log(result.weather)
            }
        }
    }

}

On a presque fini !

GUI

Il nous faut maintenant afficher ces informations dans notre fenêtre.

Copiez le fichier “WeatherDescriptor.swift” à partir du précédent tuto et changez le contenu de sa méthode makeDescription comme ceci pour que le texte soit correctement formaté :

private func makeDescription(_ weather: CurrentWeather, 
                            style: WeatherDescriptionStyle) -> String {
    let loc = "\(weather.city) (\(weather.country))"
    let ds = dateString(weather)
    let base = "\(loc), \(ds)."
    let temp = "Temp: \(weather.celsius) ºC."
    let normal = "\(base) \(temp)"
    let mood = "Ciel: \(weather.subCategory)."
    switch style {
    case .gui, .detailed:
        if let dir = weather.windDirection {
            return "\(base)\n\(mood) Vent: \(dir.degreesToCompass()) à \(weather.windSpeed) km/h."
        } else {
            return "\(base)\n\(mood) Vent: Négligeable."
        }
    case .string:
        return normal
    case .mini:
        return temp
    }
}

Et dans le viewController, créons une méthode populateView que l'on va appeller à partir de getWeather :

private func getWeather(city: String, country: String) {
    log(AppStatus.apiFetching.rawValue)
    weatherMan.currentWeather(city: city, country: country) { (result) in
        if result.success {
            log(AppStatus.apiReceived.rawValue)
            self.populateView(with: result)
        }
    }
}

private func populateView(with result: WeatherResult) {
    if let w = result.weather {
        DispatchQueue.main.sync(execute: {
            mainView.weather.stringValue = descriptor.describe(weather: w)
            mainView.temp.stringValue = "\(w.celsius) °C"
        })
        weatherMan.getIcon(url: w.iconURL, completion: { (icon) in
            DispatchQueue.main.sync(execute: {
                self.mainView.iconView.image = icon
            })
        })
    }
}

Pour finir, mais c'est optionnel, créez un fichier “Alert.swift” contenant ceci :

import Cocoa

class Alert {

    class func criticalInfo(title: String, text: String) {
        let myPopup: NSAlert = NSAlert()
        myPopup.messageText = title
        myPopup.informativeText = text
        myPopup.alertStyle = NSAlertStyle.critical
        myPopup.addButton(withTitle: "OK")
        myPopup.runModal()
    }

    class func denied() {
        criticalInfo(title: "Not authorized",
                     text: "Location services have been denied for this app - it can't run and will quit immediately.")
    }
}

Ce qui va nous permettre de mieux gérer didChangeAuthorization status :

case .restricted, .denied:
    Alert.denied()
    NSApplication.shared().terminate(nil)

Conclusion

Voilà, c'est terminé ! On ajoute un peu de gestion des erreurs par-ci par-là et on est ok.

Bien sûr, il y aurait encore bien des choses à faire : exploiter l'historique des appels à l'API pour montrer un journal, ajouter des fonctions pour les prévisions des jours suivants, améliorer l'affichage en affichant courbes, photos de paysages avec le temps correspondant, meilleures icones provenant d'autres sources, etc. Mais celà dépasserait le domaine d'un simple tutoriel.

Nous avons déjà là de bonnes bases pour s'amuser à développer toutes ces choses : un code flexible et prêt à évoluer selon les besoins !

Le projet Xcode est disponible sur GitHub.

Auteur: Eric Dejonckheere