Ch13. 🦕 Intermediate RxCocoa
Ch13. 🦕 Intermediate RxCocoa
✅ 내가 돌아왔다!
예전에 rx를 처음 공부할 때는 이 부분 하나도 이해 안가서 아래 사진처럼 해두고 스킵했었는데, 이제는 다 이해해서 포스팅 하러 옴!
✅ [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
})
}
}
Proxy로 묶은 코드인데, 이 코드는 정말 Standard하게끔 묶고 있다.
✅ MKMapView+Rx
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
})
}
}
여기도 정말 standard하게 묶고 있다. 눈에 띄는 점은 Binder와 ControlEvent를 사용했다는 것인데, 딱 봐도 어렵지 않으므로 쓱 읽어보도록 하자.
✅ 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()
}
}
주석으로 다 정리해 두었으니 살펴보기.
✅ [Color Set]
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)
}
}
Color Set을 다음과 같이 만들어서 사용하는 코드를 보았는데, 상당히 신선하고 다크모드와 같이 변경해주어야할 때, 상당히 신선하고 좋은 방법이라고 생각된다.
더 좋은 방법은 Asset의 ColorAsset을 사용하면 다크모드를 조금 더 쉽게 지원할 수 있다.
✅ 밑줄 긋는 코드
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
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()
}
UILabel에 image를 주어 사용하는 방법도 있다.
✅ 🔥 가장 중요하다고 느끼는 API 처리하는 로직🔥
개인적으로 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()
}
다른것보다도 가장 눈에 띄는건 우선 데이터를 request하고 response를 받았을 때, 데이터가 Observable은 아닐거 아니야? rx를 사용ㅇ하면 session 부분에 rx.data를 통해서 데이터를 JSON형태로 옵저버블로 내려보내게 돼. 이게 buildRequest 함수 부분이야. 그리고 Decoding은 주로 다른 쪽에서 하게되는데, 빌드 리퀘스트로 JSON 형태로 데이터를 받아와서 Weather에서 데이터를 가공 처리하게 된다.