본문 링크 (Original Link)

예제로 시작하는 RxSwift #2 – 옵저버블과 바인드

2017.10.09

#

by Łukasz Mróz, translated by pilgwon

titleImage

수정 2017년 1월 18일: 이 게시글은 Swift 3과 RxSwift 3.1로 업데이트 되었습니다

1편에서 RxSwiftRxCocoa 의 기초에 대해 배워보았습니다. (만약 아직 못 보셨으면, 꼭 보시길 권합니다!) 이제 리액티브쪽의 지식을 더 확장시킬 시간이 왔습니다. 오늘은 bindings 에 대해 얘기해보겠습니다.

걱정마세요, bindings 은 그 의미 그대로 묶는다는 의미이고 우리는 ObservablesSubjects 로 연결할 것입니다. 그리고 아직 배우지 않은 용어가 있습니다.

정의

시작하기 전에, 몇가지 정의와 친해질 필요가 있습니다. 저번 시간에 ObservablesObservers 에 대해 배웠고 오늘은 또 다른 종류에 대해 배워볼 것입니다.

Subject - ObservableObserver 를 한꺼번에 부르는 용어입니다. 기본적으로 관찰할 수도, 관찰당할 수도 있습니다.

BehaviorSubject - 구독하면 Subject 에 의해 반환한 가장 최근 값을 가져오고, 구독 이후 에 반환하는 값을 가져옵니다.

PublishSubject - 구독하면 구독 이후 에 반환하는 값을 얻게 됩니다.

ReplaySubject - 구독하면 구독 이후에 반환하는 값뿐만 아니라, 구독 이전 에 반환한 값을 가져옵니다. 얼마나 많은 과거의 값들을 가져올까요? 그것은 구독하는 ReplaySubject 의 버퍼 사이즈에 달려있습니다. 그리고 그 버퍼 사이즈는 Subject의 초기화할 때 알 수 있습니다.

Subject가 너무 많으니 간단하게 생각해 봅시다. 생일 파티 🎉가 열렸고 여러분은 받은 선물을 여는 중이라고 생각해봅시다.

첫번째 선물을 열고, 두번째 선물을 열고, 세번째 선물을 열었습니다. 아이고! 어머니는 맛있는 음식을 요리하고 있어서 선물 오픈 파티에 늦었습니다. 그녀는 당신이 이전에 어떤 선물을 받았는지 알고 싶어합니다. 그래서 당신은 받은 선물에 대해 말합니다. Rx 세계에서 보면 observable sequence (선물)를 observer (어머니)에게 보냅니다. 여기서 흥미로운 것은 그녀가 당신이 이미 약간의 값을 반환한 후에 관찰하기 시작했지만, 결국엔 모든 정보를 얻었다는 것입니다. 그녀에겐, 우리가 버퍼가 3인 ReplaySubject 입니다. (3개의 최근 선물 정보를 저장하고 있고 새로운 구독자가 생길때마다 그 정보를 넘깁니다.)

선물 개봉 파티는 여전히 진행되고 있고 두 친구(Jack과 Andy)는 늦었습니다. Jack은 친한 친구라서 지금까지 얼마나 열었는지 물어봅니다. 그가 파티에 늦은 것에 분노한 당신은 최근에 열었던 선물만 그에게 알려주었습니다. 그는 지금까지 몇 개의 선물을 열었는지 모르기 때문에, 당신이 알려준 사실에 행복해 합니다. Rx 세계에서 보면 당신은 오직 최근 반환한 값만 관찰자(Jack)에게 보냅니다. 그는 또한 앞으로 당신이 반환하는 값들을 알게될 것입니다. (다음에 열 선물들에 대한 정보) 그에게 우리는 BehaviorSubject 입니다. (Subject가 살짝 바뀌었죠 😎)

Andy 는 당신과 그냥 친구라서 이전에 오픈한 선물에 관심은 없고 선물 오픈 쇼의 나머지를 그냥 앉아서 기다리고 있습니다. 여러분이 상상하듯이, 그에게 우리는 PublishSubject 입니다. 그는 구독 이후에 반환한 값을 받습니다.

