apple/RxSwift, ReactorKit

Ch13. Intermediate RxCocoa

lgvv 2021. 8. 12. 15:20

Ch13. Intermediate RxCocoa

 

오랜만에 RxSwift를 포스팅하고자 함.

RxSwift 스터디를 했었는데, 스터디 후에도 너무 어려워서 기본기부터 다시 함.

 

RxSwift는 MVVM에서 편하게 사용하려고 쓴다는데 클로저로 MVVM도 안써본 내가 RxSwift부터 하는게 너무 황당해서 스스로 클로저로 했을때 뭐가 어떤지를 겪어보았음

 

확실히 이 과정이 지나니까 RxSwift과 너무나도 당연한 흐름?이었음을 이해하게 되어서 이해도가 훨씬 높아짐



앱 결과

예제인데 보고 연습

APP UI

 

CLLocationManager + Rx

프록시로 묶어서 처리한건데 정말 깔끔하게 묶어내고 있음

import Foundation
import CoreLocation
import RxSwift
import RxCocoa

extension CLLocationManager: HasDelegate {
    public typealias Delegate = CLLocationManagerDelegate
}

class RxCLLocationManagerDelegateProxy:DelegateProxy<CLLocationManager, CLLocationManagerDelegate>, DelegateProxyType, CLLocationManagerDelegate {
    static func registerKnownImplementations() {
        self.register { (parent) -> RxCLLocationManagerDelegateProxy in
            return RxCLLocationManagerDelegateProxy(locationManager: parent)
        }
    }

    public weak private(set) var locationManager: CLLocationManager?

    public init(locationManager: ParentObject) {
        self.locationManager = locationManager
        super.init(parentObject: locationManager, delegateProxy: RxCLLocationManagerDelegateProxy.self)
    }
}

extension Reactive where Base == CLLocationManager {
    public var delegate: DelegateProxy<CLLocationManager, CLLocationManagerDelegate> {
        return RxCLLocationManagerDelegateProxy.proxy(for: base)
    }

    var didUpdateLocations: Observable<[CLLocation]> {
        return delegate.methodInvoked(
            #selector(CLLocationManagerDelegate.locationManager(_:didUpdateLocations:))
            ).map({ (params) in
                // PARAMS contains CLLocationManager and CLLocation array
                return params[1] as! [CLLocation]
            })
    }

    var didGetLocationFail: Observable<Error> {
        return delegate.methodInvoked(
            #selector(CLLocationManagerDelegate.locationManager(_:didFailWithError:))
            ).map({ (params) in
                return params[1] as! Error
            })
    }
}

 

 

 

MKMapView+Rx

여기도 정말 standard하게 묶고 있음.

눈에 띄는 점은 Binder와 ControlEvent를 사용했다는 것인데, 딱 봐도 어렵지 않으므로 쓱 읽어보도록 하자.

import Foundation
import MapKit
import RxSwift
import RxCocoa

extension MKMapView: HasDelegate {
    public typealias Delegate = MKMapViewDelegate
}

class RxMapViewDelegateProxy:DelegateProxy<MKMapView, MKMapViewDelegate>, DelegateProxyType, MKMapViewDelegate {
    public weak private(set) var mapView: MKMapView?

    static func registerKnownImplementations() {
        self.register { (parent) -> RxMapViewDelegateProxy in
            return RxMapViewDelegateProxy(mapView: parent)
        }
    }

    public init(mapView: ParentObject) {
        self.mapView = mapView
        super.init(parentObject: mapView, delegateProxy: RxMapViewDelegateProxy.self)
    }
}

extension Reactive where Base == MKMapView {
    public var delegate: DelegateProxy<MKMapView, MKMapViewDelegate> {
        return RxMapViewDelegateProxy.proxy(for: base)
    }

