Articles

Divers

Tuto : Créer un app de météo avec SwiftUI

Par Didier Pulicani - Publié le

Lorsqu’Apple a annoncé SwiftUI à la WWDC 2019, j’ai tout de suite voulu le prendre en main en regardant les sessions et en suivant des tutoriels sur internet. J’ai eu envie d’aller plus loin qu’une app de démo pour voir ce que SwiftUI pouvait proposer. J’ai donc décidé d’écrire une app de météo en utilisant exclusivement ce framework. Voici comment je m’y suis pris.

Tuto : Créer un app de météo avec SwiftUI


Tutoriel rédigé par Benjamin Pisano du studio français Lunabee


Note : pour suivre ce tuto directement sous Xcode, vous pouvez télécharger le projet sur Github.


L’API



Il existe de nombreuses API gratuites qui fournissent des données météo. J’ai choisi Dark Sky pour sa facilité d’utilisation. Vous devrez créer un compte pour générer votre clé d’API et faire fonctionner cette app. Vous pouvez créer votre compte ici et générer votre clé gratuitement.

Tuto : Créer un app de météo avec SwiftUI


Pour tester son bon fonctionnement, tapez dans votre terminal :

curl https://api.darksky.net/forecast/your_key/45.572353,5.915807


Cette commande devrait retourner un fichier JSON contenant les données météo de la ville de Chambéry. Vous êtes maintenant prêts à configurer le projet Xcode.

Xcode



Pour utiliser SwiftUI dans votre app, vous devez télécharger Xcode 11 beta sur le site Apple Developper dans la section téléchargements. Prévoyez une bonne connexion internet puisque Xcode 11 pèse un peu plus de 5Go.

Tuto : Créer un app de météo avec SwiftUI


Une fois le téléchargement terminé, vous pouvez enfin commencer à créer votre projet avec SwiftUI. Vous êtes impatients ? Moi aussi ! 😃

Ouvrez Xcode, cliquez sur Create New Project (ou allez dans File > New > Project…). Sélectionnez Single View App. Nommez votre projet “Weather” et vérifiez que la case SwiftUI est bien cochée. Cliquez sur next pour créer le projet.

Tuto : Créer un app de météo avec SwiftUI

Le panneau de configuration de votre projet.


Nous sommes maintenant prêts à passer au code.

Créer le modèle



Météo



Maintenant que vous connaissez l’url permettant de récupérer les données météo d’une ville, créons notre modèle qui ira récupérer ces données.

Notre modèle devrait ressembler à ceci :

struct Weather: Codable {

var current: HourlyWeather
var hours: Weather.List<HourlyWeather>
var week: Weather.List<DailyWeather>

enum CodingKeys: String, CodingKey {

case current = "currently"
case hours = "hourly"
case week = "daily"

}

}

Weather.swift


extension Weather {

struct List<T: Codable & Identifiable>: Codable {

var list:

enum CodingKeys: String, CodingKey {

case list = "data"

}

}

}

WeatherList.swift


struct DailyWeather: Codable, Identifiable {

var id: Date {
return time
}

var time: Date
var maxTemperature: Double
var minTemperature: Double
var icon: Weather.Icon

enum CodingKeys: String, CodingKey {

case time = "time"
case maxTemperature = "temperatureHigh"
case minTemperature = "temperatureLow"
case icon = "icon"

}

}

DailyWeather.swift


struct HourlyWeather: Codable, Identifiable {

var id: Date {
return time
}

var time: Date
var temperature: Double
var icon: Weather.Icon

}

HourlyWeather.swift