Variable 이라는 용어도 있습니다. 이것은 BehaviorSubject 를 감싸는 .onNext() 이벤트만 제출할 수 있는 래퍼입니다. (BehaviorSubject 를 사용하면 .onError(), .onCompleted() 전송에 직접 접근할 수 있습니다) 또한, Variable 은 할당 해제될 때 자동으로 .onCompleted() 이벤트를 보냅니다.

좋습니다, 정의에 대해서는 충분한 거 같으니 시작해봅시다!

예제

원의 색과 뷰에서 원의 위치를 연결하고 뷰의 배경색과 원의 색을 연결하는 간단한 앱을 만들것입니다.

먼저 이전 튜토리얼에서 그랬듯이 프로젝트를 만들어봅시다. 또한 CocoaPods을 사용하고 RxSwift와 RxCocoa에 더해 색깔들을 연결하기 좋게 Chameleon을 사용할 것입니다. Podfile은 아래와 같을 것입니다:

platform :ios, '9.0'
use_frameworks!

target 'ColourfulBall' do

pod 'RxSwift'
pod 'RxCocoa'
pod 'ChameleonFramework/Swift', :git => 'https://github.com/ViccAlexander/Chameleon.git'

end

post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
              config.build_settings['ENABLE_TESTABILITY'] = 'YES'
              config.build_settings['SWIFT_VERSION'] = '3.0'
        end
    end
end

설정이 끝났다면 이제 코딩을 할 시간입니다! 먼저 컨트롤러의 메인 뷰에 원을 그릴 것입니다. 저는 코드로 그릴 것이지만, Interface Builder로 그리셔도 상관없습니다. 아래는 원을 그리는 코드의 예시입니다:

import ChameleonFramework
import UIKit
import RxSwift
import RxCocoa

class ViewController: UIViewController {

    var circleView: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }

    func setup() {
        // 원 모양의 뷰를 그립니다
        circleView = UIView(frame: CGRect(origin: view.center, size: CGSize(width: 100.0, height: 100.0)))
        circleView.layer.cornerRadius = circleView.frame.width / 2.0
        circleView.center = view.center
        circleView.backgroundColor = .green
        view.addSubview(circleView)
    }
}

위의 코드는 따로 설명이 필요 없을 것이니 (그냥 원 모양의 UIView를 그렸기 때문에), 다음으로 넘어가겠습니다. 다음으론 pan gesture가 감지될 때마다 원을 움직이는 작업을 해봅시다. 그러기위해선 UIPanGestureRecognizer를 추가하고 원의 frame을 변경해야 합니다:

func setup() {
    // 원 모양의 뷰를 그립니다
    circleView = UIView(frame: CGRect(origin: view.center, size: CGSize(width: 100.0, height: 100.0)))
    circleView.layer.cornerRadius = circleView.frame.width / 2.0
    circleView.center = view.center
    circleView.backgroundColor = .green
    view.addSubview(circleView)

    // gesture recognizer를 추가합니다
    let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(circleMoved(_:)))
    circleView.addGestureRecognizer(gestureRecognizer)
}

func circleMoved(_ recognizer: UIPanGestureRecognizer) {
    let location = recognizer.location(in: view)
    UIView.animateWithDuration(0.1) {
        self.circleView.center = location
    }
}

완벽합니다! 우리 앱은 아래와 같이 보일 것입니다:

다음은 무언가를 묶을(bind) 차례입니다! 이제 원의 위치와 원의 색을 연결해 봅시다. 어떻게 할까요? 먼저 rx.observe()를 사용해서 원의 중앙 위치를 관찰하고 이것을 VariablebindTo()를 사용해서 묶을 것입니다. 하지만 우리의 경우엔 바인딩이 무엇을 할까요? 흠, 원에서 새로운 위치의 값이 반환될 때마다, 그 변수는 그에 대한 새로운 신호를 받을 것입니다. 변수가 원의 중앙 위치를 관찰하기 때문에 이 경우엔 Observer라고 볼 수 있습니다.

우리는 새로운 변수를 UI에 관련된 것들을 계산할 때 사용될 ViewModel 에서 생성할 것입니다. 이렇게 되면 변수가 새로운 위치를 받을 때마다, 원의 배경 색을 계산할 수 있게 됩니다. 참 쉽죠? 🤔

