Skip to content

Latest commit

 

History

History
531 lines (405 loc) · 31.1 KB

Ch13.Intermediate RxCocoa.md

File metadata and controls

531 lines (405 loc) · 31.1 KB

Ch.13 Intermediate RxCocoa

A. 시작하기

  • 여기서는 기존의 UIKit 구성요소들을 커스텀 래핑하는 방법에 대해 확인해본다.
  • 이 장에서는 RxSwift 아키텍처에 대한 내용과 RxSwift/RxCocoa에 맞는 최적의 아키텍처에 대한 내용은 다루지 않는다. 관련 내용은 Ch.23 MVVM with RxSwift에 대해 다루게 될 것이다.
  • 이전 장에서와 마찬가지로 OpenWeatehrMap의 API 키를 복붙한다.

B. 검색할 동안 activity indicator 표시하기

  • 지금까지 구성한 앱은 요청한 도시에 대해 날씨 정보를 표시해주고 있다. 하지만 Search검색 버튼을 눌렀을 때 아무런 피드백이 없다. 앱이 네트워크 리퀘스트를 만드느라 바쁠 동안, activity indicator를 띄워주는 것은 좋은 연습과제가 될 수 있다.

  • 도시를 입력하고 Search 버튼을 누르면, 다음 그림과 같은 로직이 이뤄져야한다.

    • 이와 같은 로직이 이뤄지게 하려면, 코드 변경이 필요하다. 유저가 버튼을 눌렀을 때와 서버로부터 데이터가 도착했을 때를 인식해야 한다.
  • ViewController.swiftviewDidLoad()style() 호출 아래에 다음과 같은 코드를 추가하자.

     let searchInput = searchCityName.rx.controlEvent(.editingDidEndOnExit).asObservable()
             .map { self.searchCityName.text }
             .filter { ($0 ?? "").count > 0 }
    • searchInputobservable은 입력한 값이 빈 String이 아닌 상태로 Search 버튼을 눌렀을 때, String을 제공할 것이다.
  • 이제 serachInput observable의 사용을 위해 기존의 search observable을 아래와 같이 수정하자

     let search = searchInput.flatMap { text in
             return ApiController.shared.currentWeather(city: text ?? "Error")
                 .catchErrorJustReturn(ApiController.Weather.dummy)
         }
             .asDriver(onErrorJustReturn: ApiController.Weather.dummy)
    • 이제 앱이 API에 리퀘스트를 만드느라 바쁠 때를 나타낼 두 개의 observable이 만들어졌다.
  • 이들을 통해 UIActivityIndicatorViewisAnimating 객체에 두개의 객체를 바인딩 하고, isHidden 객체가 있는 모든 에 UILabel에도 바인딩 하는 것이다. 이 방법은 충분히 편리해보이지만 Rx에는 좀 더 나은 방법이 있다.

  • searchInputsearch라는 두 observable은 이벤트 수신 여부에 따라 true 또는 false로 구분될 수 있고, 만약 수신했을 경우에는 하나의 observable로 합쳐질 수 있다. 즉, 앱이 서버로부터 데이터를 수신했는지 여부에 따라서 구분할 수 있다는 것이다. 아래의 코드를 추가해보자.

     let running = Observable.from([
             searchInput.map { _ in true },
             search.map { _ in false }.asObservable()
             ])
             .merge()
             .startWith(true)
             .asDriver(onErrorJustReturn: false)
    • 이 조합은 다음 그림과 같은 결과를 나타낸다.
    • .asObservable()호출은 Swift의 타입추론을 위해 필요하다. 이후 두개의 observable을 합칠 수 있다.
    • .startWith(true)는 앱이 시작할 때 모든 label을 수동적으로 숨길 필요가 없게 해주는 아주 편리한 호출이다.
  • 여기서 바인딩은 아주 간단히 생성될 수 있다. 아래의 코드를 추가하자

     running
     	.skip(1)
     	.drive(activityIndicator.rx.isAnimating)
     	.disposed(by.bag)
    • 첫 번째 값은 수동적으로 추출된다는 것을 기억해야 한다. 따라서 첫 번째 값은 반드시 skip어야한다. 그렇지 않으면 activity indicaotr가 앱이 시작되자마자 표시될 것이다.
  • 아래의 코드를 통해 현 상황에 따라 라벨의 표시 여부가 달라지게 할 수 있다.

     	running
             .drive(tempLabel.rx.isHidden)
             .disposed(by: bag)
    
         running
             .drive(humidityLabel.rx.isHidden)
             .disposed(by: bag)
    
         running
             .drive(iconLabel.rx.isHidden)
             .disposed(by: bag)
    
         running
             .drive(cityNameLabel.rx.isHidden)
             .disposed(by: bag)
  • 앱을 구동해보면 다음과 같이 도시를 검색하고 값을 가져오기 전까지 activity indicator가 표시되는 것을 확인할 수 있다.

