[iOS][Swift]SnapKitをRxSwiftと連携する

# はじめに
iOSでコードベースでレイアウトを組む際に便利なSnapKitと、振る舞いを宣言的に記述できるRxSwiftを連携する方法について説明します。

# 前提
以下のライブラリについて基本的な知識を有している方向けの記事です

- RxSwift https://github.com/ReactiveX/RxSwift
- RxCocoa https://github.com/ReactiveX/RxSwift/tree/master/RxCocoa
- SnapKit https://github.com/SnapKit/SnapKit

# サンプルアプリ
https://github.com/idomazine/RxSnapKit
`git clone`後、 `pod install` を実行しXCodeでビルドします。

# RxでConstraintの値を操作

以下のように記述することでSnapKit.ConstraintのプロパティをRxのBinderに適応させることができます。

```swift
import RxSwift
import RxCocoa
import SnapKit

extension Constraint: ReactiveCompatible { }
extension Reactive where Base: Constraint {
    var isActive: Binder<Bool> {
        Binder(base) { constraint, isActive in
            constraint.isActive = isActive
        }
    }
    
    var offset: Binder<Float> {
        Binder(base) { constraint, offset in
            constraint.update(offset: offset)
        }
    }
    
    var inset: Binder<Float> {
        Binder(base) { constraint, inset in
            constraint.update(inset: inset)
        }
    }
    
    var priority: Binder<ConstraintPriority> {
        Binder(base) { constraint, priority in
            constraint.update(priority: priority)
        }
    }
}
```

サンプルアプリの以下の例では、UISliderのValueを真ん中のビューのcenterXに対応するconstraintのoffsetにbindすることでスライダーに追従した動きを実現しています。

![slider.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16785/cc1bf7fe-302b-847a-4271-b8e050450370.gif)


```swift
import UIKit
import RxSwift
import RxCocoa
import SnapKit

final class SliderViewController: UIViewController {
    private var flexibleView: UIView!
    private var centerXConstraint: Constraint!
    private var slider: UISlider!

    private let disposeBag = DisposeBag()

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        title = "Slider"
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        super.loadView()
        view.backgroundColor = .systemPurple

        flexibleView = {
            let flexibleView = UIView()
            flexibleView.backgroundColor = .systemPink
            view.addSubview(flexibleView)
            flexibleView.snp.makeConstraints {
                $0.centerY.equalToSuperview()
                $0.height.width.equalTo(44)
                centerXConstraint = $0.centerX.equalToSuperview().constraint
            }
            return flexibleView
        }()

        slider = {
            let slider = UISlider()
            slider.minimumValue = -50
            slider.maximumValue = 50
            slider.value = 0
            slider.minimumTrackTintColor = .systemRed
            slider.maximumTrackTintColor = .systemBlue
            view.addSubview(slider)
            slider.snp.makeConstraints {
                $0.top.equalTo(flexibleView.snp.bottom).offset(10)
                $0.width.equalToSuperview().offset(-20)
                $0.centerX.equalToSuperview()
            }
            return slider
        }()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Sliderの値をConstraintのoffset値にバインドしている
        slider.rx.value
            .bind(to: centerXConstraint.rx.offset)
            .disposed(by: disposeBag)
    }
}

```

# Rxで動的にレイアウトを定義

以下のように記述することでストリームで流れてくる値に合わせたレイアウトの定義を行い、自動でConstraintの切り替えを行うBinderを実装できます。

```swift
extension Reactive where Base: UIView {
    func makeConstraints<T>(makes: @escaping ((ConstraintMaker, T) -> Void)) -> Binder<T> {
        var constraints: [Constraint] = []
        return Binder(base) { view, value in
            constraints.forEach { $0.deactivate() }
            constraints = view.snp.prepareConstraints { maker in makes(maker, value) }
            constraints.forEach { $0.activate() }
        }
    }
}

```

サンプルアプリの以下の例では、真ん中の四角いビューがLeftボタンを押すと左に、Rightボタンを押すと右に移動します。
`rx.makeConstraints`で定義されたレイアウトは、新たにストリームが流れるとリフレッシュされます。


![lr.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16785/7f2a7957-ea6a-f884-5961-7dc5be830675.gif)

```swift

import RxSwift
import RxCocoa
import SnapKit

final class LeftRightViewController: UIViewController {
    private var boxView: UIView!
    private var leftButton: UIButton!
    private var rightButton: UIButton!

    private let disposeBag = DisposeBag()
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        title = "LR"
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        super.loadView()
        view.backgroundColor = .systemPurple
        boxView = {
            let boxView = UIView()
            boxView.backgroundColor = .systemGreen
            view.addSubview(boxView)
            boxView.snp.makeConstraints {
                $0.width.height.equalTo(44)
                $0.centerX.equalToSuperview().priority(.high)
                $0.centerY.equalToSuperview()
            }
            return boxView
        }()

        leftButton = {
            let leftButton = UIButton()
            view.addSubview(leftButton)
            leftButton.setTitleColor(.white, for: .normal)
            leftButton.setTitle("Left", for: .normal)
            leftButton.snp.makeConstraints {
                $0.left.equalToSuperview().inset(20)
                $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(20)
            }
            return leftButton
        }()

        rightButton = {
            let rightButton = UIButton()
            view.addSubview(rightButton)
            rightButton.setTitleColor(.white, for: .normal)
            rightButton.setTitle("Right", for: .normal)
            rightButton.snp.makeConstraints {
                $0.right.equalToSuperview().inset(20)
                $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(20)
            }
            return rightButton
        }()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        let alignsLeft: Observable<Bool> = Observable.merge(leftButton.rx.tap.map { _ in true },
                                                            rightButton.rx.tap.map { _ in false})
        // Observableで流れてくる値に応じてレイアウトを切り替えている
        alignsLeft
            .bind(to: boxView.rx.makeConstraints { maker, alignsLeft in
                if alignsLeft {
                    maker.left.equalToSuperview().inset(10)
                } else {
                    maker.right.equalToSuperview().inset(10)
                }
            })
        .disposed(by: disposeBag)
    }
}

```

# 実践にあたって

サンプルアプリ内では単体のUIViewController内でRxのストリームが完結していますが、実際の開発で例えばMVVMなどを採用しているときなどはViewModelの流すストリームに合わせてレイアウトを切り替えることになるかと思います。