Tuto : Créer un app de météo avec SwiftUI
Par Didier Pulicani - Publié le
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.
Pour tester son bon fonctionnement, tapez dans votre terminal :
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.
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
nextpour créer le 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 :
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"
}
}
struct List<T: Codable & Identifiable>: Codable {
var list:
enum CodingKeys: String, CodingKey {
case list = "data"
}
}
}
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"
}
}
var id: Date {
return time
}
var time: Date
var temperature: Double
var icon: Weather.Icon
}
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")
}
}
}
}
Vous pouvez également créer un WeatherManager qui inclura votre clé d’API Dark Sky.
static let key: String = "" // Enter your darkSky API Key here
static let baseURL: String = "https://api.darksky.net/forecast/\(key)/"
}
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.
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.
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 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()
}
}
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
BindableObjectqui est un nouveau protocole introduit avec
Combine.
BindableObjectpermet à 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.
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.
Vous venez de créer votre objet bindable. Beau travail.
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 Combine
class CityStore: BindableObject {
let didChange = PassthroughSubject<CityStore, Never>()
var cities: [City] = [City(name: "Chambery")] {
didSet {
didChange.send(self)
}
}
}
CityStore.swift
Prenons une petite pause pour résumer ce que nous venons de voir.
• Le protocole
BindableObjectnous permet d’observer les changements d’un objet en appelant didChange.send(self) dans le setter de ses variables.
• Nous avons créé un objet
Cityqui 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
BindableObjectpour 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.
Maintenant, créons notre vue qui contiendra nos villes.
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
}
}
}
Ce code devrait être plutôt facile à comprendre, mais il y quelque nouveaux mots clés. Encore une fois, expliquons-les pas à pas.
Cette variable représente le corps de notre vue. C’est ici que vous allez construire votre interface.
@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 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 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 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 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()
}
}
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 :
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))
}
}
Maintenant, toutes les vues des données météo :
@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)
}
}
@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)
}
}
@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
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 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 })
}
}
Nous utiliserons MapKit pour nous suggérer des villes.
Construisons maintenant notre vue modale pour ajouter une ville :
@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)
}
}
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.