C. CLLocationManager 확장을 통해 현재 위치 확인하기

  • RxCocoa는 UI만을 위한 것이 아니다. 기본 목적은 Apple의 공식 프레임워크들을 래핑하여 간단하고 강력한 방법으로 사용자화하는데 있다.
  • 현재 날씨앱은 현재 위치를 알지 못한다. 따라서 RxCocoa에서 제공하는 일부 구성요소를 수정하여 이 기능을 구현해보자.

1. extension 생성

  • 먼저 CoreLocation 프레임워크를 래핑해야한다. Extensions 폴더 내 CLLocationManager+Rx.swift 파일을 확인해보자. 이 파일 내에 extension이 있다. 모든 extension들은 .rx 키워드 뒤에 명시되어 있다.

  • Pod 프로젝트 내에 Reactive.swift을 확인해보자. 여기서 Reactive<Base>라는 이름의 struct를 확인할 수 있다. 이 것은 ReactiveCompatible 프로토콜이자 ReactiveCompatible의 extension이다. 이 녀석의 마지막 라인은 다음과 같다.

     /// Extend NSObject with `rx` proxy.
     extension NSObject: ReactiveCompatible { }
    • 이는 NSObject를 상속한 모든 클래스가 rx를 받는 방법이다. 따라서 CLLocationManagerrx를 이용해서 다른 클래스가 이를 이용하게 노출시킬 수 있다.
  • RxCocoa 폴더도 확인해보자. 여기서 _RxDelegateProxy.h, _RxDelegateProxy.m라 명명된 Objective-C 파일을 찾을 수 있을 것이다. 이들은 Swift의 DelegateProxy.swift, DelegateProxyType.swift와 같은 역할을 하는 놈들이다. 이들은 데이터 제공을 위한 주요 리소스로 delegate(데이터 소스)를 사용하는 모든 프레임워크들과 RxSwift를 연결해주는 솔루션을 구현한 놈들이다.

    • DelegateProxy 객체는 수신된 모든 데이터를 전용 observable로 표시할 가짜 delegate 객체를 만들어낸다.
    • DelegateProxy조합과 Reactive를 잘 사용한다면 CLLocationManager extension을 다른 RxCocoa extension 처럼 보이게 해줄 것이다.
  • CLLocationManager는 delegate를 필요로 하고, 때문에 필요한 location manager delegate에서 전용 observable 로 모든 데이터를 보내는데 필요한 proxy를 만들어야 한다. 1:1 관계의 매핑이므로 단일 프로토콜 함수는 주어진 데이터를 반환하는 단일 observable에 해당될 것이다.

  • CLLocationManager+Rx.swift 파일에 아래 코드를 추가하자.

     // 1
     extension CLLocationManager: HasDelegate {
         public typealias Delegate = CLLocationManagerDelegate
     }
    
     class RxCLLocationManagerDelegateProxy: DelegateProxy<CLLocationManager, CLLocationManagerDelegate>, DelegateProxyType, CLLocationManagerDelegate {
    
         // 2
         public weak private(set) var locationManager: CLLocationManager?
    
         public init(locationManager: ParentObject) {
             self.locationManager = locationManager
             super.init(parentObject: locationManager, delegateProxy: RxCLLocationManagerDelegateProxy.self)
         }
    
         static func registerKnowImplementations() {
             self.register { RxCLLocationManagerDelegateProxy(locationManager: $0) }
         }
     }
    
     // 3
     extension Reactive where Base: CLLocationManager {
         public var delegate: DelegateProxy<CLLocationManager, CLLocationManagerDelegate> {
             return RxCLLocationManagerDelegateProxy.proxy(for: base)
         }
    
         // 4
         var didUpdateLocations: Observable<[CLLocation]> {
             return delegate.methodInvoked(#selector(CLLocationManagerDelegate.locationManager(_:didUpdateLocations:))).map { parameters in
                     return parameters[1] as! [CLLocation]
             }
         }
     }
      1. RxCLLocationManagerDelegateProxy는 observable이 생성되고 구독이 된 직후 CLLocationManager 인스턴스에 연결하는 proxy가 될 것이다. 이 작업은 HasDelegate 프로토콜에 의해 단순화 됩니다. 여기서 proxy delegate의 초기화를 추가하고 참조해야 한다.
      1. 이 두가지 함수를 이용해서, delegate를 초기화하고, 모든 구현을 등록할 수 있다. 이 구현은 CLLocationManager 인스턴스에서 연결된 observable로 데이터를 이동시키는데 사용되는 proxy이다. 이는 RxCoca에서 delegate proxy 패턴을 쓰기위해 클래스를 확장하는 방법이다. 이렇게 proxy delegate를 생성함으로써 장소 이동을 관찰하기 위한 observable이 생성되었다.
      1. Reactive extension은 rx 키워드를 통해 CLLocationManager 인스턴스의 method들을 펼쳐놓을 것이다. 이제 모든 CLLocationManager 인스턴스에서 rx 키워드를 쓸 수 있다. 하지만, 아직 진짜 observable은 진짜 데이터를 받고 있지 않다.
      1. 이를 고치기 위해 함수를 추가했다.
      • 이 함수를 사용하면 proxy로 사용된 delegate는 didUpdateLocations의 모든 호출을 수신하고 데이터를 가져와서 CLLocation.methodInvoked(_:)의 array로 캐스팅 한다. 이는 Objective-C 코드의 일부로, RxCocoa 및 기본적으로 delegate에 대한 낮은 수준의 observer다.
      • methodInvoked(_:)는 지정된 method가 호출될 때마다 next 이벤트를 보내는 observable을 리턴한다. 이러한 이벤트레 포함된 요소는 method가 호출된 parameter의 array이다. 이 array를 parameters[1]로 접근하여 CLLocation의 array에 캐스팅한다.

