
Aprende a CREAR una APP del pronóstico del TIEMPO en SwiftUI y MVVM
Tutorial SwiftUI de como usar la arquitectura Model View ViewModel (MVVM). Aprende a usar URLSession, Decodable y varias APIs en iOS 15. Creamos nuestra propia app para ver el pronóstico del tiempo en distintas ciudades. Curso SwiftUI donde vamos paso a paso para crear una app en Xcode. MVVM SwiftUI
Tabla de contenido

Hoy en SwiftBeta vamos a crear una nueva app usando la arquitectura Model View ViewModel en SwiftUI. Esta vez crearemos una app que nos dará información del pronóstico del tiempo. Será muy sencilla, solo tendremos que especificar una ciudad y nos dará la siguiente información:
- Temperatura máxima
- Temperatura mínima
- A qué hora amanece
- A qué hora es la puesta de sol
- Humedad, etc
Para sacar todos estos datos vamos a utilizar la API de https://openweathermap.org. Al usar su API necesitamos crear un token en su web, en este caso podréis usar el mío (71c3e78149e90edcb26b5c8bf57708fa).
Pero que quede claro, que podéis crear uno vuestro de forma gratuita, lo único que tenéis que hacer es registraros.
Lo que vamos hacer es realizar una petición HTTP y obtener toda la información que hemos listado al principio del post. La URL (endpoint) que vamos a llamar va a ser la siguiente:
Fíjate que la URL se compone de:
http://api.openweathermap.org/data/2.5/weather
Y como habrás visto, esta URL tiene 4 parámetros distintos:
- q, es la ciudad de la que queremos obtener el pronóstico del tiempo
- appid, es el token que hemos creado en openweathermap.org. Lo necesitamos para poder validar todas nuestras peticiones HTTP. Podéis utilizar el mío 71c3e78149e90edcb26b5c8bf57708fa
- units, las unidades de medida que queremos usar, en nuestro caso serán grados celsius. Aquí podéis encontrar más información
- lang, es el lenguaje en el que queremos obtener la información del prónostico del tiempo, por ejemplo: "cielo claro" o "algo de nubes". Esto lo hacemos para simplicar nuestra UX.
Si copias y pegas esta URL en Safari, verás que aparece un JSON con la información que necesitamos obtener, manipular y mostrar en nuestra app.
{"coord":{"lon":2.159,"lat":41.3888},"weather":[{"id":801,"main":"Clouds","description":"algo de nubes","icon":"02n"}],"base":"stations","main":{"temp":21.07,"feels_like":21.32,"temp_min":19.99,"temp_max":22.11,"pressure":1025,"humidity":80},"visibility":10000,"wind":{"speed":2.68,"deg":229,"gust":5.36},"clouds":{"all":20},"dt":1634666651,"sys":{"type":2,"id":18549,"country":"ES","sunrise":1634623652,"sunset":1634663086},"timezone":7200,"id":3128760,"name":"Barcelona","cod":200}
Para organizar y visualizar el resultado de endpoints, uso PAW, una herramienta que compré hace tiempo, y que me ayuda a organizarme mejor (no es ningún tipo de promoción)