extension Weather {

enum Icon: String, Codable {

case clearDay = "clear-day"
case clearNight = "clear-night"
case rain = "rain"
case snow = "snow"
case sleet = "sleet"
case wind = "wind"
case fog = "fog"
case cloudy = "cloudy"
case partyCloudyDay = "partly-cloudy-day"
case partyCloudyNight = "partly-cloudy-night"

var image: Image {
switch self {
case .clearDay:
return Image(systemName: "sun.max.fill")
case .clearNight:
return Image(systemName: "moon.stars.fill")
case .rain:
return Image(systemName: "cloud.rain.fill")
case .snow:
return Image(systemName: "snow")
case .sleet:
return Image(systemName: "cloud.sleet.fill")
case .wind:
return Image(systemName: "wind")
case .fog:
return Image(systemName: "cloud.fog.fill")
case .cloudy:
return Image(systemName: "cloud.fill")
case .partyCloudyDay:
return Image(systemName: "cloud.sun.fill")
case .partyCloudyNight:
return Image(systemName: "cloud.moon.fill")
}
}

}

}

WeatherIcon.swift


Vous pouvez également créer un WeatherManager qui inclura votre clé d’API Dark Sky.

class WeatherManager {

static let key: String = "" // Enter your darkSky API Key here
static let baseURL: String = "https://api.darksky.net/forecast/\(key)/"

}

WeatherManager.swift


Notre modèle est plutôt simple. Il est conforme au protocole Codable qui nous permet de convertir facilement le JSON retourné par l’API en objet dans notre app. C’est un modèle très classique, c’est pourquoi je ne l’expliquerais pas en détail. Maintenant, créons notre modèle pour chaque ville.

Villes



Le modèle des villes est un peu plus intéressant car il introduit le concept de Binding.

Tuto : Créer un app de météo avec SwiftUI

Attendez… Quoi ? Le Binding ?


Le concept de binding a été introduit en premier lieu sur Mac OS X dans Interface Builder. Il permettait d’observer une variable et de mettre à jour la partie UI de l’app automatiquement en fonction des changements du modèle. Pas de delegate, pas de completion handler : voici le binding. Dans Mac OS X, il n’était pas très facile d’utiliser le binding. C’était encore pire de debugger avec. Avec le temps, Apple est revenu sur un modèle avec des delegates pour ses composants plutôt que du binding, principalement pour réduire le fossé qui se creusait entre AppKit et UIKit. Ce dernier n’utilisait pas le binding, un concept complètement absent sur iOS.

Tuto : Créer un app de météo avec SwiftUI

Le binding avec Storyboard sur macOS.


Note : le concept de binding n’est pas spécifique à Swift ou Objective-C.


Aujourd’hui, avec Combine et SwiftUI, Apple remet le binding au goût du jour, d’une manière bien plus élégante. Découvrons-le en créant notre modèle pour les villes.

Tout d’abord, créons notre objet City qui contiendra le nom de la ville et ses données météo. Notre objet devrait ressembler à cela :

import SwiftUI
import Combine

class City: BindableObject {

var didChange = PassthroughSubject<City, Never>()

var name: String
var weather: Weather? {
didSet {
didChange.send(self)
}
}

init(name: String) {
self.name = name
self.getWeather()
}

private func getWeather() {
guard let url = URL(string: WeatherManager.baseURL + "45.572353,5.915807?units=ca&lang=fr") else {
return
}

URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else {
return
}

do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970

let weatherObject = try decoder.decode(Weather.self, from: data)

DispatchQueue.main.async {
self.weather = weatherObject
}
} catch {
print(error.localizedDescription)
}
}.resume()
}

}

City.swift


Ce code contient plusieurs nouveaux concepts. Pas de panique ! Nous allons les expliquer pas à pas.

Premièrement, vous pouvez voir que l’objet City est conforme au protocole BindableObject qui est un nouveau protocole introduit avec Combine. BindableObject permet à notre objet d’être observé lorsque ses propriétés sont modifiées. Cela requiert un appel fonction dans le setter de nos variables qui ont besoin d’être observées. didChange.send(self) doit être appelé lorsque vous souhaitez notifier que cet objet a subi un changement. Vous pouvez voir ici que nous appelons cette fonction dans le setter de la variable weather. Cela signifie qu’à chaque fois que la variable weather sera modifiée, les observateurs de l’objet City seront notifiés qu’il y a eu un changement sur cet objet et qu’il faut probablement mettre à jour l’interface.