2. 현재 위치 확인용 버튼 사용하기

  • 이제 좌측 아래에 있는 위치 버튼을 사용할 수 있다.

  • app의 UI 작업을 하기 위해 ViewController.swift로 가보자. 버튼의 로직을 작업하기 전에 몇가지 확인해야할 게 있다. 먼저 CoreLocation 프레임워크를 import 한다.

     import CoreLocation
  • 그리고 CLLocationManager 객체를 만들어준다.

     let locationManager = CLLocationManager()
    • 참고: locationManagerviewDidLoad() 내에서 선언하는 것은 객체의 release가 일어나고 alert이 표시되는 된다. 이는 requestWhenInUseAuthorization()이 호출되면 즉시 제거된다.
  • 이제 날씨앱이 사용자의 위치를 파악할 수 이도록 해주어야 한다. iOS 8부터 운영체제는 사용자에게 반드시 위치정보공유에 대한 허가를 받아야 한다. 그러므로 첫번째로 할 일은 사용자가 현재위치 버튼을 눌렀을 때 위치정보수집에 대한 허용요청을 보내는 것이다. 다음과 같은 코드를 viewDidLoad() 내부에 구현하자.

             geoLocationButton.rx.tap
                 .subscribe(onNext: { _ in
                     self.locationManager.requestWhenInUseAuthorization()
                     self.locationManager.startUpdatingLocation()
                 })
                 .disposed(by: bag)
    
             // 1
             locationManager.rx.didUpdateLocations
                 .subscribe(onNext: { locations in
                     print(locations)
                 })
                 .disposed(by: bag)
    1. 앱이 실제로 사용자의 위치를 받고 있는지 확인하기 위해 작성한 임시 코드다. (콘솔에 프린팅 된다.)
    • simulator를 사용할 때는 Debug > Location에서 가짜 위치를 지정할 수 있다.
  • 콘솔에 프린팅이 잘 된다면, 앱이 위치를 잘 받아온다고 생각할 수 있다. 앱은 위치 데이터를 이용해서 지역 날씨를 받아올것이다. 이를 구현하기 위해 ApiController.swift 내부에 하기와 같은 함수가 구현되어있다. 유저의 위도와 경도를 기반으로 서버에서 데이터를 검색할 수 있다.

     func currentWeather(lat: Float, lon: Float) -> Observable<Weather>
    • 이 함수는 Weather 인스턴스를 반환한다.
  • viewDidLoad() 내부에, 마지막 유효 위치를 반환하는 observable을 생성하자.

             let currentLocation = locationManager.rx.didUpdateLocations
                 .map { location in
                     return location[0]
             }
                 .filter { location in
                     return location.horizontalAccuracy < kCLLocationAccuracyHundredMeters
             }
    • didUpdateLocations는 받은 위치들을 가지는 array를 방출할 것이다. 하지만 이 작업(현재 위치를 가져오는)에서는 하나의 위치 데이터만 필요하기 때문에, map을 통해 첫 번째 위치데이터만 가져온다. 그리고 완전히 다른 위치 데이터로 작업하는 것을 막기 위해 filter를 이용하여 받아온 데이터가 100 미터 이내로 정확한 값인지 확인한다.