Aquí podemos crear distintos endpoints, ver sus parámetros, ver la respuesta del endpoint, etc. Solo quería comentarlo, si utilizáis otro tipo de aplicación molaría que me lo dejaráis en un comentario.
Vamos a empezar a escribir código.
Model-View-ViewModel en SwiftUI
Model en MVVM
Lo primero que vamos hacer, es crear un proyecto de cero en Xcode, y nada más crearlo pulsamos CMD+N para crear WeatherResponseDataModel
Si ya has visto mis videos sobre Network en Swift sabrás que esto ya lo hemos visto, lo único que estamos haciendo es crear un Modelo. Al realizar nuestra petición HTTP y recibir el JSON, transformaremos el JSON a algo que entienda nuestra app, y que será eso que entiende nuestra app? pues los modelos que creamos a continuación.
Al final del JSON que recibimos al hacer la petición HTTP, nosotros nos quedamos con la información que queremos manipular o mostrar en nuestra app.
import Foundation
struct WeatherResponseDataModel: Decodable {
let city: String
let weather: [WeatherDataModel]
let temperature: TemperatureDataModel
enum CodingKeys: String, CodingKey {
case city = "name"
case weather
case temperature = "main"
}
}
struct WeatherDataModel: Decodable {
let main: String
let description: String
let iconURLString: String
enum CodingKeys: String, CodingKey {
case main
case description
case iconURLString = "icon"
}
}
struct TemperatureDataModel: Decodable {
let currentTemperature: Double
let minTemperature: Double
let maxTemperature: Double
let humidity: Int
enum CodingKeys: String, CodingKey {
case currentTemperature = "temp"
case minTemperature = "temp_min"
case maxTemperature = "temp_max"
case humidity
}
}
Te aconsejo que eches un vistazo a los videos o posts sobre Network en Swift
Una vez hemos creado el modelo, vamos a meterlo en una carpeta que se llame Model, así todos los modelos de nuestra app los podremos identificar super rápido.
Lo siguiente que vamos hacer es crear el ViewModel. Esta parte de la arquitectura del MVVM se encargará de todo lo relacionado con realizar la petición HTTP y transformar estos datos para que la vista no tenga que hacer absolutamente nada y solo se encargue de mostrar la información del modelo.
Es decir, no queremos que la vista añada el símbolo de los grados º en la temperatura o que añada el simbolos del % a la humedad, etc.
ViewModel en MVVM
Pulsamos otra vez CMD+N para crear nuestro ViewModel, lo podemos llamar como queramos, en mi caso lo llamaré WeatherViewModel. Y lo primero de todo que haré será crear el método que se encargue de hacer la petición HTTP:
import Foundation
final class WeatherViewModel {
func getWeather(city: String) async {
let url = URL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=71c3e78149e90edcb26b5c8bf57708fa&units=metric&lang=es")!
do {
async let (data, _) = try await URLSession.shared.data(from: url)
let dataModel = try! await JSONDecoder().decode(WeatherResponseDataModel.self, from: data)
print(dataModel)
} catch {
print(error.localizedDescription)
}
}
}
Fíjate que hemos usado APIs de iOS 15, es por eso que si tienes el proyecto de Xcode usando iOS 15 te aparecerán errores. Solo debes cambiar la versión a iOS 15 y estaría solucionado.
Observa bien el código, para realizar la petición HTTP hemos usado URLSession pero con una novedad que no habíamos visto hasta ahora en el canal. Hemos usado async/await en SwiftUI para evitar tener que crear el típico completionBlock. Una vez realizamos la petición, printamos los datos por consola. Vamos a probar que todo funciona bien, nos vamos a la vista e instanciamos nuestro nuevo ViewModel para poder llamar al método getWeather(city:) pasándole una ciudad.
La vista ContentView quedaría de la siguiente manera:
import SwiftUI
struct ContentView: View {
private let weatherViewModel = WeatherViewModel()
var body: some View {
Text("Hello, world!")
.padding()
.task {
await weatherViewModel.getWeather(city: "Barcelona")
}
}
}
Si compilamos nuestra app y la probamos en un simulador qué pasa? Pues que la app crashea, por qué? Si te fijas, nos aparece un mensaje por consola.
"The resource could not be loaded because the App Transport Security policy requires the use of a secure connection."
Cuando hacemos peticiones que son http (es decir, las que no son https), Apple nos obliga a que lo especifiquemos en su Info.plist.
Para solucionarlo, debemos ir al Info.plist de nuestra app en Xcode. Una vez allí, añadimos una nueva Key, App Transport Security Settings y una vez añadida creamos otra Key dentro de la que acabamos de crear llamada Allow Arbitrary Loads y en esta le damos el valor de YES (con esto arreglamos el crash de nuestra app)

