ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI MVVM 모델
    SwiftUI/MVVM 알아보기 2020. 8. 27. 04:45

    이전 블러그에서 스트리보드를 이용한 MVC 모델에 대해서 살펴 보았다. MVC 모델은 ViewController 에 대부분의 코드가 위치하게 되고 UI 에 대한 액션도 ViewController에서 처리하고 데이터 모델을 직접 뷰에 설정해야 했다. 

     

    SwiftUI 에서는 뷰 모델은 모델 데이터가 변경된 경우 이를 뷰에 통지한다. 뷰는 변경사항에 대한 통지를 받은 경우 뷰를 다시 그리게 된다. 

    이는 Obsever 패턴과 바인딩을 이용해 구현되어 있다. 

     

    이제 실제 어떻게 MVVM을 이용해 날씨 앱을 변경할 수 있는지를 살펴 보자 .

     

    ContentView.swift

    import SwiftUI
    
    struct ContentView: View {
        // ViewModel의 통지를 받을 수 있도록 @ObservedObject로 설정
        @ObservedObject var viewModel = WeatherViewModel()
        
        //TextField에 바인딩 - TextField 의 text 값을 얻는 데 사용됨
        @State private var searchTerm = ""
        
        //뷰가 초기화 시 기본 값으로 서울의 날씨 정보를 얻어옴
        init() {
            viewModel.fetchWeatherData(city: "Seoul")
        }
        
        var body: some View {
            return ZStack {
                //Image 가 디스플레이 영역을 넘어가는 경우 전체 Frame의 크기가 Image의 크기로 설정되는데
                // 이를 막기 위해  GeometryReader를 이용해 ImageView의 부모 뷰의 영역을 이용해
                // width를 설정
                GeometryReader { bounds in
                    Image(uiImage: #imageLiteral(resourceName: "background"))
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: bounds.size.width)
                        .ignoresSafeArea()
                }
                
                VStack(alignment: .center) {
                    //SearchBar 영역
                    HStack(spacing: 16.0) {
                        //나중에 해당 이미지를 버튼으로 활용 CLLocation을 이용해 현재 위치에 대한 날씨 정보를 얻기위해
                        //미리 영역 설정 - 현재 구현은 하지 않음
                        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)
                            )
                        //도시 값을 얻기위한 텍스트 필드로  입력이 완료되면 입력된 값을 통해 날씨 정보 요청
                        TextField("Enter a city name", text: $searchTerm, onCommit:  {
                            viewModel.fetchWeatherData(city: searchTerm)
                            searchTerm = ""
                        })
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .keyboardType(.default)
                            .shadow(color: Color.black.opacity(0.8), radius: 10, x: 10, y: 10)
                        //구현되지 않음
                        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()
                    
                    //날씨 위젯
                    HStack {
                        //날씨 위젯을 오른쪽으로 정렬하기 위해 스페이서 설정
                        Spacer()
                        VStack {
                            //날씨 이미지
                            Image(systemName: viewModel.imageName)
                                .resizable()
                                .aspectRatio(contentMode: .fit)
                                .frame(width: 80, height: 80)
                            //온도
                            Text(viewModel.temperature)
                                .font(.title).bold()
                            //도시
                            Text(viewModel.cityName)
                                .font(.title).bold()
                        }.foregroundColor(.white)
                        .shadow(color: Color.black, radius: 10, x: 10, y: 10)
                        .padding(.top, 39.0)
                        .padding(.trailing, 34.0)
                        
                    }
                    // 위젯을 위로 올리기 위해 스페이서 설정
                    Spacer()
                }
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    

    WeatherViewModel.swift

    //
    //  WeatherViewModel.swift
    //  ClimaForSwiftUI
    //
    //  Created by HYEONJUN PARK on 2020/08/27.
    //
    
    import Foundation
    
    import SwiftUI
    
    
    class WeatherViewModel : ObservableObject {
        //관찰가능한 객체로 뷰에서 해당 객체의 변경사항을 통지 받을 수 있음
        //모델이 변경된 경우 통지를 발행하기 위해 @Published 설정
        @Published var model : WeatherData?
        
        //도시 이름 반환
        var cityName: String  {
            return model?.name ?? ""
        }
        
        //온도 스트링 반환
        var temperature : String {
            if let temp = model?.main.temp {
                return String(format: "%0.1f", temp)
            }
            return ""
        }
        //아이콘 이름 반환
        var imageName : String {
            guard let id = model?.weather[0].id else {
                return ""
            }
            return WeatherData.getWeatherImage(id: id)
        }
        
        // 날씨 정보 요청
        func fetchWeatherData(city: String) {
            let url = URL.urlWith(city: city.lowercased())
            guard let safeUrl = url else {
                return
            }
            let resoure = Resource<WeatherData>(url: safeUrl)
            URLRequest.load(resource: resoure) { (result) in
                switch result {
                case .success(let weatherData) :
                    self.model = weatherData
                case .failure(let error) :
                    print(error)
                }
            }
        }
    }
    
    

    나머지 모델 로직은 이전 블로그의 내용과 거의 같다. 변경된 사항은 WeatherData에 확장함수인 getWeatherImage(id: Int) 함수의 반환 값을 UIImage로 반환한 것을 String으로 변경한 것 뿐이다. 변경 사항은 아래와 같다. 

     

    WeatherData.swift 의 변경 사항 

     

    /*
     * Weather 구조체의 id 값에 따른 이미지 정보
     */
    extension WeatherData {
        static func getWeatherImage(id: Int) -> String {
            switch id {
            case 200...232:
                return "cloud.bolt"
            case 300...321:
                return "cloud.drizzle"
            case 500...531:
                return "cloud.rain"
            case 600...622:
                return  "cloud.snow"
            case 701...781:
                return "cloud.fog"
            case 800:
                return  "sun.max"
            case 801...804:
                return "cloud.bolt"
            default:
                return  "wifi.exclamationmark"
            }
        }
    }
    

    WeatherViewModel은 ObservableObject로 fetchWeatherData(city:) 함수가 호출해 model 값이 변경된 경우 이를 해당 Observable 객체의 변경사항을 Observer들에게 알리게 된다. 

    class WeatherViewModel : ObservableObject {
        @Published var model : WeatherData?
        ...
    }

    ContentView viewModel은 Observer로 해당 값이 변경된 경우 해당 값을 사용한 이미지 구조체가 다시 생성되게 되어, 이미지를 다시 갱신하게 된다. 

    struct ContentView: View {
        @ObservedObject var viewModel = WeatherViewModel()
        ....
        Image(systemName: viewModel.imageName)
    }

     

    이전 MVC 모델이 fetchWeatherData(city:) 함수가 호출되고 해당 데이터가 변경 될 때 마다 직접 뷰에 이미지를 할당해야 했지만, 이제 데이터가 변경되면 자동으로 이를 수행하게된다. 또한 텍스트 필드의 입력이 완료된 액션을 뷰에서 처리하기 때문에 TextField를 위한 delegate을 설정하거나 할 필요 없이 해당 액션에 대한 요청을 viewModel에 전달하게된다. 이는 뷰에 대한 모델 데이터의 의존성을 제거 해 단위 테스트가 쉽고, 해당 뷰에 대한 재사용성 또한 증가한다. 

    TextField("Enter a city name", text: $searchTerm, onCommit:  {
      viewModel.fetchWeatherData(city: searchTerm)
      searchTerm = ""
    })

     

     

     

    해당 앱의 구조는 위의 다이어그램과 같다. 

    댓글

Designed by Tistory.