はじめに
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に適応させることができます。
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することでスライダーに追従した動きを実現しています。
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を実装できます。
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
で定義されたレイアウトは、新たにストリームが流れるとリフレッシュされます。
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の流すストリームに合わせてレイアウトを切り替えることになるかと思います。