Note : Ici, je n’appelle pas didChange.send(self) dans le setter de la variable name, car on supposera que le nom d’une ville ne change jamais une fois qu’elle est initialisée.


La fonction getWeather() va récupérer les données météo de la ville de manière asynchrone dès que la ville est initialisée. Lorsque les données sont récupérées, elle décode le JSON et modifie la variable weather.

Note importante : d’après la documentation Apple, didChange.send(self) doit être appelée uniquement sur le thread principal. C’est pourquoi j’utilise DispatchQueue.main pour modifier la variable weather puisque URLSession s’exécute en arrière plan.


Vous venez de créer votre objet bindable. Beau travail.

Tuto : Créer un app de météo avec SwiftUI


Maintenant, créons un CityStore qui contiendra la liste de nos villes. CityStore devra aussi être bindable pour pouvoir observer si une ville a été ajoutée ou supprimée. Mais maintenant que vous avez compris le principe du binding, ça va être super simple non ?

import SwiftUI
import Combine

class CityStore: BindableObject {

let didChange = PassthroughSubject<CityStore, Never>()

var cities: [City] = [City(name: "Chambery")] {
didSet {
didChange.send(self)
}
}

}

CityStore.swift

Tuto : Créer un app de météo avec SwiftUI


Prenons une petite pause pour résumer ce que nous venons de voir.

Le protocole BindableObject nous permet d’observer les changements d’un objet en appelant didChange.send(self) dans le setter de ses variables.

Nous avons créé un objet City qui est conforme au protocole BindableObject pour observer les changements d’une ville (lorsque les données météo sont récupérées).

Nous avons créé un CityStore qui est conforme au protocole BindableObject pour observer les changements dans notre liste de villes (lorsque l’utilisateur ajoute ou supprime une ville).

L’interface utilisateur



Si vous n’avez jamais vu à quoi ressemble une interface faite avec SwiftUI, je vous invite à regarder les tutoriels d’Apple qui sont excellents pour se familiariser avec la conception d’interfaces.

Créer une interface utilisateur avec SwiftUI est très simple et assez amusant. J’ai toujours utilisé Storyboard pour concevoir mes interfaces, mais SwiftUI a mis la barre très haute. En réduisant significativement le code nécéssaire pour concevoir une interface, SwiftUI permet plus d’itérations, ce que je considère comme un réel avantage pour les développeurs et designers.

Storyboard n’existe plus, puisque SwiftUI génère, en direct, un aperçu de votre app. Si votre code change, l’aperçu se met à jour. Mais encore plus fort : si vous changez l'aperçu, votre code se met à jour aussi. C’est presque magique.

Tuto : Créer un app de météo avec SwiftUI

Le code et l’aperçu. (Note : vous aurez besoin de macOS 10.15 Catalina pour avoir l’aperçu)


Maintenant, créons notre vue qui contiendra nos villes.

import SwiftUI

struct CityListView : View {

@EnvironmentObject var cityStore: CityStore

@State var isAddingCity: Bool = false
@State private var isEditing: Bool = false

var body: some View {
NavigationView {
List {
Section(header: Text("Your Cities")) {
ForEach(cityStore.cities) { city in
CityRow(city: city)
}
.onDelete(perform: delete)
.onMove(perform: move)
}
}
.navigationBarItems(leading: EditButton(), trailing: addButton)
.navigationBarTitle(Text("Weather"))
}
}

private var addButton: some View {
Button(action: {
self.isAddingCity = true
self.isEditing = false
}) {
Image(systemName: "plus.circle.fill")
.font(.title)
}
.presentation(isAddingCity ? newCityView : nil)
}

private func delete(at offsets: IndexSet) {
for index in offsets {
cityStore.cities.remove(at: index)
}
}

private func move(from source: IndexSet, to destination: Int) {
var removeCities: [City] = []

for index in source {
removeCities.append(cityStore.cities[index])
cityStore.cities.remove(at: index)
}

cityStore.cities.insert(contentsOf: removeCities, at: destination)
}

private var newCityView: Modal {
Modal(NewCityView(isAddingCity: $isAddingCity).environmentObject(cityStore)) {
self.isAddingCity = false
}
}

}

