iOS
Swift
RxSwift
RxSwiftDay 11

RxCocoaのUIBindingObserverでLINEのようなUIテーマを全てのViewに反映させるサンプルコード

More than 1 year has passed since last update.


概要

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)
}
}


テーマの準備

ThemeThemeServiceはアプリごとで適当に定義してください。今回はラベルやボタンごとにテーマの適用方法を決められるようにネストした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)
}
}
}
}


完成品

これで以下のように書くことができます。

注意点としては、UIBindingObserverUIElement(今回でいう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をいくらでも書けるので、

他にも活用できる場所は多そうです。

今回紹介したコードで改善できる箇所がありましたら、コメントをいただけると嬉しいです!