Vamos a probar si funciona o no, compilamos y ejecutamos la app en el simulador. Y... vemos que todo funciona perfectamente, vemos el print de nuestro WeatherResponseDataModel por consola:
WeatherResponseDataModel(city: "Barcelona", weather: [SwiftBeta_WeatherTest.WeatherDataModel(main: "Clouds", description: "algo de nubes", iconURLString: "02n")], temperature: SwiftBeta_WeatherTest.TemperatureDataModel(currentTemperature: 20.73, minTemperature: 19.21, maxTemperature: 22.14, humidity: 80))
Antes de ir a la vista, necesitamos hacer unos retoques a nuestro ViewModel, ya que en lugar de printar por consola, lo que queremos es guardar esta información en una propiedad, y que esta notifique a la vista para que se refresque y muestre los nuevos datos. Para ello hacemos lo que hemos visto en muchos de nuestros videos, usar el property wrapper @Published y conformar el protocolo ObservableObject.
import Foundation
final class WeatherViewModel: ObservableObject {
@Published var weatherResponseDataModel: WeatherResponseDataModel?
func getWeather(city: String) async {
let url = URL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=71c3e78149e90edcb26b5c8bf57708fa&units=metric&lang=es")!
do {
async let (data, _) = try await URLSession.shared.data(from: url)
let dataModel = try! await JSONDecoder().decode(WeatherResponseDataModel.self, from: data)
DispatchQueue.main.async {
self.weatherResponseDataModel = dataModel
}
} catch {
print(error.localizedDescription)
}
}
}
View en MVVM
Vamos a dar un diseño a nuestra vista para poder mostrar los datos de nuestro modelo WeatherResponseDataModel. Es decir, hasta ahora nos estamos mostrando nada de los datos recibidos en nuestra petición HTTP.
La vista que creamos es la siguiente:
import SwiftUI
struct ContentView: View {
@StateObject var weatherViewModel = WeatherViewModel()
var body: some View {
ZStack {
VStack {
Text(weatherViewModel.weatherResponseDataModel?.city ?? "No city")
.foregroundColor(.white)
.font(.system(size: 70))
Text(weatherViewModel.weatherResponseDataModel?.weather.first?.description ?? "No temperature")
.font(.headline)
.foregroundColor(.white)
.padding(.bottom, 8)
HStack {
if let iconURL = weatherViewModel.weatherResponseDataModel?.weather.first?.iconURLString,
let url = URL(string: "http://openweathermap.org/img/wn/\(iconURL)@2x.png") {
AsyncImage(url: url) { image in
image
} placeholder: {
ProgressView()
}
}
Text("\(weatherViewModel.weatherResponseDataModel?.temperature.currentTemperature ?? 0.0)º")
.font(.system(size: 70))
.foregroundColor(.white)
}
.padding(.top, -20)
HStack(spacing: 14) {
Label("\(weatherViewModel.weatherResponseDataModel?.temperature.maxTemperature ?? 0.0)º máx.", systemImage: "thermometer.sun.fill")
.symbolRenderingMode(.multicolor)
.foregroundColor(.white)
Label("\(weatherViewModel.weatherResponseDataModel?.temperature.minTemperature ?? 0.0)º min.", systemImage: "thermometer.snowflake")
.symbolRenderingMode(.multicolor)
.foregroundColor(.white)
}
Divider()
.foregroundColor(.white)
.padding()
Label("\(weatherViewModel.weatherResponseDataModel?.temperature.humidity ?? 0) %", systemImage: "humidity.fill")
.symbolRenderingMode(.multicolor)
.foregroundColor(.white)
Spacer()
}
.padding(.top, 32)
}
.background(
LinearGradient(gradient: Gradient(colors: [.blue, .purple]), startPoint: .topLeading, endPoint: .bottomTrailing)
)
.task {
await weatherViewModel.getWeather(city: "Barcelona")
}
}
}
Y si compilamos nuestra app, vemos que el resultado no es bien bien lo que queremos. Pero lo arreglaremos! De momento ya tenemos algo, hemos printado datos de nuestra petición HTTP en nuestra app 👏