3. 현재 데이터를 이용하여 날씨 업데이트 하기

  • 이제 사용자의 위치를 반환하는 observable이 만들어졌다. 또한 위도와 경도를 기반으로 날씨를 가져오는 메커니즘도 만들어졌다. RxSwift에의 결합은 다음과 같이 이루어질 것이다.

  • 필요한 observable을 모델링하기 위해, geoLocationButton.rx.tap 코드를 다음과 같이 수정하자.

             // 1
             let geoInput = geoLocationButton.rx.tap.asObservable()
                 .do(onNext: {
                     self.locationManager.requestWhenInUseAuthorization()
                     self.locationManager.startUpdatingLocation()
                 })
    
             let geoLocation = geoInput.flatMap {
                 return currentLocation.take(1)
             }
    
             // 2
             let geoSearch = geoLocation.flatMap { location in
                 return ApiController.shared.currentWeather(lat: location.coordinate.latitude, lon: location.coordinate.longitude)
                     .catchErrorJustReturn(ApiController.Weather.dummy)
             }
      1. location manager가 현재 위치에 대한 정보(앞서 말한 한개의 값)를 업데이트 하고 제공하도록 해준다. 이는 location manager로 부터 새로운 값이 들어올 때마다 앱이 업데이트 되는 것을 방지한다.
      1. 날씨 데이터를 받기 위한 새로운 observable을 만든다. 이 것은 Weather타입의 observable인 geoSearch 를 만든다. Weather타입의 observable은 곧 앞서서 도시명을 입력해서 얻는 값과 같은 것이다.
      • Weather라는 같은 타입을 반환하는 두 개의 observable은 결국 같은 일을 하는 것이다. 즉, 앞서 만든 코드에 대한 리팩토링이 필요하다. 그렇다. 이 함수는 도시명을 받는 observable과 합쳐질 수 있다.
  • 목표는 WeatherDriver역할을 하는 search와 앱의 현재 상태에 대한 observable인 running을 남기는 것이다.

             // 1
             let textSearch = searchInput.flatMap { text in
                 return ApiController.shared.currentWeather(city: text ?? "Error")
                     .catchErrorJustReturn(ApiController.Weather.dummy)
             }
    
             // 2
             let search = Observable.from([
                     geoSearch, textSearch
                 ])
                 .merge()
                 .asDriver(onErrorJustReturn: ApiController.Weather.dummy)
    
             let running = Observable.from([
                 searchInput.map { _ in true },
                 geoInput.map { _ in true }, // 3
                 search.map { _ in false }.asObservable()
                 ])
                 .merge()
                 .startWith(true)
                 .asDriver(onErrorJustReturn: false)
    1. 기존의 search삭제하고, 다음과 같이 새 코드를 searchInput 아래에 추가한다.
    2. 이제 textSearchgeoSearch를 새로운 search observable로 합칠 수 있다.
      • 이 것은 source(도시명 또는 사용자의 현재위치)와는 관계없이 UI에 Weather 객체를 전달한다.
      • 마지막 단계는 피드백을 제공하고 검색할 동안 activity indicator를 올바르게 표시한 뒤, 요청이 완료되면 숨기는 것이다.
    3. running observable로 가서, geoInput애 대한 조건을 추가한다. 이제 도시명 또는 현재위치를 통해 검색하는 결과는 정확히 똑같이 나타난다.
  • 지금까지의 과정을 통해 앱의 기능을 확장시켰다. 다음의 그림처럼 단순한 단방향 스트림을 멀티소스로 바꾸는 merge() 연산자를 통해 입력값을(도시명 + 현재위치) 추가했다.

  • running status에 다음과 같은 변경사항도 있다.

  • 앱을 만들기 시작할 때는 하나의 text source(도시명 입력값)로 시작했지만, 지금은 두개의 데이터 소스를 가지고 있다.

