VesselWheel

위치 정보와 날씨 정보 데이터를 받아 UI에 표현하기(feat. CoreLocation, OpenWeatherMap API) 본문

Xcode Study

위치 정보와 날씨 정보 데이터를 받아 UI에 표현하기(feat. CoreLocation, OpenWeatherMap API)

JasonYang 2024. 2. 7. 09:58

참고글: https://vesselwheel.tistory.com/178

 

정해진 지역의 날씨를 호출하여 날씨 정보 표시하기(feat. OpenWeatherMap API)

MVC 패턴으로 만든 OpenWeatherMap API의 특정지역(서울)의 날씨정보 호출 및 출력 1. OpenWeatherMap에서 가져온 데이터로 구조체 만들기 // // WeatherModel.swift // Weather777 // // Created by Jason Yang on 2/5/24. // import

vesselwheel.tistory.com

위치정보와 날씨정보의 데이터를 받아 UI에 표한하기에 앞서, 상기 첨부된 정해진 지역의 날씨를 호출하여 날씨정보를 표시해보았다.

MVVM 디자인 패턴에서

1. 모델 : WeatherData

2. 뷰 모델 WeatherManager

3. 뷰 ViewController

위 3가지를 이용하여,

WeatherManager에서 호출한 서울의 OpenWeatherMap API에서 받은 날씨정보를, View에서 싱글톤 패턴으로 받아DispatchQueue.main을 활용하여 비동기로 메인스레드에서 동작하여, 사용자의 인터페이스(UI)를 업데이트 하였다. 


지금부터는 서울지역이라는 정해진 지역에서의 위치가 아닌 위경도를 활용하여 사용자가 원하는 위치의 날씨정보를 받아 UI를 업데이트하고자 한다. 

위에서 언급한, MVVM 패턴에서 2. 뷰 모델에 LocationManager가 추가 된다. 

1. Model : WeatherData(변경없음)

//
//  WeatherModel.swift
//  Weather777
//
//  Created by Jason Yang on 2/5/24.
//

import Foundation

// MARK: - WeatherData
struct WeatherData: Codable {
    let coord: Coord
    let weather: [Weather]
    let base: String
    let main: Main
    let visibility: Int
    let wind: Wind
    let clouds: Clouds
    let dt: Int
    let sys: Sys
    let timezone, id: Int
    let name: String
    let cod: Int
}

// MARK: - Clouds
struct Clouds: Codable {
    let all: Int
}

// MARK: - Coord
struct Coord: Codable {
    let lon, lat: Double
}

// MARK: - Main
struct Main: Codable {
    let temp, feelsLike, tempMin, tempMax: Double
    let pressure, humidity: Int

    enum CodingKeys: String, CodingKey {
        case temp
        case feelsLike = "feels_like"
        case tempMin = "temp_min"
        case tempMax = "temp_max"
        case pressure, humidity
    }
}

// MARK: - Sys
struct Sys: Codable {
    let type, id: Int
    let country: String
    let sunrise, sunset: Int
}

// MARK: - Weather
struct Weather: Codable {
    let id: Int
    let main, description, icon: String
}

// MARK: - Wind
struct Wind: Codable {
    let speed: Double
    let deg: Int
}

 

2. CoreLocation 프레임워크를 활용하여 현재 위치정보를 처리하는 LocationManager(추가)

//
//  LocationManager.swift
//  Weather777
//
//  Created by Jason Yang on 2/7/24.
//

import Foundation
import CoreLocation

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
    // 싱글톤 정적상수 활용
    static let shared = LocationManager()
    
    // Create a location manager. 초기화
    private let locationManager = CLLocationManager()
    
    // 위,경도 정보를 저장할 변수
    @Published var currentLocation: CLLocationCoordinate2D?
    
    // callback 위치가 업데이트되었을 때 자동 호출
    var onLocationUpdate: ((CLLocationCoordinate2D) -> Void)?
    
    override init() {
        super.init()
        
        // Configure the location manager.
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }
    // 현재 위치를 요청하는 메소드
    public func requestLocation() {
        locationManager.requestWhenInUseAuthorization()
        locationManager.requestLocation()
    }
    
    //MARK: - CLLocationManagerDelegate
    public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        currentLocation = location.coordinate
        onLocationUpdate?(currentLocation!)
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Failed to get location: \(error)")
    }
    
    public func setLocation(latitude: Double, longitude: Double) {
        currentLocation = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    }
    
    
}