    public func setDelegate(_ delegate: MKMapViewDelegate) -> Disposable {
        // 이 코드의 정확한 의미는 모르겠으나, 이 코드가 없다면 mapView에 날씨 아이콘이 나타나지 않는다.
        return RxMapViewDelegateProxy.installForwardDelegate(
            delegate,
            retainDelegate: false,
            onProxyForObject: self.base
        )
    }
    
    var overlays: Binder<[MKOverlay]> {
        return Binder(self.base){ mapView, overlays in
            mapView.removeOverlays(mapView.overlays)
            mapView.addOverlays(overlays)
        }
    }

    
    var regionDidChangeAnimated: ControlEvent<Bool> {
        //맵 보기에 표시된 영역이 바뀌었음을 delegate에게 알림
        let source = delegate.methodInvoked(#selector(MKMapViewDelegate.mapView(_:regionDidChangeAnimated:)))
            .map { (params) in
                return params[1] as? Bool ?? false
            }
        return ControlEvent(events: source)
    }

    var centerCoordinate: Binder<CLLocationCoordinate2D> {
        return Binder(self.base, binding: { (mapViews, center) in
            mapViews.centerCoordinate = center
        })
    }
}

 

ViewController

주석으로 정리함

import UIKit
import RxSwift
import RxCocoa
import NSObject_Rx
import MapKit
import CoreLocation

class ViewController: UIViewController {
    
    @IBOutlet weak var mapView: MKMapView!
    @IBOutlet weak var mapButton: UIButton!
    @IBOutlet weak var geoLocationButton: UIButton! // 현재위치 버튼 - simulator에서는 san francisco로 되어있음
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var searchCityName: UITextField!
    @IBOutlet weak var tempLabel: UILabel!
    @IBOutlet weak var humidityLabel: UILabel!
    @IBOutlet weak var iconLabel: UILabel!
    @IBOutlet weak var cityNameLabel: UILabel!
    
    let locationManager = CLLocationManager()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        style()
        
        // MARK: - GEO SEARCH
        locationManager.delegate = self
        
        let geoInput = geoLocationButton.rx.tap // 위치 아이콘 탭하면
            .do(onNext: { _ in
                self.locationManager.requestWhenInUseAuthorization()
                self.locationManager.requestLocation()
            })
                
        let currentLocation = locationManager.rx.didUpdateLocations // 위치 업데이트 되면 정보 받아옴
            .debug("currentLocation")
            .map { (locations) in
                return locations[0]
            }
        
        locationManager.rx.didGetLocationFail
            .subscribe(onNext: { error in
                print("Did fail with error", error.localizedDescription)
            })
            .disposed(by: rx.disposeBag)
        
        let geoLocation = geoInput.flatMap { // geoInput은 tap이벤트라 Void
            return currentLocation.take(1) // 현재위치로 업뎃
        } // 즉, 탭 이벤트 들어오면 현재위치로 바꿔서 geoLocation에 담기게 된다.
        
        let geoSearch = geoLocation.flatMap { (location) in // location은 Observable이다.
            ApiController.shared.currentWeather(
                lat: location.coordinate.latitude,
                lon: location.coordinate.longitude
            )
                .catchErrorJustReturn(ApiController.Weather.dummy)
        }
                
        // MARK: - MAP VIEW
        mapView.rx.setDelegate(self).disposed(by: rx.disposeBag)
        
        mapButton.rx.tap
            .subscribe(onNext: { [weak self] _ in
                guard let `self` = self else {
                    return
                }
                self.mapView.isHidden = !self.mapView.isHidden
            })
            .disposed(by: rx.disposeBag)
        
        let mapInput = mapView.rx.regionDidChangeAnimated // 화면에서 지도위치가 변경될 때 호출된다.
            .debug("mapInput")
            .skip(1)
            .map { _ in self.mapView.centerCoordinate } // 지금 보는 화면 mapView의 중심좌표를 반환하다
        
