Aprende la arquitectura Model View ViewModel en SwiftUI
Aprende la arquitectura Model View ViewModel en SwiftUI

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

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
App Real con Arquitectura Model View ViewModel en SwiftUI (MVVM)
App Real con Arquitectura Model View ViewModel en SwiftUI (MVVM)

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:

http://api.openweathermap.org/data/2.5/weather?q=barcelona&appid=71c3e78149e90edcb26b5c8bf57708fa&units=metric&lang=es

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)

SwiftBeta Paw

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
    }
}
Creamos el modelo de datos en Swift usando Decodable
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)
        }
    }
}
Creamos el ViewModel con la petición HTTP y URLSession en Swift

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")
            }
    }
}
Usamos el modificador task en SwiftUI

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)

Agregamos App Transpot Security Settings en el Info.plist de Xcode

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))
Mensaje que aparece en la consola de Xcode

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)
        }
    }
}
Creamos una propiedad @Published y conformamos ObservableObject

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")
        }
    }
}
Creamos la vista en SwiftUI

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 👏

Simulador de iPhone mostrando nuestra app en SwiftUI

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%")
}
Creamos un nuevo modelo en Swift

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)%")
    }
}
Creamos un Mapper en Swift

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)
        }
    }
}
Usamos el mapper en nuestro ViewModel

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")
        }
    }
}
Usamos el nuevo modelo: WeatherModel en la View ContentView

Y el resultado en el simulador mucho más limpio también,

Resultado al compilar nuestra app en el simulador de Xcode

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
}
Añadimos un nuevo modelo en Swift

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
    }
}
Creamos dos propiedades para recoger los datos del JSON en Swift
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)
}
Añadimos sunset y sunrise a WeatherModel en Swift

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)
    }
}
Modificamos el Mapper para calcular el sunset y sunrise en Swift

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)
Actualizamos la vista en SwiftUI para mostrar los nuevos datos

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:

Mostramos el resultado final en el Simulador de Xcode

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.