해석 

더보기

CoreLocation 프레임워크를 사용하여 iOS 디바이스의 현재 위치를 추적하고 관리하는 클래스인 `LocationManager`를 정의하고 있습니다.

 

1. `class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate`

- LocationManager 클래스는 NSObject를 상속받아 Objective-C와의 호환성을 보장합니다.

- ObservableObject 프로토콜을 준수하여 SwiftUI의 뷰 업데이트 메커니즘에 연동될 수 있습니다.

- CLLocationManagerDelegate 프로토콜을 준수하여 CoreLocation 프레임워크의 콜백 메소드를 구현합니다.

 

2. `static let shared = LocationManager()`

- 싱글톤 패턴을 사용하여 LocationManager 클래스의 단일 인스턴스를 생성하고, 이를 `shared`라는 정적 상수를 통해 앱 어디에서나 접근 가능하게 합니다.

3. `private let locationManager = CLLocationManager()`

- CoreLocation 프레임워크의 CLLocationManager 인스턴스를 생성합니다. 이 인스턴스를 통해 위치 관련 기능을 제공합니다.

4. `@Published var currentLocation: CLLocationCoordinate2D?`

- 현재 위치의 위도와 경도 정보를 저장하는 변수입니다. `@Published` 프로퍼티 래퍼를 사용하여 값이 변경될 때마다 SwiftUI 뷰를 업데이트합니다.

5. `var onLocationUpdate: ((CLLocationCoordinate2D) -> Void)?`

- 위치가 업데이트되었을 때 호출되는 클로저입니다. 이 클로저를 통해 위치 업데이트 이벤트를 다른 곳에서도 처리할 수 있습니다.

6. `locationManager.delegate = self`

- LocationManager 인스턴스 자체를 CLLocationManager의 delegate로 설정합니다.

7. `locationManager.requestWhenInUseAuthorization()`

- 위치 서비스를 사용할 권한을 요청합니다. 이 메소드를 호출하면 사용자에게 위치 서비스를 사용할 것인지 물어보는 알림이 표시됩니다.

8. `locationManager.startUpdatingLocation()`

- 위치 업데이트를 시작합니다. 이 메소드를 호출하면 디바이스의 현재 위치를 계속 추적하게 됩니다.

9. `locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation])`

- 위치 정보가 업데이트될 때마다 호출되는 delegate 메소드입니다. 이 메소드에서는 `currentLocation` 변수를 업데이트하고 `onLocationUpdate` 클로저를 호출합니다.

10. `locationManager(_ manager: CLLocationManager, didFailWithError error: Error)`

- 위치 정보를 가져오는 데 실패했을 때 호출되는 delegate 메소드입니다. 이 메소드에서는 오류 메시지를 출력합니다.

11. `setLocation(latitude: Double, longitude: Double)`

- `currentLocation`의 값을 수동으로 설정하는 메소드입니다. 이 메소드를 호출하면 `currentLocation`의 값이 주어진 위도와 경도로 변경됩니다.

 

3. WeatherManager에서 사용자의 위경도 위치정보를 받아 날씨정보를 호출

//
//  WeatherManager.swift
//  Weather777
//
//  Created by Jason Yang on 2/5/24.
//

import Foundation

// 에러 정의
enum NetworkError: Error {
    case badUrl
    case noData
    case decodingError
    case badLocation
}

class WeatherManager {
    // 싱글톤 정적상수 활용
    static let shared = WeatherManager()
    
    private init() {}
    
    // MARK: - public Methods
    //LocationManager에서 위치정보를 받고, 위경도 API에 적용한 후, 날씨 데이터 값 복사 : 독립적인 인스턴스 생성
    public func getLocationWeather(completion: @escaping(Result<WeatherData, NetworkError>) -> Void) {
        guard let currentLocation = LocationManager.shared.currentLocation else {
            return completion(.failure(.badLocation))
        }
        
        let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?lat=\(currentLocation.latitude)&lon=\(currentLocation.longitude)&appid=\(apiKey)")
        guard let url = url else {
            return completion(.failure(.badUrl))
        }
        performRequest(with: url, completion: completion)
    }
    
    
    // 위경도 기준 OpenWeatherMap의 API 요청시 날씨정보를 처리하는 메소드
    private func performRequest(with url: URL?, completion: @escaping (Result<WeatherData, NetworkError>) -> Void) {
        guard let url = url else {
            return completion(.failure(.badUrl))
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data, error == nil else {
                return completion(.failure(.noData))
            }
            let weatherData = try? JSONDecoder().decode(WeatherData.self, from: data)
            if let weatherData = weatherData {
                completion(.success(weatherData))
            } else {
                completion(.failure(.decodingError))
            }
        }.resume()
    }
    
    
    
    
}