        let mapSearch = mapInput.flatMap { (coor) in // 지도 중심좌표를 가지고 그 좌표의 현재 날씨를 알아온다.
            return ApiController.shared.currentWeather(
                lat: coor.latitude,
                lon: coor.longitude
            )
                .catchErrorJustReturn(ApiController.Weather.dummy) // 에러 있으면 dummy로 처리하기
        }
        
        // MARK: - TEXT SEARCH
        let searchInput = searchCityName.rx.controlEvent(.editingDidEndOnExit)
            .asObservable()
            .map { self.searchCityName.text } // 터치이벤트라 void인데 터치 이벤트 날라오면 map을 통해서 text를 저장한다. 변수는 string타입
            .filter { ($0 ?? "").count > 0 } // map으로 바꿀 값이 0보다 더 큰 경우만
        
        let textSearch = searchInput
            .flatMap { text in // observable로 감싸있는 경우는 map이 아니라 flatmap을 사용해서 바꾼다.
                return ApiController.shared.currentWeather(city: text ?? "Error")
                    .catchErrorJustReturn(ApiController.Weather.dummy)
            } // Observable<Weather> 타입의 데이터를 갖고 있다.
        
        let search = Observable.from([
            geoSearch, // 현재 위치 버튼 클릭시, 위치의 좌표
            textSearch, // text입력 시, 텍스트 기준 좌표
            mapSearch // 지도 움직일 때, 지도 중심점 기준 좌표.
        ])
            .merge() // 세 옵저버블 합쳐서, merge를 통해 가장 마지막에 방출되는 이벤트 search값에 넣기
            .asDriver(onErrorJustReturn: ApiController.Weather.dummy) // 에러 시, dummy 내보내기
        
        // merge해서 그 데이터를 그에 알맞게끔 내려보낸다.
        
        search.map { "\($0.temperature)° C" }
        .drive(tempLabel.rx.text)
        .disposed(by: rx.disposeBag)
        
        search.map { $0.icon }
        .drive(iconLabel.rx.text)
        .disposed(by: rx.disposeBag)
        
        search.map { "\($0.humidity)%" }
        .drive(humidityLabel.rx.text)
        .disposed(by: rx.disposeBag)
        
        search.map { $0.cityName }
        .drive(cityNameLabel.rx.text)
        .disposed(by: rx.disposeBag)
        
        search.map {[$0.overlay()]}
        .drive(mapView.rx.overlays)
        .disposed(by: rx.disposeBag)
        
        let running = Observable.from([ // 얘는 데이터가 들어오면 그에 맞는 조건에 따른 이벤트들을 보여주기 위함.
            searchInput.map {_ in return true}, // 텍스트 입력
            geoInput.map {_ in return true}, // 현재위치 버튼 클릭
            mapInput.map {_ in return true}, // 맵 위치 이동
            search.map {_ in return false}.asObservable() // 결과가 나왔을 때
            // search의 경우에는 옵저버블이 여러겹 래핑되어 있어서 asObservable()을 통해 단일로 방출시킴
                                      ])
            .merge() // merge니까 running은 저기 위에 이벤트들은 전부 바라보고 있는데, 거기서 내려온 마지막 값을 방출받아서 할당해 사용하게 된다.
            .asDriver(onErrorJustReturn: false)
        
        // animatimg은 true여야 화면에 표시됨으로 애만 보이도록 하고 나머지는 끝나면 나타나게끔 처리한 로직인데 감탄!
        
        running.drive(activityIndicator.rx.isAnimating)
            .disposed(by: rx.disposeBag)
        running.drive(iconLabel.rx.isHidden)
            .disposed(by: rx.disposeBag)
        running.drive(tempLabel.rx.isHidden)
            .disposed(by: rx.disposeBag)
        running.drive(cityNameLabel.rx.isHidden)
            .disposed(by: rx.disposeBag)
        running.drive(humidityLabel.rx.isHidden)
            .disposed(by: rx.disposeBag)
        
        
        