Si te fijas, nuestra vista hace muchas cosas:
- Comprueba si un valor del modelos es nil, y si es nil le pone un valor por defecto.
- Comprueba si tenemos un iconURL
- Crea una URL para poder mostrar el icono que representa el tiempo.
- Añade el símbolo de º a la temperatura
Lo que vamos hacer es crear un nuevo modelo, es decir vamos a crear un WeatherModel que tendrá la info preparada para que la vista la muestre sin tener que hacer absolutamente nada. Con esto lo que hacemos es crear un boundary, el DataModel se encargará de recoger la información que queramos de nuestra petición HTTP y el Modelo se encargará de tener los datos listos para mostrar en la vista.
Creamos el Modelo WeatherModel
Para crear el nuevo modelo, nos vamos a la carpeta que hemos creado antes, llamada Model, y creamos WeatherModel.
import Foundation
struct WeatherModel {
let city: String
let weather: String
let description: String
let iconURL: URL?
let currentTemperature: String
let minTemperature: String
let maxTemperature: String
let humidity: String
static let empty: WeatherModel = .init(city: "No city",
weather: "No Weather",
description: "No description",
iconURL: nil,
currentTemperature: "0º",
minTemperature: "0º Min.",
maxTemperature: "0º Máx.",
humidity: "0%")
}
Aquí tenemos todas las propiedades al mismo nivel, y como detalle hemos creado una propiedad estática llamada empty, que nos servirá para mostrar esta información mientras la app está realizando la petición HTTP, en ese transcurso aparecerá No city, No weather, etc y al recibir la información de la petición HTTP , si todo ha ido bien, se cargarán los datos del pronóstico del tiempo.
Por último, lo que vamos hacer es crear una nueva entidad, va a ser la encargada de pasar del DataModel a nuestro modelo y dejarlo todo listo para que la vista no tenga que hacer nada.
Creamos el mapper de DataModel a Modelo
Lo que vamos hacer es crear una struct, esta struct tendrá una función que recibirá como parámetro un DataModel, que en nuestro caso es WeatherResponseDataModel y aquí dentro pasaran ciertas transformaciones y como resultado final, saldrá un WeatherModel, listo para ser utilizado en la vista.
import Foundation
struct WeatherModelMapper {
func mapDataModelToModel(dataModel: WeatherResponseDataModel) -> WeatherModel {
guard let weather = dataModel.weather.first else {
return .empty
}
let temperature = dataModel.temperature
return WeatherModel(city: dataModel.city,
weather: weather.main,
description: "(\(weather.description))",
iconURL: URL(string: "http://openweathermap.org/img/wn/\(weather.iconURLString)@2x.png"),
currentTemperature: "\(Int(temperature.currentTemperature))º",
minTemperature: "\(Int(temperature.minTemperature))º",
maxTemperature: "\(Int(temperature.maxTemperature))º",
humidity: "\(temperature.humidity)%")
}
}
Con esto ya verás que el código queda mucho más limpio en la vista. Ahora solo debemos usarlo en nuestro ViewModel. Para ello vamos a crear una propiedad nueva llamada weatherModelMapper y vamos a actualizar el tipo de nuestra propiedad @Published, ya que dejará de ser WeatherResponseDataModel y pasará a ser WeatherModel. Nuestro WeatherViewModel quedaría de la siguiente manera:
import Foundation
final class WeatherViewModel: ObservableObject {
@Published var weatherInfo: WeatherModel = .empty
private let weatherModelMapper: WeatherModelMapper
init(weatherModelMapper: WeatherModelMapper = WeatherModelMapper()) {
self.weatherModelMapper = weatherModelMapper
}
func getWeather(city: String) async {
let url = URL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=71c3e78149e90edcb26b5c8bf57708fa&units=metric&lang=es")!
do {
async let (data, _) = try await URLSession.shared.data(from: url)
let dataModel = try! await JSONDecoder().decode(WeatherResponseDataModel.self, from: data)
DispatchQueue.main.async {
self.weatherInfo = self.weatherModelMapper.mapDataModelToModel(dataModel: dataModel)
}
} catch {
print(error.localizedDescription)
}
}
}
Si ahora intentamos compilar, vamos a tener un error en nuestra vista ContentView. Allí tenemos que utilizar las propiedades de nuestor nuevo modelo WeatherModel.
Después de hacer los cambios nos quedaría este código en ContentView, muchísimo más limpio que el que teníamos antes:
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = WeatherViewModel()
var body: some View {
ZStack {
VStack {
Text(viewModel.weatherInfo.city)
.foregroundColor(.white)
.font(.system(size: 70))
Text(viewModel.weatherInfo.description)
.font(.headline)
.foregroundColor(.white)
.padding(.bottom, 8)
HStack {
if let url = viewModel.weatherInfo.iconURL {
AsyncImage(url: url) { image in
image
} placeholder: {
ProgressView()
}
}
Text(viewModel.weatherInfo.currentTemperature)
.font(.system(size: 70))
.foregroundColor(.white)
}
.padding(.top, -20)
HStack(spacing: 14) {
Label(viewModel.weatherInfo.maxTemperature, systemImage: "thermometer.sun.fill")
Label(viewModel.weatherInfo.minTemperature, systemImage: "thermometer.snowflake")
}
.symbolRenderingMode(.multicolor)
.foregroundColor(.white)
Divider()
.foregroundColor(.white)
.padding()
Label(viewModel.weatherInfo.humidity, systemImage: "humidity.fill")
.symbolRenderingMode(.multicolor)
.foregroundColor(.white)
Spacer()
}
.padding(.top, 32)
}
.background(
LinearGradient(gradient: Gradient(colors: [.blue, .purple]), startPoint: .topLeading, endPoint: .bottomTrailing)
)
.task {
await viewModel.getWeather(city: "california")
}
}
}
Y el resultado en el simulador mucho más limpio también,

