SwiftUI/MVVM 알아보기

SwiftUI 에서 CoreLocation 사용

basker 2020. 8. 27. 15:49

이 전 블러그에서 텍스트 필드에서 입력받은 도시의 날씨를 표현하는 앱을 구현하였다. 이미지 location.fill 위치에 버튼을 추가하고 CoreLocation을 이용해 현재 좌표에 대한 날씨를 조회하도록 기능을 추가해 보자. 

이 보다 먼저 ContentView의 body에서 상단 바와 날씨 정보를 보여주는 위젯을 Extract Subview를 이용해 두 영역을 하위 뷰로 분리한다. 

위의 그림처럼 분리하고자 하는 VStack에 커서를 두고 ⌘ 누르고 마우스의 왼쪽키를 클릭하면 메뉴바가 나오는데 여기서 Extract Subview를 통해서 쉽게 분리할 수 있다. 뷰는 아래와 같은 구조를 가진다. 

struct ContentView: View {
	...
    var body: some View {
        ZStack {
        	...
            }
            VStack {
                //SearchBar 영역
                WeatherSearchBar(viewModel: viewModel)
                //날씨 위젯
                if viewModel.isLoading == false {
                    WeatherWidget(viewModel: viewModel)
                }
                // 위젯을 위로 올리기 위해 스페이서 설정
                Spacer()
            }
        }
    }
}

struct WeatherWidget: View {
    @ObservedObject var viewModel : WeatherViewModel
    var body: some View {
      ...
    }
}

struct WeatherSearchBar: View {
    @ObservedObject var viewModel : WeatherViewModel
    @State private var searchTerm = ""
    var body: some View {
 		...
    }
}

 

LocationService 클래스

CoreLocation 프레임워크를 사용해 현재 위치를 가져오기 위해서는 info.plist에 'Privacy - Location When In Use Usage Description'

를 추가해 해당 앱이 위치정보를 요청할 경우 사용자에게 이를 승인받기 위한 문구를 추가한다. 

 

LocationService는 CLLocationManager를 생성하고 해당 객체의 delegate 프로토콜을 구현해 CLLocationManager의 delegate로 설정한다. 로케이션 매니저에 위치정보를 요청한 경우 locationManager(_manager:didUpdateLocations locations:) delegate 함수를 통해서 위치 정보를 가져올 수 있다. LocationService는 LocationServiceDelegate를 통해서 이를 다른 클래스에 전달하는 대신 위치 정보 요청 시 completionHandler를 등록해 이를 통해 위치 정보를 전달하도록 구현해 구현을 간결하게 한다. 

이를 아래와 같이 구현할 수 있다. 

 

LocationService.swift

import Foundation
import CoreLocation
class LocationService : NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var completionHandler: ((CLLocationCoordinate2D) -> (Void))?
    
    override init() {
        super.init()
        //CLLocationManager의 delegate 설정
        manager.delegate = self
        //manager.desiredAccuracy = kCLLocationAccuracyBest
        //위치 정보 승인 요청
        manager.requestWhenInUseAuthorization()
    }
    
    //위치 정보 요청 - 정보 요청이 성공하면 실행될 completionHandler를 등록
    func requestLocation(completion: @escaping ((CLLocationCoordinate2D) -> (Void))) {
        completionHandler = completion
        manager.requestLocation()
        
    }
    //위치 정보는 주기적으로 업데이트 되므로 이를 중단하기 위한 함수
    func stopUpdatingLocation() {
        manager.stopUpdatingHeading()
    }
    
    //위치 정보가 업데이트 될 때 호출되는 delegate 함수
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else {
            return
        }
        //requestLocation 에서 등록한 completion handler를 통해 위치 정보를 전달
        if let completion = self.completionHandler {
            completion(location.coordinate)
        }
        //위치 정보 업데이트 중단
        manager.stopUpdatingLocation()
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print(error)
    }
}

URL+Extensions.swift 에 위치 좌표 값을 통해 현재 날씨 정보를 로드하기 위한 REST API를 아래와 같이 추가한다.