CityListView.swift


Ce code devrait être plutôt facile à comprendre, mais il y quelque nouveaux mots clés. Encore une fois, expliquons-les pas à pas.

Note : Swift 5.1 introduit une nouvelle fonctionnalité appelée Property Wrappers. Je vous recommande cet article qui explique ce concept un peu plus en détail.


var body: some View


Cette variable représente le corps de notre vue. C’est ici que vous allez construire votre interface.

@EnvironmentObject


@EnvironmentObject nous permet d’utiliser une seule instance d’un objet qui sera utilisée globalement dans l’app. Pratique pour des objets comme un utilisateur, qui est utilisé partout dans une app.

@State


@State est un Property Wrapper qui représente un état dans une vue. @State ne doit être utilisé que dans une vue et déclaré private pour éviter de l’utiliser n’importe où. Par exemple, dans notre CityListView, il y a un état qui définit si l’utilisateur est en train d’éditer la liste des villes.

@Binding


@Binding est utilisé pour binder une variable @State. Dans certains cas, vous aurez besoin de passer un état dans une vue enfant de votre hiérarchie. Vous pouvez alors utiliser @Binding dans votre vue enfant et lui passer votre variable @State.

@ObjectBinding


@ObjectBinding est utilisé pour observer les changements d’un objet qui répond au protocole BindableObject que nous avons vu précédemment.

Ces propriétés vont nous permettre de garder notre interface à jour en fonction de notre modèle.

Pour faire de CityListView la première vue qui sera présentée dans notre app, modifiez le fichier SceneDelegate.swift comme ceci :

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use a UIHostingController as window root view controller
let window = UIWindow(frame: UIScreen.main.bounds)
let cityStore = CityStore()
window.rootViewController = UIHostingController(rootView: CityListView().environmentObject(cityStore))
self.window = window
window.makeKeyAndVisible()
}

}

SceneDelegate.swift


Ici, nous instancions un CityStore et le passons en tant qu’EnvironmentObject à notre CityListView.

Nous pouvons maintenant créer une vue météo pour les villes :

import SwiftUI

struct CityView : View {

@ObjectBinding var city = City(name: "Chambéry")

var body: some View {
List {
Section(header: Text("Now")) {
CityHeaderView(city: city)
}

Section(header: Text("Hourly")) {
CityHourlyView(city: city)
}

Section(header: Text("This week")) {
ForEach(city.weather?.week.list ?? []) { day in
CityDailyView(day: day)
}
}
}
.navigationBarTitle(Text(city.name))
}

}

CityView.swift


Note : Nous utilisons un @ObjectBinding dans notre CityView. Cela permet à l’utilisateur d’accéder à cette vue même si les données météo n’ont pas encore été récupérées. Une fois celle-ci chargées, tous les éléments d’interface qui contiennent une référence à cet objet sera re-rendu à l’écran. Cela ne fonctionne que si votre objet est conforme au protocole BindableObject.


Maintenant, toutes les vues des données météo :

struct CityHeaderView: View {

@ObjectBinding var city: City

var temperature: String {
guard let temperature = city.weather?.current.temperature else {
return "-ºC"
}
return temperature.formattedTemperature
}

var body: some View {
HStack(alignment: .center) {
Spacer()
HStack(alignment: .center, spacing: 16) {
city.weather?.current.icon.image
.font(.largeTitle)
Text(temperature)
.font(.largeTitle)
}
Spacer()
}
.frame(height: 110)
}

}

CityHeaderView.swift


struct CityHourlyView : View {

@ObjectBinding var city: City

private let rowHeight: CGFloat = 110

var body: some View {
ScrollView(alwaysBounceHorizontal: true, showsHorizontalIndicator: false) {
HStack(spacing: 16) {
ForEach(city.weather?.hours.list ?? []) { hour in
VStack(spacing: 16) {
Text(hour.time.formattedHour)
.font(.footnote)
hour.icon.image
.font(.body)
Text(hour.temperature.formattedTemperature)
.font(.headline)
}
}
}
.frame(height: rowHeight)
.padding(.trailing)
}
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(height: rowHeight)
}

}