// MARK: - Extensions
extension WeatherManager {
    //plist를 통해 숨긴 API 호출을 위한 프로퍼티
    private var apiKey: String {
        get {
            // 생성한 .plist 파일 경로 불러오기
            guard let filePath = Bundle.main.path(forResource: "WeatherKey", ofType: "plist") else {
                fatalError("Couldn't find file 'WeatherKey.plist'.")
            }
            
            // .plist를 딕셔너리로 받아오기
            let plist = NSDictionary(contentsOfFile: filePath)
            
            // 딕셔너리에서 값 찾기
            guard let value = plist?.object(forKey: "OPENWEATHERMAP_KEY") as? String else {
                fatalError("Couldn't find key 'OPENWEATHERMAP_KEY' in 'KeyList.plist'.")
            }
            return value
        }
    }
}

해석

더보기

해당 코드는 날씨 정보를 가져오는 `WeatherManager` 클래스를 정의하는 코드입니다.

1. `enum NetworkError: Error`

- 네트워크 요청 중 발생할 수 있는 에러들을 나타내는 Enum입니다.

badUrl은 잘못된 URL에 대한 에러,

noData는 데이터를 받아오지 못했을 때의 에러,

decodingError는 데이터 디코딩 실패에 대한 에러,

badLocation은 잘못된 위치 정보에 대한 에러를 의미합니다.

 

2. `static let shared = WeatherManager()`

- 싱글톤 인스턴스를 생성합니다. 앱 내에서 하나의 `WeatherManager` 인스턴스만 공유하여 사용합니다.

 

3. `getLocationWeather(completion: @escaping(Result<WeatherData, NetworkError>) -> Void)`

- LocationManager에서 현재 위치를 받아와서 OpenWeatherMap API에 요청을 보내고, 그 결과를 받아와서 WeatherData 형식으로 디코딩하는 메서드입니다.

 

4. `performRequest(with url: URL?, completion: @escaping (Result<WeatherData, NetworkError>) -> Void)`

- 주어진 URL로 네트워크 요청을 수행하고, 결과를 받아와서 WeatherData 형식으로 디코딩하는 메서드입니다.

5. `apiKey`

- OpenWeatherMap API 요청을 위한 API 키를 가져오는 프로퍼티입니다. "WeatherKey.plist" 파일에서 "OPENWEATHERMAP_KEY" 값을 가져옵니다. 따라서 이 코드는 사용자의 현재 위치를 기반으로 OpenWeatherMap API를 통해 날씨 정보를 가져와서 앱에서 사용할 수 있도록 하는 역할을 합니다.

 

더보기

`performRequest` 메서드는 주어진 URL을 기반으로 네트워크 요청을 수행하는 비동기 함수입니다.

이 함수는 완료 핸들러(`completion`)를 통해 `WeatherData` 형식의 결과를 전달합니다. 이 함수 내에서 사용되는 매개변수와 변수에 대한 설명은 다음과 같습니다:

- `url`: 네트워크 요청을 보낼 URL입니다.

- `completion`: 비동기 작업이 완료되었을 때 호출되는 클로저입니다. 클로저의 매개변수로는 `Result<WeatherData, NetworkError>` 형식이 사용됩니다.

 

함수 내에서 수행되는 작업에 대한 설명은 다음과 같습니다:

1. `url`이 `nil`인지 확인하고, `nil`이면 `.badUrl`과 함께 실패(`.failure`) 상태로 `completion` 클로저를 호출합니다.

2. `URLSession.shared.dataTask(with:completionHandler:)` 메서드를 사용하여 네트워크 요청을 시작합니다. 이 메서드는 주어진 URL로부터 데이터를 가져오는 비동기 작업을 수행합니다.

