概要
RxSwift Advent Calendar 2016 の11日目の記事です。
LINEやEvernoteなどのようにUIのテーマを変えたいことがありますよね?
テーマをアプリ全体のViewに反映させるような場合に、RxCocoaのUIBindingObserverを使う方法を考えたので紹介します。
RxSwift/UIBindingObserver.swift at master · ReactiveX/RxSwift · GitHub
環境
RxSwift 3.0.1
Xcode 8.1(8B62)
Swift 3.0
UIBindingObserverとは
UIKitの各クラスのプロパティのObserverです。UILabelではlabel.rx.text
のようにアクセスできます。サンプルコードは以下です。
// サンプルコード
Observable<String?>.just("text").asDriver(onErrorJustReturn: nil)
.drive(label.rx.text)
.addDisposableTo(disposeBag)
// RxCocoa内の定義は以下のようになっています
extension Reactive where Base: UILabel {
/// Bindable sink for `text` property.
public var text: UIBindingObserver<Base, String?> {
return UIBindingObserver(UIElement: self.base) { label, text in
label.text = text
}
}
}
UIBindingObserverはUIElement
にAnyObject(大体UIKitのクラスになると思います)を渡し、binding
のクロージャ内でUIElement
にイベントの値をバインドします。上記UILabelの例でいうと、渡ってきたString?
の値をlabel.text
にバインドしています。UIBindingObserver のRxCocoa内の定義は以下のようになっています。
/**
Observer that enforces interface binding rules:
* can't bind errors (in debug builds binding of errors causes `fatalError` in release builds errors are being logged)
* ensures binding is performed on main thread
`UIBindingObserver` doesn't retain target interface and in case owned interface element is released, element isn't bound.
*/
public class UIBindingObserver<UIElementType, Value> : ObserverType where UIElementType: AnyObject {
public typealias E = Value
weak var UIElement: UIElementType?
let binding: (UIElementType, Value) -> Void
/// Initializes `ViewBindingObserver` using
public init(UIElement: UIElementType, binding: @escaping (UIElementType, Value) -> Void) {
self.UIElement = UIElement
self.binding = binding
}
/// Binds next element to owner view as described in `binding`.
public func on(_ event: Event<Value>) {
MainScheduler.ensureExecutingOnScheduler(errorMessage: "Element can be bound to user interface only on MainThread.")
switch event {
case .next(let element):
if let view = self.UIElement {
binding(view, element)
}
case .error(let error):
bindingErrorToInterface(error)
case .completed:
break
}
}
/// Erases type of observer.
///
/// - returns: type erased observer.
public func asObserver() -> AnyObserver<Value> {
return AnyObserver(eventHandler: on)
}
}
テーマの準備
Theme
やThemeService
はアプリごとで適当に定義してください。今回はラベルやボタンごとにテーマの適用方法を決められるようにネストしたstructで定義してみました。
struct Theme {
struct Label {
let textColor: UIColor
static let `default` = Label(textColor: .black)
}
struct Button {
let textColor: UIColor
let backgroundColor: UIColor
static let `default` = Button(textColor: .white, backgroundColor: .blue)
}
let label: Label
let button: Button
static let `default` = Theme(
label: Label.default,
button: Button.default
)
}
final class ThemeService {
static let instance = ThemeService()
let theme: Driver<Theme>
init() {
self.theme = Observable.just(Theme.default).asDriver(onErrorJustReturn: Theme.default)
}
}
目指すインターフェース
以下のようにView1個1個にバインドしていくのは簡単に書けるのですが、これは面倒なので避けたいです。
ThemeService.instance.theme
.drive(label.rx.driveTheme)
.addDisposableTo(disposeBag)
テーマを適用するボタンやラベルなどのViewの配列を渡して、テーマが変わるとすべてに適用されるようにします。以下が目指すインターフェースです。
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
@IBOutlet weak var button1: UIButton!
@IBOutlet weak var button2: UIButton!
let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
ThemeService.instance.theme
.drive([label, button1, button2].rx.driveTheme)
.addDisposableTo(disposeBag)
}
}
ThemeApplicableプロトコル
Viewそれぞれでテーマの適用方法を定義していきます。
そのためのプロトコルがThemeApplicable
です。
protocol ThemeApplicable: NSObjectProtocol {
func applyTheme(_ theme: Theme)
}
UILabelとUIButtonにテーマを適用していきます。
extension UILabel: ThemeApplicable {
func applyTheme(_ theme: Theme) {
textColor = theme.label.textColor
}
}
extension UIButton: ThemeApplicable {
func applyTheme(_ theme: Theme) {
setTitleColor(theme.button.textColor, for: .normal)
backgroundColor = theme.button.backgroundColor
}
}
テーマを適用するUIBindingObserverを書く
ThemeApplicable
を配列として持つThemeApplicableCollection
というクラスを定義します。
// .rxにアクセスできるようにReactiveCompatibleプロトコルを採用します。
final class ThemeApplicableCollection: ReactiveCompatible {
let items: [ThemeApplicable]
init(_ items: [ThemeApplicable]) {
self.items = items
}
}
あとはUIBindingObserver
のextensionを書くだけです。
// .rx.driveThemeにアクセスできるようにextensionを書きます。
extension Reactive where Base: ThemeApplicableCollection {
// 値はThemeにします。<, Theme>の部分
var driveTheme: UIBindingObserver<Base, Theme> {
return UIBindingObserver(UIElement: base) { collection, theme in
// Themeの値が渡ってきたら、すべてのViewに適用します。
collection.items.forEach {
$0.applyTheme(theme)
}
}
}
}
完成品
これで以下のように書くことができます。
注意点としては、UIBindingObserver
のUIElement
(今回でいうThemeApplicableCollection)がweakで定義されているため、プロパティとして定義してRetainしてあげないと動作しませんでした。
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
@IBOutlet weak var button1: UIButton!
@IBOutlet weak var button2: UIButton!
let disposeBag = DisposeBag()
var themeApplicableCollection: ThemeApplicableCollection!
override func viewDidLoad() {
super.viewDidLoad()
themeApplicableCollection = ThemeApplicableCollection([label, button1, button2])
ThemeService.instance.theme
.drive(themeApplicableCollection.rx.driveTheme)
.addDisposableTo(disposeBag)
}
}
最後に
extensionで独自のUIBindingObserverをいくらでも書けるので、
他にも活用できる場所は多そうです。
今回紹介したコードで改善できる箇所がありましたら、コメントをいただけると嬉しいです!