DEV Community

GeraldCO
GeraldCO

Posted on

Understanding MVVM by Building a Simple Weather App with SwiftUI

MVVM with swiftUI

When learning SwiftUI, one of the first architectural patterns you'll encounter is MVVM (Model-View-ViewModel). In this tutorial, we'll build a simple weather application that consumes the OpenWeather API while applying MVVM, dependency injection, and protocol-oriented programming. By the end, you'll understand not only how to structure the project, but also why each layer exists.

This is the link for the OpenWeather API https://openweathermap.org/api.

What you'll learn

By the end of this tutorial you'll know how to:

  • Structure a SwiftUI project using MVVM.
  • Consume a REST API using async/await.
  • Apply dependency injection using protocols.
  • Display loading and error states.
  • Keep Views focused only on UI.

This is how the data flow looks.

        User taps "Search"
                │
                ▼
         ┌──────────────┐
         │ ContentView  │
         └──────┬───────┘
                │
        await fetchWeather()
                │
                ▼
      ┌────────────────────┐
      │ WeatherViewModel   │
      └─────────┬──────────┘
                │
                ▼
        WeatherServiceProtocol
                │
                ▼
          ┌─────────────────┐
          │ WeatherService  │
          └──────┬──────────┘
                 │
                 ▼
         OpenWeather API
Enter fullscreen mode Exit fullscreen mode

Project Structure

WeatherApp
├── Configuration
│  └──AppConfig.swift
├── Models
│   ├── Main.swift
│   ├── Weather.swift
│   └── WeatherResponse.swift
├── Services
│   ├── WeatherService.swift
│   └── WeatherServiceProtocol.swift
├── ViewModels
│   └── WeatherViewModel.swift
└── Views
    └── ContentView.swift
Enter fullscreen mode Exit fullscreen mode
  • Configuration contains application-wide constants such as the API key and base URLs.
  • Models contains the data structures used to decode the API response.
  • Services is responsible for networking and fetching data.
  • ViewModels contains the presentation logic and exposes data to the UI.
  • Views contains the SwiftUI interface.

On the AppConfig file, we are going to keep static info, just like the base URL, API key, etc

struct AppConfig {
    static let apiKey = "YOUR API KEY"
    static let baseGeoCodingAPIURL = "https://api.openweathermap.org/geo/1.0/direct?q="
    static let baseURL = "https://api.openweathermap.org/data/2.5/weather?&units=metric&lat="

}
Enter fullscreen mode Exit fullscreen mode

Designing the UI

Before implementing the networking layer, we'll start by designing the UI. Defining the interface first helps us identify exactly which data we need from the API, allowing us to create only the models required by the application.

The app is going to look something like

┌───────────────────────────────┐
│ Enter city           Search   │
├───────────────────────────────┤
│                               │
│             Rain              │
│        Moderate Rain          │
│                               │
│             28.5°             │
│                               │
│  Feels Like     Min      Max  │
│     29.1°      25.0°    31.2° │
│                               │
└───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

After defining what data we need to show, we can create our models. The OpenWeather API JSON returns something like this