3. 네트워크 요청이 완료되면, `data`, `response`, `error`의 값이 제공됩니다. 각각은 다음과 같은 의미를 가집니다:

- `data`: 서버로부터 수신된 데이터입니다. 이 데이터는 요청에 성공한 경우에만 제공됩니다.

- `response`: 서버의 응답입니��. 이 응답은 HTTP 상태 코드, 헤더 및 기타 정보를 포함합니다.

- `error`: 요청 중에 발생한 오류입니다. 예를 들어, 네트워크 연결 오류나 서버로부터의 잘못된 응답이 있을 수 있습니다.

 

4. `data`가 data가 아니고, `error`가 `nil`이 아닌 경우, `.noData`와 함께 실패(`.failure`) 상태로 `completion` 클로저를 호출합니다.

 

5. `data`가 유효한 경우, `JSONDecoder`를 사용하여 `WeatherData` 형식으로 디코딩합니다.

 

6. 디코딩이 성공하면, `weatherData`를 `.success` 상태로 `completion` 클로저를 호출하여 성공 결과를 전달합니다.

 

7. 디코딩이 실패하면, `.decodingError`와 함께 실패(`.failure`) 상태로 `completion` 클로저를 호출합니다.

 

8. `.resume()` 메서드를 호출하여 네트워크 요청을 시작합니다. 이렇게 설명하면 되겠습니까? 추가로 궁금한 내용이 있으시면 언제든지 알려주세요!

 

4. WeatherData와 WeatherManager, LocationManager를 활용하여 View에서 호출하기

//
//  ViewController.swift
//  Weather777
//
//  Created by Jason Yang on 2/5/24.
//

import UIKit
import SwiftUI


class ViewController: UIViewController {
    
    var weather: Weather?
    var main: Main?

    
    // MARK: - UI Properties
    let testLabel: UILabel = {
        let label = UILabel()
        label.text = "7팀 화이팅입니다.😃"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    var weatherImage: UIImageView!
    var tempLabel: UILabel!
    var maxTempLabel: UILabel!
    var minTempLabel: UILabel!

    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setUI()
        setAddSubView()
        setLayout()
       
        //LocationManager가 위치정보 업데이트 -> WeatherManager가 날씨정보 업데이트 -> 초기화한 weather, main DTO에서 날씨정보를 가져와 비동기로 UI를 업데이트
        LocationManager.shared.onLocationUpdate = { [weak self] location in
            WeatherManager.shared.getLocationWeather { result in
                switch result {
                case .success(let weatherResponse):
                    DispatchQueue.main.async {
                        self?.weather = weatherResponse.weather.first
                        self?.main = weatherResponse.main
                        self?.updateWeather()
                    }
                case .failure(_ ):
                    print("error")
                }
            }
        }
        
        // 위치정보를 가져오는 시간이 걸리더라도 날씨정보를 우선 업데이트해서 UI를 변경하여 viewDidLoad()에서 즉시 반환
        LocationManager.shared.requestLocation()
    }
    
}


extension ViewController {
    func setUI() {
        // 배경색 지정
        view.backgroundColor = .white
        
        weatherImage = UIImageView()
        weatherImage.translatesAutoresizingMaskIntoConstraints = false
        
        tempLabel = UILabel()
        tempLabel.translatesAutoresizingMaskIntoConstraints = false
        
        maxTempLabel = UILabel()
        maxTempLabel.translatesAutoresizingMaskIntoConstraints = false
        
        minTempLabel = UILabel()
        minTempLabel.translatesAutoresizingMaskIntoConstraints = false
        
    }
    
    func setAddSubView() {
        view.addSubview(testLabel)
        view.addSubview(weatherImage)
        view.addSubview(tempLabel)
        view.addSubview(maxTempLabel)
        view.addSubview(minTempLabel)
    }
    