CityHourlyView.swift


struct CityDailyView : View {

@State var day: DailyWeather

var body: some View {
ZStack {
HStack(alignment: .center) {
Text(day.time.formattedDay)
Spacer()
HStack(spacing: 16) {
verticalTemperatureView(min: true)
verticalTemperatureView(min: false)
}
}
HStack(alignment: .center) {
Spacer()
day.icon.image
.font(.body)
Spacer()
}
}
}

func verticalTemperatureView(min: Bool) -> some View {
VStack(alignment: .trailing) {
Text(min ? "min" : "max")
.font(.footnote)
.color(.gray)
Text(min ? day.minTemperature.formattedTemperature : day.maxTemperature.formattedTemperature)
.font(.headline)
}
}

}

CityDailyView.swift

Note : La CollectionView n’est pas encore un composant disponible dans SwiftUI. Pour pallier cette absence, nous utilisons ici une ScrollView contenant une HStack pour la vue par heure.


Enfin, nous pouvons créer une vue pour l’ajout d’une nouvelle ville. Construisons notre modèle qui nous suggérera des villes en fonction d’une recherche. Encore une fois, ce modèle sera bindable pour pouvoir observer les résultats de manière asynchrone.

import SwiftUI
import Combine
import MapKit

class CityFinder: NSObject, BindableObject {

var didChange = PassthroughSubject<CityFinder, Never>()

var results: [String] = [] {
didSet {
didChange.send(self)
}
}

private var searcher: MKLocalSearchCompleter

override init() {
results = []
searcher = MKLocalSearchCompleter()
super.init()
searcher.resultTypes = .address
searcher.delegate = self
}

func search(_ text: String) {
searcher.queryFragment = text
}

}

extension CityFinder: MKLocalSearchCompleterDelegate {

func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
results = completer.results.map({ $0.title })
}

}

CityFinder.swift


Nous utiliserons MapKit pour nous suggérer des villes.

Construisons maintenant notre vue modale pour ajouter une ville :

struct NewCityView : View {

@Binding var isAddingCity: Bool
@State private var search: String = ""

@ObjectBinding var cityFinder: CityFinder = CityFinder()
@EnvironmentObject var cityStore: CityStore

var body: some View {
NavigationView {
List {
Section {
TextField($search, placeholder: Text("Search City")) {
self.cityFinder.search(self.search)
}
}

Section {
ForEach(cityFinder.results.identified(by: \.self)) { result in
Button(action: {
self.addCity(from: result)
self.isAddingCity = false
}) {
Text(result)
}
.foregroundColor(.black)
}
}
}
.navigationBarTitle(Text("Add City"))
.navigationBarItems(leading: cancelButton)
.listStyle(.grouped)
}
}

private var cancelButton: some View {
Button(action: {
self.isAddingCity = false
}) {
Text("Cancel")
}
}

private func addCity(from result: String) {
let cityName = result.split(separator: ",").first ?? ""
let city = City(name: String(cityName))
cityStore.cities.append(city)
}

}

NewCityView.swift


Cette vue est intéressante pour deux raisons :

1) Elle nous montre comment afficher une vue modale avec SwiftUI. Pour se faire, vous aurez besoin de passer une variable qui indique si la vue doit être affichée ou non. Actuellement, ce fonctionnement est un peu bugué.

2) @EnvironmentObject nous permet encore une fois d’utiliser une seule instance de notre CityStore à travers notre app avec du binding.

Et voilà ! Vous venez de créer votre première app avec SwiftUI.

Et après ?



Vous pouvez télécharger le projet complet sur mon Github. Vous pouvez aussi me contacter sur Twitter @benjamin_pisano. Et enfin, vous pouvez découvrir Aria sur le Mac AppStore.