{
  "lat": 51.5,
  "lon": -0.1,
  "timezone": "Europe/London",
  "timezone_offset": 3600,
  "data": [
    {
      "dt": 1777449371,
      "sunrise": 1777437375,
      "sunset": 1777490344,
      "temp": 286.42,
      "feels_like": 285.32,
      "pressure": 1024,
      "humidity": 58,
      "dew_point": 278.34,
      "uvi": 1.55,
      "clouds": 0,
      "visibility": 10000,
      "wind_speed": 8.23,
      "wind_deg": 70,
      "weather": [
        {
          "id": 800,
          "main": "Clear",
          "description": "sky is clear",
          "icon": "01d"
        }
      ]
      "alerts": [
        "8B46C632-DCA7-44D7-8BDF-02445621BAFF",
        "29F58A35-BB91-4A73-9F46-9FC64BDF604F",
        ...
    ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The OpenWeather response contains much more information than we need. Rather than decoding the entire JSON, we'll only model the fields our application uses.

Creating the Models

First we have the Weather model

struct Weather: Codable{
    var id: Int
    var main: String
    var description: String
    var icon: String
}
Enter fullscreen mode Exit fullscreen mode

also we have the Main model

struct Main: Codable{
    let temp: Double
    let feels_like: Double
    let temp_min: Double
    let temp_max: Double
}
Enter fullscreen mode Exit fullscreen mode

Now we can combine the two of them to get the data we need from the OpenWeather API, this is the WeatherResponse object

struct WeatherResponse: Codable {
    var weather : [Weather]
    var main : Main
}
Enter fullscreen mode Exit fullscreen mode

Building the Service Layer

After creating the model, we can work on the service, the service is part of the data layer, it handles API/NETWORK tasks.

fetchCity and fetchWeather are asynchronous throwing functions. They propagate any networking or decoding errors to the caller, allowing the ViewModel to decide how to handle them.

The WeatherService is responsible for communicating with the OpenWeather API. It converts the city name into coordinates using the Geocoding API, then requests the current weather using those coordinates.

We are going to create the protocol for WeatherService, in that way we can use dependency injection, we can pass the interface via the initializer so you can easily swap it with a mock service during SwiftUI Previews or Unit testing.

protocol WeatherServiceProtocol{
    static func convertCityToGeoCodingAPI(city: String) -> String
    static func getWeatherAPI(lat: Double, lon: Double) -> String
    static func fetchCity(for city: String ) async throws  -> CityCoords
    func fetchWeather(city: String) async throws -> WeatherResponse
}
Enter fullscreen mode Exit fullscreen mode

We make these helper methods static because they don't depend on any instance-specific state. They simply build URLs based on the provided parameters, so there's no need to create a WeatherService object just to call them.

static func convertCityToGeoCodingAPI(city: String) -> String {
        return AppConfig.baseGeoCodingAPIURL + city + "&limit=1&appid=" + AppConfig.apiKey
    }

    static func getWeatherAPI(lat: Double, lon: Double) -> String {
        return AppConfig.baseURL + "\(lat)&lon=\(lon)&appid=" + AppConfig.apiKey
    }

Enter fullscreen mode Exit fullscreen mode

now we have the fetchCity and fetchWeather, the fetchCity receives the city name and converts it to coords, the fetchWeather uses the fetchCity function to get the current weather data from the results from fetchCity.

    static func fetchCity(for city: String ) async throws  -> CityCoords {
        //create and validate url
        guard let url = URL(string : Self.convertCityToGeoCodingAPI(city: city)) else {
            throw NetworkError.invalidURL
        }

        //Fetch data from the network
            let (data, response) = try await URLSession.shared.data(from: url)

        //verify http status code
        guard let httpResponse = response as? HTTPURLResponse,httpResponse.statusCode == 200 else {
            throw NetworkError.invalidResponse
        }

        //Decode JSON payload
        let coordsList = try JSONDecoder().decode([CityCoords].self, from: data)

        // 5. Ensure the array isn't empty before picking the first result
        guard let coords = coordsList.first else {
            throw NetworkError.cityNotFound 
        }

        return coords;
    }

    func fetchWeather(city: String) async throws -> WeatherResponse {
        let coords = try await Self.fetchCity(for: city)

        guard let url = URL(string: Self.getWeatherAPI(lat: coords.lat, lon: coords.lon)) else {
            throw NetworkError.invalidURL
        }
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw NetworkError.invalidResponse
        }

        let weatherData = try JSONDecoder().decode(WeatherResponse.self, from: data)

        return weatherData
    }
}

Enter fullscreen mode Exit fullscreen mode

Implementing the ViewModel

Now we can work on the ViewModel; The ViewModel responds to user actions, requests data from the service, stores the screen's state, and exposes formatted data for the View to display.

We use computed properties to send the data formatted to the view so we can keep the view dumb.

The WeatherViewModel conforms to ObservableObject, allowing SwiftUI to observe it for changes. Properties marked with @Published automatically notify the View whenever their values change, causing the UI to refresh.

The @MainActor is used to guarantee the code will run in the main thread, which is mandatory when we are updating the user interface.

import Foundation
import Combine


@MainActor
final class WeatherViewModel: ObservableObject {
    //Store state
    @Published var weatherResponse : WeatherResponse?
    @Published var isLoading = false
    @Published var errorMessage: Error?

    //Dependencies
    private let weatherService : WeatherServiceProtocol

    init(weatherService: WeatherServiceProtocol){
        self.weatherService = weatherService
    }

    //private derived data
    private var currentWeather: Weather? {
        weatherResponse?.weather.first
    }

    //UI computed properties
    var weatherDescription: String {
        currentWeather?.description ?? "No Data"
    }
    var weatherCondition: String {
        currentWeather?.main ?? "No Data"
    }
    var temperatureText : String {
        formattedTemperature(weatherResponse?.main.temp)
    }

    var feelsLikeText : String {
        formattedTemperature(weatherResponse?.main.feels_like)
    }
    var minTemperatureText: String {
        formattedTemperature(weatherResponse?.main.temp_min)
    }
    var maxTemperatureText: String {
        formattedTemperature(weatherResponse?.main.temp_max)
    } 

Enter fullscreen mode Exit fullscreen mode

We use this function to format the temperature and send it to the view; in this way, the view does not have to do anything but display the data.

private func formattedTemperature(_ value: Double?) -> String {
        guard let value else {
            return "No Data"
        }

        return String(format: "%.2f°C", value)
    }
Enter fullscreen mode Exit fullscreen mode

the last function is the fetchWeather, this one receives a city name as String and invokes the function from the weatherService, this function also controls the different states such as isLoading, errorMessage and weatherResponse.

func fetchWeather(city : String) async {

        isLoading = true
        errorMessage = nil

        defer {
            isLoading = false
        }
        do{
           weatherResponse = try await weatherService.fetchWeather(city: city)
        } catch {
            errorMessage = error
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Building the View

Finally, we can build the View.

We create the @State var for getting the user input, As the user types, SwiftUI automatically updates the city state.

We create the weatherService object in the constructor, also the weather ViewModel is created inside the constructor because it needs the reference to the service object.

    //
//  ContentView.swift
//  WeatherApp
//
//  Created by Gerald on 23/6/26.
//

import SwiftData
import SwiftUI

struct ContentView: View {
    @State private var city = ""
    let service : WeatherService
    @StateObject private var weatherVM : WeatherViewModel

    init(service: WeatherService = WeatherService()) {
        self.service = service
        self._weatherVM = StateObject(wrappedValue: WeatherViewModel(weatherService: service))
    }

    var body: some View {
        VStack(spacing: 24) {

            HStack {
                TextField("Enter city", text: $city)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        fetchWeather()
                    }

                Button("Search") {
                    fetchWeather()
                }
                .buttonStyle(.borderedProminent)
            }

            if weatherVM.temperatureText != "No Data" {
                if weatherVM.isLoading {
                    ProgressView()
                }

                VStack(spacing: 16) {

                    Text(weatherVM.weatherCondition)
                        .font(.largeTitle)
                        .fontWeight(.bold)

                    Text(weatherVM.weatherDescription.capitalized)
                        .font(.title3)
                        .foregroundStyle(.secondary)

                    Text(weatherVM.temperatureText)
                        .font(.system(size: 72, weight: .thin))

                    HStack(spacing: 40) {

                        WeatherInfoView(
                            title: "Feels Like",
                            value: weatherVM.feelsLikeText
                        )

                        WeatherInfoView(
                            title: "Min",
                            value: weatherVM.minTemperatureText
                        )

                        WeatherInfoView(
                            title: "Max",
                            value: weatherVM.maxTemperatureText
                        )
                    }
                }
                .padding()
            } else {
                ContentUnavailableView(     
                    "No Weather Data",
                    systemImage: "cloud.sun",
                    description: Text("Search for a city to see the weather")
                )
            }

            Spacer()
        }.alert(
            "Error",
            isPresented: .constant(weatherVM.errorMessage != nil)
        ) {
            Button("OK") {
                weatherVM.errorMessage = nil
            }
        } message: {
            Text(weatherVM.errorMessage?.localizedDescription ?? "")
        }
        .padding()
    }

    private func fetchWeather() {
        Task {
            await weatherVM.fetchWeather(city: city)
        }
    }
}

struct WeatherInfoView: View {
    let title: String
    let value: String

    var body: some View {
        VStack(spacing: 6) {
            Text(title)
                .font(.caption)
                .foregroundStyle(.secondary)

            Text(value)
                .font(.headline)
        }
    }
}

#Preview {
    ContentView()
}

Enter fullscreen mode Exit fullscreen mode

In this tutorial we built a weather application using SwiftUI and the MVVM architectural pattern.
Along the way we learned how to:

  • Separate presentation logic from networking.

  • Use dependency injection through protocols.

  • Consume a REST API using async/await.

  • Manage loading and error states.

  • Keep the View focused only on displaying data.

Although this is a small application, these same architectural principles can be applied to much larger iOS projects.

Source Code

The complete source code for this project is available on GitHub:

https://github.com/GeraldCO/weather-app-swift

Top comments (0)