    func setLayout() {
        NSLayoutConstraint.activate([
            testLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            testLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            
            // weatherImage의 제약 조건 설정
            weatherImage.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            weatherImage.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 100),
            weatherImage.widthAnchor.constraint(equalToConstant: 100),
            weatherImage.heightAnchor.constraint(equalToConstant: 100),
            
            // tempLabel의 제약 조건 설정
            tempLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            tempLabel.topAnchor.constraint(equalTo: weatherImage.bottomAnchor, constant: 20),
            
            // maxTempLabel의 제약 조건 설정
            maxTempLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            maxTempLabel.topAnchor.constraint(equalTo: tempLabel.bottomAnchor, constant: 20),
            
            // minTempLabel의 제약 조건 설정
            minTempLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            minTempLabel.topAnchor.constraint(equalTo: maxTempLabel.bottomAnchor, constant: 20)
        ])
    }
    
    //WeatherData의 데이터를 활용하여 UI에 적용하기
    private func updateWeather() {
        guard let icon = self.weather?.icon else {
            return
        }
        
        let urlString = "https://openweathermap.org/img/wn/\(icon)@2x.png"
        guard let url = URL(string: urlString) else {
            return
        }
        
        let session = URLSession.shared
        let task = session.dataTask(with: url) { (data, response, error) in
            guard let data = data, error == nil else {
                return
            }
            
            DispatchQueue.main.async {
                self.weatherImage.image = UIImage(data: data)
            }
        }
        task.resume()
        
        if let main = self.main {
            let tempCelsius = main.temp
            let tempMaxCelsius = main.tempMax
            let tempMinCelsius = main.tempMin
            
            DispatchQueue.main.async {
                if let celsiustempLabel = self.convertFahrenheitToCelsius(tempCelsius),
                   let celsiusmaxTempLabel = self.convertFahrenheitToCelsius(tempMaxCelsius),
                   let celsiusminTempLabel = self.convertFahrenheitToCelsius(tempMinCelsius) {
                    // Call to method 'convertFahrenheitToCelsius' in closure requires explicit use of 'self' to make capture semantics explicit
                    
                    self.tempLabel.text = "\(celsiustempLabel)"
                    self.maxTempLabel.text = "\(celsiusmaxTempLabel)"
                    self.minTempLabel.text = "\(celsiusminTempLabel)"
                }
            }
        }
    }
}

// 화씨를 섭씨로 전환하는 매소드
extension ViewController {
    func convertFahrenheitToCelsius(_ fahrenheit: Double) -> String? {
        let celsiusUnit = UnitTemperature.celsius
        let formatter = NumberFormatter()
        formatter.maximumFractionDigits = 1
        let celsius = celsiusUnit.converter.value(fromBaseUnitValue: fahrenheit)
        if let formattedCelsius = formatter.string(from: celsius as NSNumber) {
            return "\(formattedCelsius)°C"
        }
        return ""
    }
}

해석

더보기

이 코드는 iOS 앱의 메인 뷰 컨트롤러인 `ViewController`를 정의하고 있습니다.

1. `class ViewController: UIViewController` - `UIViewController`를 상속받은 `ViewController` 클래스입니다.

 

2. `var weather: Weather?`와 `var main: Main?` - `Weather`와 `Main` 형식의 변수들로, 날씨 정보를 저장합니다.

 

3. `override func viewDidLoad()` - 뷰 컨트롤러의 뷰가 메모리에 로드된 후 호출되는 메소드입니다. 이 메소드 안에서 UI 설정을 하고, 위치 정보를 요청하며, 날씨 정보를 업데이트하는 동작을 수행합니다.

 

4. `setUI()`, `setAddSubView()`,setLayout()`

- UI를 설정하는 메소드들입니다. `setUI`에서는 뷰를 생성하고, `setAddSubView`에서는 뷰를 추가하며, `setLayout`에서는 뷰의 레이아웃을 설정합니다.

 

5. `updateWeather()`

- 날씨 정보를 업데이트하는 메소드입니다. `weather`와 `main`의 정보를 바탕으로 UI를 업데이트합니다.

 

6. `convertFahrenheitToCelsius(_ fahrenheit: Double) -> String?`

- 화씨온도를 섭씨온도로 변환하는 메소드입니다. 이 메소드를 통해 온도 정보를 사용자가 이해하기 쉬운 섭씨로 표시합니다.

이 클래스는 사용자의 현재 위치를 기반으로 날씨 정보를 가져와서 UI에 표시하는 역할을 합니다. `LocationManager`와 `WeatherManager`를 이용하여 위치 정보를 가져오고, 날씨 정보를 요청합니다. 가져온 날씨 정보는 `updateWeather` 메소드를 통해 UI에 반영됩니다.