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.
Note : pour suivre ce tuto directement sous Xcode, vous pouvez télécharger le projet sur Github.
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.
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
Nous sommes maintenant prêts à passer au code.
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 :
Vous pouvez également créer un WeatherManager qui inclura votre clé d’API Dark Sky.
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.
Le modèle des villes est un peu plus intéressant car il introduit le concept de
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 :
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
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 ?
CityStore.swift
Prenons une petite pause pour résumer ce que nous venons de voir.
• Le protocole
• Nous avons créé un objet
• Nous avons créé un CityStore qui est conforme au protocole
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.
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 :
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 :
Maintenant, toutes les vues des données météo :
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.
Nous utiliserons MapKit pour nous suggérer des villes.
Construisons maintenant notre vue modale pour ajouter une ville :
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.
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.
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.
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.
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.
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"
}
}
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"
}
}
}
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"
}
}
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
}
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")
}
}
}
}
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)/"
}
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.
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.
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()
}
}
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
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.
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.
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)
}
}
}
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.
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
}
}
}
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()
}
}
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))
}
}
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)
}
}
@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)
}
}
@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)
}
}
}
@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 })
}
}
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)
}
}
@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.