import Foundation
import CoreLocation
let appid = ""
let weatherURL = "https://api.openweathermap.org/data/2.5/weather?appid=\(appid)&units=metric"
extension URL {
    //도시 이름을 통해 날씨 정보를 가져오는  GET Rest API
   static func urlWith(city: String) -> URL? {
        return URL(string: "\(weatherURL)&q=\(city)")
    }
    //위치 값을 통해 날씨 정보를 가져오는  GET Rest API
    static func urlWith(coordinate : CLLocationCoordinate2D) -> URL? {
        let urlString = "\(weatherURL)&lat=\(coordinate.latitude)&lon=\(coordinate.longitude)"
        print(urlString)
        return URL(string: urlString)
    }
    
}

LocationService를 통해 WeatherViewModel은 현재 위치에 대한 정보를 가져오는 함수 fetchWeatherDataWithCurrentLocation() 함수는 아래와 같다. 

WeatherViewModel.swift

    func fetchWeatherDataWithCurrentLocation() {
        //locationService의 requestLocation 호출 
        locationService.requestLocation { coordinate in
            //좌표 값을 수신한 경우 fetchWeatherData를 통해서 api 호출
            self.fetchWeatherData(coordinate: coordinate)
        }
    }
    
    private func fetchWeatherData(coordinate: CLLocationCoordinate2D) {
        let url = URL.urlWith(coordinate: coordinate)
        guard let safeUrl = url else {
            return
        }
        load(resource: Resource<WeatherData>(url: safeUrl))
    }
    
    private func load(resource: Resource<WeatherData>) {
        isLoading = true
        URLRequest.load(resource: resource) { (result) in
            switch result {
            case .success(let weatherData) :
                self.model = weatherData
            case .failure(let error) :
                print(error)
            }
            self.isLoading = false
        }
    }

이제 뷰에서 초기화 시 뷰 모델의 fetechWeatherDataWithCurrentLocation() 함수를 호출해 현재 위치의 날씨를 표현 하도록 하고 location 아이콘을 클릭 시 현재 위치의 날씨를 보여주도록 아래와 같이 추가해준다. 

 

struct ContentView: View {
    ...
    init() {
        print("content view init")
        viewModel = WeatherViewModel()
        viewModel.fetchWeatherDataWithCurrentLocation()
    }
}


struct WeatherSearchBar: View {
    @ObservedObject var viewModel : WeatherViewModel
    //TextField에 바인딩 - TextField 의 text 값을 얻는 데 사용됨
    @State private var searchTerm = ""
    var body: some View {
        VStack {
            HStack(spacing: 16.0) {
                //현재 위치에 대한 날씨 정보 요청 버튼
                Button (action: {
                    viewModel.fetchWeatherDataWithCurrentLocation()
                }, label: {
                    Image(systemName: "location.fill")
                        .font(.headline)
                        .frame(width: 30, height: 30)
                        .foregroundColor(.white)
                        .background(
                            Circle()
                                .stroke(Color.white,  lineWidth: 3.0)
                                .shadow(color: Color.black.opacity(0.8), radius: 10, x: 10, y: 10)
                        )
                })
                
        
                ... 
                //텍스트 필드 에 입력된 도시이름으로 날씨 정보 요청 
                Button(action: {
                    viewModel.fetchWeatherData(city: searchTerm)
                }, label: {
                    Image(systemName: "magnifyingglass")
                        .font(.headline)
                        .frame(width: 30, height: 30)
                        .foregroundColor(.white)
                        .background(
                            Circle()
                                .stroke(Color.white,  lineWidth: 3.0)
                                .shadow(color: Color.black.opacity(0.8), radius: 10, x: 10, y: 10)
                        )
                })
                
                
            }.padding()
        }
    }
}

    

 

다음 글에서는 Lottie 이미지를 출력하는 LottieView를 구현하고 날씨 정보를 가져오는 동안 로딩화면을 이를 통해 표현할 수 있도록 추가해 본다.