        let mapLocationMoving = Observable.from([
            geoSearch,
            textSearch
        ])
            .merge()
            .asDriver(onErrorJustReturn: ApiController.Weather.dummy)
        
       
        
        mapLocationMoving.map {$0.coordinate} // mapLocationMoving가 결국은 weather 타입이라서 coordinate 사용해서 좌표로 변환.
        .drive(mapView.rx.centerCoordinate) // 맵뷰의 중앙을 맞춤
        .disposed(by: rx.disposeBag)
        
        let mapAround = mapInput.flatMap { (coor) in // 맵 중심좌표 가져다가
            return ApiController.shared.currentWeatherAround(lat: coor.latitude, lon: coor.longitude)
                .catchErrorJustReturn([])
        }.asDriver(onErrorJustReturn: [])
        
        mapAround.map { (weathers) in // 맵 주변 좌표를 가져서 맵뷰의 오버레이에 그린다.
            return weathers.map({ (weather) in
                return weather.overlay()
            })
        }
        .drive(mapView.rx.overlays)
        .disposed(by: rx.disposeBag)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        Appearance.applyBottomLine(to: searchCityName)
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    // MARK: - Style
    
    private func style() {
        // color 부분에서 Colors에 모아두는 것이 인상깊음
        view.backgroundColor = UIColor.aztec
        searchCityName.textColor = UIColor.ufoGreen
        tempLabel.textColor = UIColor.cream
        humidityLabel.textColor = UIColor.cream
        iconLabel.textColor = UIColor.cream
        cityNameLabel.textColor = UIColor.cream
    }
}

extension ViewController: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    }
}

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()
    }
}

 

 

 

ColorSet

컬러유틸인데, 더 좋은 방법은 Asset의 ColorAsset을 사용하면 다크모드를 조금 더 쉽게 지원할 수 있음.

Asset하면 코드로는 간혹 다크모드 전환시 반영안되기도 함.

 

import Foundation
import UIKit

public func colorFromDecimalRGB(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, alpha: CGFloat = 1.0) -> UIColor {
  return UIColor(
    red: red / 255.0,
    green: green / 255.0,
    blue: blue / 255.0,
    alpha: alpha
  )
}

extension UIColor {

  // MARK: Custom Defined Colors

  /// Dark Blue Aztec
  class var aztec: UIColor {
    return colorFromDecimalRGB(38, 39, 41)
  }

  /// Light Cream Color
  class var lightCream: UIColor {
    return colorFromDecimalRGB(232, 234, 221)
  }

  /// Cream Color
  class var cream: UIColor {
    return colorFromDecimalRGB(229, 231, 218)
  }

  /// Swirl Color
  class var swirl: UIColor {
    return colorFromDecimalRGB(228, 221, 202)
  }

  /// Travertine Color
  class var travertine: UIColor {
    return colorFromDecimalRGB(214, 206, 195)
  }

  /// Green
  class var ufoGreen: UIColor {
    return colorFromDecimalRGB(64, 186, 145)
  }

  class var textGrey: UIColor {
    return colorFromDecimalRGB(146, 146, 146)
  }

}

 

 

밑줄 긋는 코드

이것도 UIKit에서는 하나의 도전과제

import Foundation
import UIKit

public struct Appearance {

  // MARK: Component Theming
  static func applyBottomLine(to view: UIView, color: UIColor = UIColor.ufoGreen) {
    let line = UIView(frame: CGRect(x: 0, y: view.frame.height - 1, width: view.frame.width, height: 1))
    line.backgroundColor = color
    view.addSubview(line)
  }

}

 

적용하는 예시

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        Appearance.applyBottomLine(to: searchCityName)
    }

 

 

imageFromText

UILabel에 image를 주어 사용하는 방법도 있음.

fileprivate func imageFromText(text: NSString, font: UIFont) -> UIImage {
    
    let size = text.size(withAttributes: [NSAttributedString.Key.font: font])
    
    UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
    text.draw(at: CGPoint(x: 0, y:0), withAttributes: [NSAttributedString.Key.font: font])
    
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    
    return image ?? UIImage()
}

 

 