D. UIKit view 확장하는 법

  • 지금까지의 앱은 사용자에 위치에 따른 날씨를 보여주고 있다. 하지만 지도를 둘러보며 날씨를 확인할 수 있다면 더 멋질 것이다.
  • 즉, MKMapView 클래스에 대해 새로운 extension을 만들어야 할 것 같다.

1. MKMapView를 통해 UIKit view 확장하기

  • MKMapView를 확장하기 위해서는 CLLocationManager:에 처럼 RxNKMapViewDelegateProxyReactive 확장을 이용할 수 있다.

  • MKMapView+Rx.swift를 열고 Extensions를 찾아 다음과 같은 extension을 작성해보자.

     extension MKMapView: HasDelegate {
         public typealias Delegate = MKMapViewDelegate
     }
    
     class RxMKMapViewDelegateProxy: DelegateProxy<MKMapView, MKMapViewDelegate>, DelegateProxyType, MKMapViewDelegate {
    
         public weak private(set) var mapView: MKMapView?
    
         public init(mapView: ParentObject) {
             self.mapView = mapView
             super.init(parentObject: mapView, delegateProxy: RxMKMapViewDelegateProxy.self)
         }
    
         static func registerKnownImplementations() {
             self.register { RxMKMapViewDelegateProxy(mapView: $0) }
         }
     }
    
     extension Reactive where Base: MKMapView {
         public var delegate: DelegateProxy<MKMapView, MKMapViewDelegate> {
             return RxMKMapViewDelegateProxy.proxy(for: base)
     }
    
  • 아래의 코드를 viewDidLoad()에 표시해보자. 이 코드는 map view를 버튼을 누름에 따라서 표시되거나 사라지게 된다.

             mapButton.rx.tap
                 .subscribe(onNext: {
                     self.mapView.isHidden = !self.mapView.isHidden
                 })
                 .disposed(by: bag)
    • 앱을 구동해보면 맵뷰가 지도 버튼을 누를 때마다 사라지거나 나타나는 것을 알 수 있다.

2. 지도에 오버레이 표시하기

  • 이제 지도는 데이터를 표시할 준비가 되었다. 하지만 날씨 오버레이를 표시하기 전에 몇가지 선행되어야할 작업이 있다. 지도에 오버레이를 표시하기 위해 아래와 같은 delegate method가 필요하다.

     func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer
  • 반환값이 있는 delegate를 Rx로 래핑하는 것은 다음과 같은 이유 때문에 아주 어려운 일이다.

    • 반환값이 있는 delegate method는 관찰을 위한 것이 아니라 동작을 사용자화 하기 위한 것이다.
    • 자동적으로 기본값을 지정하는 것은 일단 중요한 작업이 아니다.
  • 따라서 여기서 최상의 솔루션은 delegate의 기본 구현에 이 호출을 전달하는 것이다.

  • 다음 함수를 MKMapViewReactive extension 내 추가한다.

         public func setDelegate(_ delegate: MKMapViewDelegate) -> Disposable {
             return RxMKMapViewDelegateProxy.installForwardDelegate(
                 delegate,
                 retainDelegate: false,
                 onProxyForObject: self.base)
         }
    • 이 함수를 통해서, 기존의 public static func installForwardDelegate(_ forwardDelegate: AnyObject, retainDelegate: Bool, onProxyForObject object: AnyObject) -> Disposable 함수를 설치할 수 있다.
  • 하기 코드를 viewDidLoad()에 추가해보자.

             mapView.rx.setDelegate(self)
                 .disposed(by: bag)
  • 이렇게 하면 프로토콜을 채택하지 않았다는 컴파일러 에러가 뜬다. 이 것을 해결하기 위해서 하기 코드를 ViewController.swift에 추가하자.

     extension ViewController: MKMapViewDelegate {
         func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
             if let overlay = overlay as? ApiController.Weather.Overlay {
                 let overlayView = ApiController.Weather.OverlayView(overlay: overlay, overlayIcon: overlay.icon)
                 return overlayView
             }
             return MKOverlayRenderer()
         }
     }
    • OverlayView는 지도 정보를 띄우기 위해 MKMapView 인스턴스를 필요로 한다.
    • 여기서의 목적은 단순하다. 날씨 아이콘을 추가적인 정보 없이 지도 위에 띄우는 것이다.
    • 현재까지 작업으로 delegate의 리턴타입, 전달된 proxy 생성, 화면 오버레이 셋팅을 완료했다. 이제 이러한 오버레이들을 RxSwift로 함께 작업하는 것만이 남았다.
  • MKMapView+Rx.swift로 가서 Reactive extension에 아래와 같은 바인딩 observer를 추가하자. 이는 MKOverlay의 모든 인스턴스를 받을 것이고 또한 이들은 현재 지도에 나타낼 것이다.

     var overlays: Binder<[MKOverlay]> {
     	return Binder(self.base){ mapView, overlays in
     		mapView.removeOverlays(mapView.overlays)
     		mapView.addOverlays(overlays)
     	}
    • Binder의 사용은 bind 또는 drive 함수를 사용할 수 있게 해준다.
    • overlays binding observable 내서는 이전 Overlay들은 매번 Subject에 새 array가 보내질 때마다 사라지고 재생성될 것이다.
  • 이 앱의 목적을 고려해보면 이 코드에 더 이상의 최적화 작업은 필요 없다. 한번에 10개 이상의 오버레이는 없을 것이기 때문에 모든 것을 제거하고 다시 새로 추가하는 것은 그렇게 나쁜 접근이 아니다. 물론 더 많은 것을 처리해야할 필요가 있을 때는, 성능 개선을 위해 diffalgorithm을 사용할 수 있다.

3. 생성한 바인딩의 사용

  • ApiController.swift를 열고 Weather struct를 확인하자. 여기에는 OverlayOverlayView라는 두가지 클래스가 있다.

    • OverlayNSObject의 서브클래스이며, MKOverlay 프로토콜을 준수한다. 이는 지도를 통해 실제 데이터를 렌더링 하기 위해 OverlayView에 전달할 정보 객체다. 여기서 알아둬야할 것은 Overlay는 지도에 아이콘을 표시하는데 필요한 정보만을 담고 있다는 것이다.
    • OverlayView는 오버레이 렌더링을 담당한다. 이미지가져오기를 피하기 위해, imageFromText는 텍스트를 이미지로 변환하므로, 아이콘을 맵에 오버레이로 표시할 수 있다. OverlayView는 새로운 인스턴스를 만들기 위해 기본 오버레이 인스턴스와 아이콘 텍스트만 필요로 한다.
  • Weather struct에 아래와 같은 함수를 발견할 수 있다. 이 함수는 struct를 Overlay로 변환해주는 편리한 함수다.

     func overlay() -> Overlay { ... }
  • ViewController.swift로 돌아가서 다음 코드를 viewDidLoad()에 추가하자.

    search.map { [$0.overlay()] }
    	.drive(mapView.rx.overlays)
    	.disposed(by: bag)
    • 이는 새로 도착한 데이터를 앞서 만든 오버레이 subject와 바인딩한다. 그리고 Weather struct를 올바를 overlay에 매핑한다.
  • 앱을 실행시켜서, 도시를 검색해보자. 그리고 지도를 열어 해당 도시로 이동해보자. 다음과 같은 화면을 확인할 수 있다.

4. 지도 스크롤 이벤트 관찰하기

  • 목표는 사용자가 지도에서 drag 이벤트나 다른 네비게이션 이벤트를 발생시키는 것을 확인하는 것이다.

  • 사용자가 네비게이션을 중단했을 때, 화면에서 표시 중인 지도의 가운데에 날씨 정보를 표시한다.

  • 이러한 변화를 추적할 수 있도록, MKMapViewDelegate는 다음과 같은 메소드를 제공한다.

     func mapView(_ mapView: MKMapView, regionDidChangeAnimated animation: Bool)
  • 이 델리게이트 메소드를 사용하면, 이 놈은 사용자가 새로운 지역으로 지도를 drag할 때마다 호출된다. 따라서 이 곳에 reactive extention을 생성하기 딱 좋을 것이다. MKMapView+Rx.swift를 열고 다음과 같은 코드를 extension내에 추가하자.

     public var regionDidChangeAnimated: ControEvent<Bool> {
     	let source = delegate
     		.methoudInvoked(#selector(MKMapViewDelegate.mapView(_:regionDidChangedAnimated:)))
     		.map { parameters in
     			return (parameters[1] as? Bool) ?? false
     		}
     	return ControlEvent(events: source)
     }
    • 안전을 위해, 캐스팅이 실패했을 땐 해당 메소드가 false를 내뱉도록 해두었다.

5. regionDidChangeAnimated 이벤트에 반응하기

  • drag에 대한 정보는 주어졌다. 그리고 RxSwift를 이용한 관찰 메커니즘이 준비되었다. 남은 것은 앞서 준비한 ControlEvent를 실제로 사용하는 것이다.

  • ViewController.swift로 이동하여 다음과 같은 기능을 추가할 코드를 작성해보자.

    • 기존에 만든 observalbe을 사용할 mapInput을 생성한다.
    • 위치를 검색할 mapSearch를 생성한다.
    • mapSearch의 결과를 다룰 search observable을 업데이트 한다.
    • 지도 이벤트와 날씨결과를 제대로 다룰 running observable을 업데이트 한다.
  • 먼저 textSearch = ... 바로 다음에 아래와 같은 코드를 추가한다.

     let mapInput = mapView.rx.regionDidChangeAnimated
     	.skip(1)
     	.map { _ in self.mapView.centerCoordinate }
  • mapInput을 이용하여 새로운 지도 날씨 데이터를 가져올 mapSearch observable을 만든다.

     let mapSearch = mapInput.flatMap { coordinate in
     	return ApiController.shared.currentWeather(lat: coordinate.latitude, lon: coordinate.longitude)
     		.catchErrorJustReture(ApiController.Weather.dummy)
     }
  • 두개의 observable을 만들었으니, 이제 결과를 나타내는 search observable과 상태를 나타내는 running observable을 업데이트하면 된다. 다음과 같이 search를 리팩토링하자.

     let search = Observable.from([geoSearch, textSearch, mapSearch])
  • running에 대해서는 별도로 수정할 필요 없이 mapSearch에 대한 내용만 추가해주면 된다. 다음 코드를 확인하자.

     let running = Observable.from([searchInput.map { _ in true },
     								geoInput.map { _ in true },
     								mapInput.map { _ in true },
     								search.map { _ in false }.asObservable()])

E. 하나만 더: Signal!

  • RxSwift 4.0에서는 Signal이라는 새로운 trait을 소개했다. 문서에선 다음과 같은 특성을 소개하고 있다.
    • It can't fail 실패할 수 없다.
    • Events are sharing only when connected 이벤트는 연결되었을 때만 공유된다.
    • All events are delivered in the main scheduler 모든 이벤트는 메인 스케줄러로 보내진다.
  • 이렇게 보면 Driver의 대체라고 볼 수도 있다. 하지만 하나 중요한 내용이 있다. 바로 구독한 뒤 마지막 이벤트에 대해서는 replay 하지 않는다는 것이다.
  • DriverSignal의 차이점은 BehaviorSubjectPublishSubject의 차이와 비슷하다.
  • 상황에 따라 어떤 것을 사용해야할지 판단해야할 때는 스스로 *"리소스에 연결했을 때, 마지막 이벤트에 대한 replay가 필요한가?"*를 생각해보자. 만약 필요없다면 Signal이 좋은 옵션이 될 수 있다. 필요하다면 Driver가 해결책이다.

F. 결론

  • RxCocoa는 필수적인 라이브러리가 아니다. 다만 아주 유용할 뿐.
  • 다음과 같은 장점이 있다.
    • 이미 가장 자주 사용되는 구성 요소에 대해 많은 extension을 가지고 있다.
    • 기본 UI 구성요소를 뛰어 넘는다.
    • Traits를 사용해서 코드를 안전하게 해준다.
    • binddrive를 통해 쉽게 사용할 수 있다.
    • 사용자화한 확장을 만들 수 있는 모든 메커니즘을 제공한다.

G. Challenges

1. Challenge 1: 주어진 지점에 지도의 초점을 맞추기 위한 바인딩 추가

  • 사용자가 text field나 위치 버튼을 눌렀을 때의 작동을 수정해보자. 현재 앱에서는 지도로 제대로 이동하지 않는다.
  • 다음과 같은 과정을 통해 개선해보자.
    • coordinate 객체를 갖는 바인딩 프로퍼티를 생성하고, 지도를 주어진 지점으로 이동하여 업데이트 한다.
    • geoSearchtextSearch에 대한 결과를 새로운 바인딩 프로퍼티로 바인딩한다.
  • 문제를 제대로 풀었다면, 도시명 또는 위치 정보가 입력될 때마다 지도가 해당 위치로 제대로 포커스 되어야 한다.

A.

2. Challenge 2: 위치를 둘러보고 주변의 날씨상태를 표시하기 위해 MKMapView를 이용하기

  • 현재 앱에서는 단 하나의 위치에 대해서만 날씨가 표시되고 있다. 이 작동을 개선해보자.
  • 다음과 같은 과정을 통해 수정할 수 있다.
    • coordinate를 갖고 주변 위치를 포함하는 array의 observable을 반환하는 새 currentWeatherAround를 생성한다.
    • 적절한 연산자를 사용하여, 이 요청들을 병합한다. 병합이 진행중일 때도 앱이 계속 응답하고 실행상태가 계속 업데이트 되는지 확인해야 한다.
    • 결과 observable을 .rx.overlays로 바인드 한다.
  • 문제를 제대로 풀었다면, 지도상 여러개의 오버레이가 표시될 것이며, 지도상 표시된 여러 위치에 대한 날씨가 표시될 것이다.

A.


Artwork/images/designs: from RxSwift: Reactive Programming in Swift book, available at http://www.raywenderlich.com