Para finalizar me gustaría añadir má información a nuestra vista, en este caso vamos a mostrar cuando amanece y cuando es la puesta de sol en cada ciudad.
Mostrar cuando amanece y cuando es la puesta de sol
Lo primero de todo que tenemos que hacer es recoger esta información de nuestra petición HTTP. Para hacerlo nos vamos al fichero WeatherResponseDataModel y creamos una nueva struct.
struct SunModel: Decodable {
let sunrise: Date
let sunset: Date
}
Y también deberemos crear una nueva propiedad en la struct WeatherResponseDataModel:
struct WeatherResponseDataModel: Decodable {
let city: String
let weather: [WeatherDataModel]
let temperature: TemperatureDataModel
let sun: SunModel
let timezone: Double
enum CodingKeys: String, CodingKey {
case city = "name"
case weather
case temperature = "main"
case sun = "sys"
case timezone
}
}
Fíjate que también hemos añadido el timezone, esto lo utilizaremos para calcular la hora exacta en la que amanece o se pone el sol.
Ya que estamos con los modelos, vamos a añadir dos propiedades a nuestro WeatherModel, estas dos propiedades serán de tipo Date y WeatherModel quedaría:
import Foundation
struct WeatherModel {
let city: String
let weather: String
let description: String
let iconURL: URL?
let currentTemperature: String
let minTemperature: String
let maxTemperature: String
let humidity: String
let sunset: Date
let sunrise: Date
static let empty: WeatherModel = .init(city: "No city",
weather: "No Weather",
description: "No description",
iconURL: nil,
currentTemperature: "0º",
minTemperature: "0º Min.",
maxTemperature: "0º Máx",
humidity: "0%",
sunset: .now,
sunrise: .now)
}
Una vez hemos añadido esta información al modelo, debemos actualizar nuestro mapper, ya que el mapper es el que nos hace ir de WeatherResponseDataModel al WeatherModel. En WeatherModelMapper, quedaría el siguiente código, donde calculamos exactamente la hora de la salida del sol y de la puesta del sol, y se las pasamos a WeatherModel:
import Foundation
struct WeatherModelMapper {
func mapDataModelToModel(dataModel: WeatherResponseDataModel) -> WeatherModel {
guard let weather = dataModel.weather.first else {
return .empty
}
let temperature = dataModel.temperature
let sunsetWithTimeZone = dataModel.sun.sunset.addingTimeInterval(dataModel.timezone - Double(TimeZone.current.secondsFromGMT()))
let sunriseWithTimeZone = dataModel.sun.sunrise.addingTimeInterval(dataModel.timezone - Double(TimeZone.current.secondsFromGMT()))
return WeatherModel(city: dataModel.city,
weather: weather.main,
description: "(\(weather.description))",
iconURL: URL(string: "http://openweathermap.org/img/wn/\(weather.iconURLString)@2x.png"),
currentTemperature: "\(Int(temperature.currentTemperature))º",
minTemperature: "\(Int(temperature.minTemperature))º",
maxTemperature: "\(Int(temperature.maxTemperature))º",
humidity: "\(temperature.humidity)%",
sunset: sunsetWithTimeZone,
sunrise: sunriseWithTimeZone)
}
}
Si ahora compilamos, todo funciona perfectamente, pero nos falta un último paso, poder mostrar esta nueva información en la vista. Pues vamos a ContentView y añadimos un divider y un HStack:
Divider()
.foregroundColor(.white)
.padding()
HStack(spacing: 32) {
VStack {
Image(systemName: "sunrise.fill")
.symbolRenderingMode(.multicolor)
Text(viewModel.weatherInfo.sunrise, style: .time)
}
VStack {
Image(systemName: "sunset.fill")
.symbolRenderingMode(.multicolor)
Text(viewModel.weatherInfo.sunset, style: .time)
}
}
.foregroundColor(.white)
Lo he añadido justo encima debajo de la temperatura máxima y mínima de la ciudad, pero lo podéis añadir donde queráis. Y si compiláis ahora la aplicación deberíais ver algo muy parecido a esto:

Ahora por ejemplo, podríais añadir un buscador para que de forma dinámica puedas ir haciendo peticiones ha distintas ciudades y poder ver su pronóstico del tiempo.
Conclusión
Hoy hemos visto como usar la arquitectura Model View ViewModel en SwiftUI. Hemos practicado distintos temas, desde peticiones HTTP, decodable, async/await, etc. También hemos aprendido sobre OpenWeatherMap por si queremos crear alguna app del pronóstico del tiempo.