이제 ViewModel 을 만들어야 합니다. 이 뷰모델은 속성이 2개밖에 없기 때문에 매우 간단할 것입니다. 하나는 observer와 observable 역할을 하는 centerVariable 이고, 이것은 데이터를 저장하고 가져오는데에 사용됩니다. 두번째는 backgroundColorObservable 입니다. 이것은 실제로 Variable 은 아니고, 그저 Observable 입니다.

이런 궁금증이 생길수도 있습니다. “왜 centerVariableVariable 인데, backgroundColorObservableObservable 일까?” 매우 좋은 질문입니다! 보시면, 관찰 가능한 원의 중앙은 centerVariable과 연결되어 있습니다. 이 말은 시간이 지나서 중앙 위치가 바뀌면, centerVariable도 변한다는 것을 의미합니다. 그래서 그것은 Observer 입니다. 또한 ViewModel 에서는 centerVariable을 Observable 로 사용하는데, 이것은 ObserverObservable 둘 다로 사용하는 것이기 떄문에 그냥 Subject 입니다. 왜 PublishSubjectReplaySubject 가 아닌 Variable 일까요? 그 이유는 단지 우리가 구독한 원의 가장 최근 중앙 지점만을 필요로 하기 때문입니다.

backgroundColorObservable 은 그저 Observable 입니다. 다른 것들에 영향을 주지 않기 때문에 Observable로 두는 것이 완벽합니다.

좋습니다! 이론은 끝났으니, 코드를 작성해 봅시다! 기본 ViewModel 은 다음과 같습니다:

import ChameleonFramework
import Foundation
import RxSwift
import RxCocoa

class CircleViewModel {

    var centerVariable = Variable<CGPoint?>(.zero) // Create one variable that will be changed and observed
    var backgroundColorObservable: Observable<UIColor>! // Create observable that will change backgroundColor based on center

    init() {
        setup()
    }

    setup() {
    }
}

완벽합니다. 이제 backgroundColorObservable 를 설정할 때는 centerVariable 가 제공하는 CGPoint 값에 기반해서 변경해야 합니다.

func setup() {
    // 새로운 중앙 값을 받으면, 새로운 UIColor를 반환합니다
    backgroundColorObservable = centerVariable.asObservable()
        .map { center in
            guard let center = center else { return UIColor.flatten(.black)() }

            let red: CGFloat = ((center.x + center.y) % 255.0) / 255.0 // We just manipulate red, but you can do w/e
            let green: CGFloat = 0.0
            let blue: CGFloat = 0.0

            return UIColor.flatten(UIColor(red: red, green: green, blue: blue, alpha: 1.0))()
        }
}

순서대로 해봅시다:

  1. Variable을 Observable 로 변경합니다. VariableObserverObservable 둘 다 될 수 있기 때문에, 하나를 결정해야 합니다. 우린 이것을 관찰 하고 싶기 때문에, Observable 로 변경할 것 입니다.

  2. 모든 새로운 CGPoint의 값을 UIColor로 연결합니다. 우리는 Observable 이 생성하는 새로운 중앙 값을 받게되고, 정말 복잡한 수학 계산에 기반해서 새로운 UIColor를 만듭니다.

  3. Observable 이 옵셔널 CGPoint일 것입니다. 왜그럴까요? nil을 받을 때를 대비해서 디폴트 색을 반환하기 위함입니다. (우리의 경우엔 검은색을 반환할 것입니다.)

끝에 거의 도착했습니다! 이제 원의 위치에 따른 새로운 배경색을 반환하는 Observable을 가지게 되었습니다. 나머지 할 일은 새로운 값을 원에 업데이트 하는 것입니다. 이건 매우 쉬운 일입니다. 이건 1편에서 한 것과 비슷합니다. Observablesubscribe()할 것입니다.