API 처리

Rx와 가장 궁합이 좋은 영역

import Foundation
import RxSwift
import RxCocoa
import SwiftyJSON
import CoreLocation
import MapKit

class ApiController {
    
    /// The shared instance
    static var shared = ApiController()
    
    /// The api key to communicate with openweathermap.org
    /// Create you own on https://home.openweathermap.org/users/sign_up
    private let apiKey = "5b0afdc348787659e01e0b959f8d9175"
    
    /// API base URL
    let baseURL = URL(string: "http://api.openweathermap.org/data/2.5")!
    
    init() {
        Logging.URLRequests = { request in
            return true
        }
    }
    
    //MARK: - Api Calls
    
    func currentWeather(city: String) -> Observable<Weather> {
        
        return buildRequest(pathComponent: "weather", params: [("q", city)]).map() { json in // map의 결과가 json임
            return Weather(
                cityName: json["name"].string ?? "N/A",
                temperature: json["main"]["temp"].int ?? 0,
                humidity: json["main"]["humidity"].int  ?? 0,
                icon: iconNameToChar(icon: json["weather"][0]["icon"].string ?? "e"),
                lat: json["coord"]["lat"].double ?? 0,
                lon: json["coord"]["lon"].double ?? 0
            )
        }
    }
    
    func currentWeather(lat: Double, lon: Double) -> Observable<Weather> {
        return buildRequest(pathComponent: "weather", params: [("lat", "\(lat)"), ("lon", "\(lon)")]).map() { json in
            return Weather(
                cityName: json["name"].string ?? "N/A",
                temperature: json["main"]["temp"].int ?? 0,
                humidity: json["main"]["humidity"].int  ?? 0,
                icon: iconNameToChar(icon: json["weather"][0]["icon"].string ?? "e"),
                lat: json["coord"]["lat"].double ?? 0,
                lon: json["coord"]["lon"].double ?? 0
            )
        }
    }
    
    func currentWeatherAround(lat: Double, lon: Double) -> Observable<[Weather]> {
        var weathers = [Observable<Weather>]()
        for i in -1...1 {
            for j in -1...1 {
                weathers.append(currentWeather(lat: lat + Double(i), lon: lon + Double(j)))
            }
        }
        return Observable.from(weathers).merge().toArray()
    }
    
    //MARK: - Private Methods
    
    /**
     * Private method to build a request with RxCocoa
     */
    private func buildRequest(method: String = "GET", pathComponent: String, params: [(String, String)]) -> Observable<JSON> {
        let url = baseURL.appendingPathComponent(pathComponent)
        var request = URLRequest(url: url)
        let keyQueryItem = URLQueryItem(name: "appid", value: apiKey)
        let unitsQueryItem = URLQueryItem(name: "units", value: "metric")
        let urlComponents = NSURLComponents(url: url, resolvingAgainstBaseURL: true)!
        
        if method == "GET" {
            var queryItems = params.map { URLQueryItem(name: $0.0, value: $0.1) }
            queryItems.append(keyQueryItem)
            queryItems.append(unitsQueryItem)
            urlComponents.queryItems = queryItems
        } else {
            urlComponents.queryItems = [keyQueryItem, unitsQueryItem]
            
            let jsonData = try! JSONSerialization.data(withJSONObject: params, options: .prettyPrinted)
            request.httpBody = jsonData
        }
        
        request.url = urlComponents.url!
        request.httpMethod = method
        
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let session = URLSession.shared
        
        return session.rx.data(request: request).map { try JSON(data: $0) }
    }
    
    /**
     * Weather information and map overlay
     */
    
    struct Weather {
        let cityName: String
        let temperature: Int
        let humidity: Int
        let icon: String
        let lat: Double
        let lon: Double
        
