前言

這幾天有空就玩一下去年蘋果新推出的兩大功能 - Combine & SwiftUI。

SwiftUI 對我來說是一個全新的境界,跟之前碰過的程式語言都較為不同,目前還在熟悉中。
但是 Apple 推出的響應式編程 - Combine,以之前已經碰過 RxSwift 的我來說較為熟悉。
觀念大致相同,不同的是用法跟語法吧。

但就在我練習當中發現 Combine 跟 UIKit 的相容性似乎比較差(也可以說完全沒有= =),可能因為有 SwiftUI 輔助了吧。
所以就上網查了一下相關資訊,發現蠻多人是使用 UIKit + Combine ,只不過 Publisher 要自己寫就是。


Create Custom Publisher

在 “How to create custom Publisher in Combine” 裡面有指出如果要做出一個客製化的 Publisher ,需要三件事情:

  1. Publisher
  2. Subscription
  3. Subscriber

發布者(Publisher)

簡單來說,可以先把他看做是報社的角色,他的職責就是負責製作報紙、送報紙。
而在這邊我們要送的是 UIControl ,因為所有的元件都繼承這個 class 。所以我們就先製作一個 送 UIControl 的 Publisher。
如果有看到 Combine 的 framework,就可以發現 Publisher 其實是一個 protocol 而非實體

public protocol Publisher {

    /// The kind of values published by this publisher.
    associatedtype Output

    /// The kind of errors this publisher might publish.
    ///
    /// Use `Never` if this `Publisher` does not publish errors.
    associatedtype Failure : Error

    /// Attaches the specified subscriber to this publisher.
    ///
    /// Implementations of ``Publisher`` must implement this method.
    ///
    /// The provided implementation of ``Publisher/subscribe(_:)-4u8kn``calls this method.
    ///
    /// - Parameter subscriber: The subscriber to attach to this ``Publisher``, after which it can receive values.
    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}

所以我們就產生一個 Class 來實作 Publisher

// Publisher
final class CustomePublisher<C:UIControl> : Publisher {
    typealias Output = C
    typealias Failure = Never
    
    func receive<S>(subscriber: S) where S : Subscriber, CustomePublisher.Failure == S.Failure, CustomePublisher.Output == S.Input {
        
    }
}

協議(Subscription)

也就是明定這份報紙在什麼情況下會送到訂閱用戶手上。

final class CustomSubscription<S: Subscriber, C: UIControl> : Subscription where S.Input == C {
    func request(_ demand: Subscribers.Demand) {
        //TODO:...
    }
    
    func cancel() {
        //TODO:...
    }
}

訂閱者(Subscriber)

也就是訂閱用戶,用戶可以向報社訂閱報紙。

protocol CombineCompatible { }
extension UIControl: CombineCompatible { }
// Subscriber
extension CombineCompatible where Self:UIControl {
    func controlEvent(for event:UIControl.Event) -> CustomePublisher<UIControl> {
        
    }
}

到這邊為止,所有 Class 的架構已經做好,接下來就要進行內部實作。
就先從 訂閱者(Subscriber) 下手,目前在 extension 的 func controlEvent(for:) 還沒有回傳一個CustomePublisher,而我們需要在 UIControl 的 addTarget 去把事件註冊進去。
所以就在 CustomePublisher 新增建構子

final class CustomePublisher<C:UIControl> : Publisher {
    typealias Output = C
    typealias Failure = Never

    //=== 新增建構子===
    private let control:C
    private let event:UIControl.Event
    init(control:C, event:UIControl.Event) {
        self.control = control
        self.event = event
    }
    
    func receive<S>(subscriber: S) where S : Subscriber, CustomePublisher.Failure == S.Failure, CustomePublisher.Output == S.Input {
        //TODO:...
    }
}

// Subscriber
extension CombineCompatible where Self:UIControl {
    func controlEvent(for event:UIControl.Event) -> CustomePublisher<UIControl> {
        //=== 補上init function ===
        return CustomePublisher(control: self, event: event)
    }
}

Subscription (協議)

接下來是最重要的部分 - Subscription,就在上面我們創造了一個名為 CustomSubscription 的協議,這個協議裡面就會寫著(UIControl)在什麼時間點(UIControl.Event)做什麼事情(#selector(eventHandler)),實際程式碼如下:

final class CustomSubscription<S: Subscriber, C: UIControl> : Subscription where S.Input == C {
    
    private var subscriber:S?
    private let control:C
    
    init(subscriber:S, control:C, event:UIControl.Event) {
        self.subscriber = subscriber
        self.control = control
        
        self.control.addTarget(self, action: #selector(eventHandler), for: event)
    }
    
    func request(_ demand: Subscribers.Demand) {
        //TODO:...
    }
    
    func cancel() {
        subscriber = nil
    }
    
    @objc private func eventHandler() {
        _ = subscriber?.receive(control)
    }
    
    deinit {
        control.removeTarget(self, action: nil, for: .allEvents)
    }
}

//CustomePublisher
func receive<S>(subscriber: S) where S : Subscriber, CustomePublisher.Failure == S.Failure, CustomePublisher.Output == S.Input {
    //補上程式碼
    let subscription = CustomSubscription(subscriber: subscriber, control: control, event: event)
    subscriber.receive(subscription: subscription)
}

寫到這邊就已經完成一個客製化的Publisher了。實際上的應用可以寫成這樣:

//UIButton Tap Event
_ = button.controlEvent(for: .touchUpInside).sink { (control) in
    print("touchUpInside")
}

//UISwitch Switch Event
_ = customSwitch.controlEvent(for: .valueChanged).sink(receiveValue: { (control) in
    print("valueChanged")
})

寫到這邊忽然發現一件事情,就是如果我要拿 UISwitch 裡面的值,在這個block裡面就要將 control 轉成 UISwitch 才能拿到裡面的 isOn 。

_ = switchTerms.controlEvent(for: .valueChanged).sink(receiveValue: { (control) in
    if let mSwitch = control as? UISwitch { //這邊每次都要轉換成該元件才能拿到 UI 元件的相關資料
        print(mSwitch.isOn)
    }
})

那有沒有辦法是直接就轉成該元件就好,不需要再自己轉換一次呢?有的,我們把原本 controlEvent 回傳的值從 UIControl 改成 Self 就好了唷。

func controlEvent(for event:UIControl.Event) -> CustomePublisher<UIControl> {
    return .init(control: self, event: event)
}
    
func controlEvent(for event:UIControl.Event) -> CustomePublisher<Self> {
    return .init(control: self, event: event)
}

再把想要擴充的元件加上一個 Extension

extension UISwitch {
    var valueChanged:CustomePublisher<UISwitch> {
        return ControlEvent(for: .valueChanged)
    }
}

_ = customSwitch.valueChanged.sink(receiveValue: { (s) in
    print(s.isOn)
})

//也可以跟其他元件做綁定
customSwitch.valueChanged.map({ $0.isOn })
            .assign(to: \.isEnabled, on: button)
            .store(in: &cancellables)

這樣是不是很方便呢?


這幾天測試下來的心得,Combine 如果使用在 Model 層面個人覺得是綽綽有餘了, 雖然它對 UI 的支援性不夠好,但是有 SwiftUI 做輔助也夠了。如果真的再不行,網路上也是很多大神針對 Combine + UIKit 做很好的擴充。

今天這篇文章就到這邊,希望能幫上你們的忙唷。

GitHub连结


Reference