Ch13. Intermediate RxCocoa
오랜만에 RxSwift를 포스팅하고자 함.
RxSwift 스터디를 했었는데, 스터디 후에도 너무 어려워서 기본기부터 다시 함.
RxSwift는 MVVM에서 편하게 사용하려고 쓴다는데 클로저로 MVVM도 안써본 내가 RxSwift부터 하는게 너무 황당해서 스스로 클로저로 했을때 뭐가 어떤지를 겪어보았음
확실히 이 과정이 지나니까 RxSwift과 너무나도 당연한 흐름?이었음을 이해하게 되어서 이해도가 훨씬 높아짐
앱 결과
예제인데 보고 연습
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 |