        static let empty = Weather(
            cityName: "N/A",
            temperature: 0,
            humidity: 0,
            icon: iconNameToChar(icon: "e"),
            lat: 0,
            lon: 0
        )
        
        static let dummy = Weather(
            cityName: "N/A",
            temperature: 0,
            humidity: 0,
            icon: iconNameToChar(icon: "e"),
            lat: 0,
            lon: 0
        )
        
        var coordinate: CLLocationCoordinate2D {
            return CLLocationCoordinate2D(latitude: lat, longitude: lon)
        }
        
        func overlay() -> Overlay {
            let coordinates: [CLLocationCoordinate2D] = [
                CLLocationCoordinate2D(latitude: lat - 0.25, longitude: lon - 0.25),
                CLLocationCoordinate2D(latitude: lat + 0.25, longitude: lon + 0.25)
            ]
            let points = coordinates.map { MKMapPoint($0) }
            let rects = points.map { MKMapRect(origin: $0, size: MKMapSize(width: 0, height: 0)) }
            let fittingRect = rects.reduce(MKMapRect.null) { (result, rect) in
                return result.union(rect)
            }
            return Overlay(icon: icon, coordinate: coordinate, boundingMapRect: fittingRect)
        }
        
        public class Overlay: NSObject, MKOverlay {
            var coordinate: CLLocationCoordinate2D
            var boundingMapRect: MKMapRect
            let icon: String
            
            init(icon: String, coordinate: CLLocationCoordinate2D, boundingMapRect: MKMapRect) {
                self.coordinate = coordinate
                self.boundingMapRect = boundingMapRect
                self.icon = icon
            }
        }
        
        public class OverlayView: MKOverlayRenderer {
            var overlayIcon: String
            
            init(overlay:MKOverlay, overlayIcon:String) {
                self.overlayIcon = overlayIcon
                super.init(overlay: overlay)
            }
            
            public override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
                let imageReference = imageFromText(text: overlayIcon as NSString, font: UIFont(name: "Flaticon", size: 32.0)!).cgImage
                let theMapRect = overlay.boundingMapRect
                let theRect = rect(for: theMapRect)
                
                context.scaleBy(x: 1.0, y: -1.0)
                context.translateBy(x: 0.0, y: -theRect.size.height)
                context.draw(imageReference!, in: theRect)
            }
        }
    }
}

/**
 * Maps an icon information from the API to a local char
 * Source: http://openweathermap.org/weather-conditions
 */
public func iconNameToChar(icon: String) -> String {
    switch icon {
    case "01d":
        return "\u{f11b}"
    case "01n":
        return "\u{f110}"
    case "02d":
        return "\u{f112}"
    case "02n":
        return "\u{f104}"
    case "03d", "03n":
        return "\u{f111}"
    case "04d", "04n":
        return "\u{f111}"
    case "09d", "09n":
        return "\u{f116}"
    case "10d", "10n":
        return "\u{f113}"
    case "11d", "11n":
        return "\u{f10d}"
    case "13d", "13n":
        return "\u{f119}"
    case "50d", "50n":
        return "\u{f10e}"
    default:
        return "E"
    }
}

fileprivate func imageFromText(text: NSString, font: UIFont) -> UIImage {
    
    let size = text.size(withAttributes: [NSAttributedString.Key.font: font])
    
    UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
    text.draw(at: CGPoint(x: 0, y:0), withAttributes: [NSAttributedString.Key.font: font])
    
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    
    return image ?? UIImage()
}

 

'apple > RxSwift, ReactorKit' 카테고리의 다른 글

iOS RxStarScream 총정리  (0) 2022.01.12
RxSwift 06 RxDataSources  (0) 2021.08.19
RxSwift Ch12. Beginning RxCocoa  (0) 2021.08.12
🐉 RxSwift 4Hour - Step3(Rx)  (0) 2021.07.18
🐉 RxSwift + MVVM (TableView) 코드1  (2) 2021.07.15