// ViewModel의 새로운 색을 얻기 위해 backgroundObservable을 구독(Subscribe)합니다.
circleViewModel.backgroundColorObservable
    .subscribe(onNext: { [weak self] backgroundColor in
        UIView.animateWithDuration(0.1) {
            self?.circleView.backgroundColor = backgroundColor
            // 주어진 배경색의 상호 보완적인 색을 구합니다
            let viewBackgroundColor = UIColor(complementaryFlatColorOf: backgroundColor)
            // 새로운 배경색과 기존의 배경색이 다른지 검사합니다
            if viewBackgroundColor != backgroundColor {
                // 원의 배경색으로 새로운 배경색을 할당합니다
                // 우린 그저 뷰에서의 원이 보일 수 있는 다른 색을 원합니다
                self?.view.backgroundColor = viewBackgroundColor
            }
        }
    })
    .addDisposableTo(disposeBag)

보시다시피, 원의 배경색에 상호 보완적인 색으로 뷰의 배경색을 바꾸는 것도 추가했습니다. 또한 상호 보완적인 색이 원의 색과 같은지 체크하는 부분도 있습니다. (원이 적어도 보여야 하니까요!) 하지만 이것은 하나의 기능이지, 주요 업무가 아닙니다. 그래서 이 코드를 setup() 메소드에 넣을 것입니다:

func setup() {
    // 원 모양의 뷰를 추가합니다
    circleView = UIView(frame: CGRect(origin: view.center, size: CGSize(width: 100.0, height: 100.0)))
    circleView.layer.cornerRadius = circleView.frame.width / 2.0
    circleView.center = view.center
    circleView.backgroundColor = .green
    view.addSubview(circleView)

    circleViewModel = CircleViewModel()
    // CircleView의 중앙 지점을 centerObservable에 묶습니다(Bind).
    circleView
        .rx.observe(CGPoint.self, "center")            
        .bindTo(circleViewModel.centerVariable)
        .addDisposableTo(disposeBag)

    // ViewModel의 새로운 색을 얻기 위해 backgroundObservable을 구독(Subscribe)합니다.
    circleViewModel.backgroundColorObservable
        .subscribe(onNext: { [weak self] backgroundColor in
            UIView.animateWithDuration(0.1) {
                self?.circleView.backgroundColor = backgroundColor
                // 주어진 배경색의 상호 보완적인 색을 구합니다
                let viewBackgroundColor = UIColor(complementaryFlatColorOf: backgroundColor)
                // 새로운 배경색과 기존의 배경색이 다른지 검사합니다
                if viewBackgroundColor != backgroundColor {
                    // 원의 배경색으로 새로운 배경색을 할당합니다
                    // 우린 그저 뷰에서의 원이 보일 수 있는 다른 색을 원합니다
                    self?.view.backgroundColor = viewBackgroundColor
                }
            }
        })
        .addDisposableTo(disposeBag)

    let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(circleMoved(_:)))
    circleView.addGestureRecognizer(gestureRecognizer)
}

끝입니다! 위의 색상을 조정하는 모든 작업은 델리게이트, 노티피케이션 그리고 온갖 상용구 코드 없이 만들었습니다. 결과는 시작 부분에 있는 예제와 비슷할 것입니다.

이제 커스터마이징도 시도해 볼 수 있습니다! 중앙과 원의 사이즈를 묶는 바인딩도 추가할 수 있겠죠? 너비높이를 기반으로 cornerRadius를 바꾸는 것도 시도해 볼 수 있겠죠? 그것은 여러분에게 달려있지만 제 생각엔 Rx와 함께 그 작업들을 한다면 정말 기분 좋을 것입니다.

오늘은 여기까집니다! 질문은 언제나 환영입니다! 전체 프로젝트는 깃헙에서 확인하실 수 있습니다!

전체 소스 코드는 Droids on Roids의 깃헙에서 볼 수 있고 여기서 다른 RxSwift 예제들을 확인할 수 있습니다!

추신. 저는 매주 많은 예제를 구현하고 있으니 제 레포구독(😎) 해주세요!

RxSwift에 관련된 다른 게시글을 읽어보세요 (영어 원문)

» 예제로 시작하는 RxSwift #1 - 기초

» 예제로 시작하는 RxSwift #3 – 네트워킹.

RxSwift에 관련된 다른 게시글을 읽어보세요 (한글 번역)

» 예제로 시작하는 RxSwift #1 - 기초

» 예제로 시작하는 RxSwift #3 – 네트워킹.