27
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RxSwiftAdvent Calendar 2016

Day 11

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

Posted at

概要

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をいくらでも書けるので、
他にも活用できる場所は多そうです。
今回紹介したコードで改善できる箇所がありましたら、コメントをいただけると嬉しいです!

27
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?