将 SwiftUI 按钮绑定到 AnySubscriber,例如 RxCocoa 的按钮点击

Posted

技术标签:

【中文标题】将 SwiftUI 按钮绑定到 AnySubscriber,例如 RxCocoa 的按钮点击【英文标题】:Binding a SwiftUI Button to AnySubscriber like RxCocoa's button tap 【发布时间】:2020-01-21 20:23:31 【问题描述】:

我使用以下基于UIViewControllerRxSwift/RxCocoa 的代码编写了一个非常简单的MVVM 模式来绑定UIButton 点击事件来触发一些Observable 工作并监听结果:

import UIKit
import RxSwift
import RxCocoa

class ViewController: UIViewController 

    @IBOutlet weak var someButton: UIButton!

    var viewModel: ViewModel!
    private var disposeBag = DisposeBag()

    override func viewDidLoad() 
        super.viewDidLoad()
        viewModel = ViewModel()
        setupBindings()
    

    private func setupBindings() 
        someButton.rx.tap
        .bind(to: self.viewModel.input.trigger)
        .disposed(by: disposeBag)

        viewModel.output.result
            .subscribe(onNext:  element in
            print("element is \(element)")
            ).disposed(by: disposeBag)
    


class ViewModel 

    struct Input 
        let trigger: AnyObserver<Void>
    

    struct Output 
        let result: Observable<String>
    

    let input: Input
    let output: Output

    private let triggerSubject = PublishSubject<Void>()

    init() 
        self.input = Input(trigger: triggerSubject.asObserver())
        let resultObservable = triggerSubject.flatMap  Observable.just("TEST") 
        self.output = Output(result: resultObservable)
    

它编译并运行良好。但是,我需要用SwiftUICombinify 这个模式,所以我将该代码转换为以下代码:

import SwiftUI
import Combine

struct ContentView: View 
    var viewModel: ViewModel
    var subscriptions = Set<AnyCancellable>()

    init(viewModel: ViewModel) 
        self.viewModel = viewModel
        setupBindings()
    

    var body: some View 

        Button(action: 
            // <---- how to trigger viewModel's trigger from here
        , label: 
            Text("Click Me")
        )
    

    private func setupBindings() 
        self.viewModel.output.result.sink(receiveValue:  value in
            print("value is \(value)")
            )
            .store(in: &subscriptions) // <--- doesn't compile due to immutability of ContentView
    


class ViewModel 

    struct Input 
        let trigger: AnySubscriber<Void, Never>
    

    struct Output 
        let result: AnyPublisher<String, Never>
    

    let input: Input
    let output: Output

    private let triggerSubject = PassthroughSubject<Void, Never>()

    init() 
        self.input = Input(trigger: AnySubscriber(triggerSubject))

        let resultPublisher = triggerSubject
            .flatMap  Just("TEST") 
            .eraseToAnyPublisher()

        self.output = Output(result: resultPublisher)
    


由于两个错误(在代码中注释),此示例无法编译:

(1)问题一:如何像上面RxSwift的情况一样,从按钮的actionclosure中触发发布者的工作?

(2) 问题 2 与架构设计有关,而不是编译错误: 错误说:... Cannot pass immutable value as inout argument: 'self' is immutable ...,那是因为SwiftUI 视图是结构,它们被设计为只能通过各种绑定(@State@ObservedObject 等...)进行更改,我有两个相关的子问题问题2:

[A]:在SwiftUI 视图中sink 发布者是否被认为是一种不好的做法?这可能需要一些解决方法来将cancellable 存储在View 的结构范围内?

[B]:就 MVVM 架构模式而言,哪个更适合 SwiftUI/Combine 项目:使用具有 [ Input[Subscribers]、Output[AnyPublishers] ] 模式的 ViewModel,或者 ObservableObject ViewModel 带有 [@Published 属性]?

【问题讨论】:

如果有人开始回答他们会重复官方文档,所以从SwiftUI: State and Data Flow开始。简而言之,使用ObservableObject - 它是原生的、简单的,并且自动与 SwiftUI 集成。 【参考方案1】:

我在理解最佳 mvvm 方法时遇到了同样的问题。 推荐也看看这个帖子Best data-binding practice in Combine + SwiftUI?

将发布我的工作示例。应该很容易转换成你想要的。

SwiftUI 视图:

struct ContentView: View 
    @State private var dataPublisher: String = "ggg"
    @State private var sliderValue: String = "0"
    @State private var buttonOutput: String = "Empty"


    let viewModel: SwiftUIViewModel
    let output: SwiftUIViewModel.Output

    init(viewModel: SwiftUIViewModel) 
        self.viewModel = viewModel
        self.output = viewModel.bind(())
    

    var body: some View 
        VStack 
            Text(self.dataPublisher)
            Text(self.sliderValue)
            Slider(value: viewModel.$sliderBinding, in: 0...100, step: 1)
            Button(action: 
                self.viewModel.buttonBinding = ()
            , label: 
                Text("Click Me")
            )
            Text(self.buttonOutput)
        
        .onReceive(output.dataPublisher)  value in
            self.dataPublisher = value
        
        .onReceive(output.slider)  (value) in
            self.sliderValue = "\(value)"
        
        .onReceive(output.resultPublisher)  (value) in
            self.buttonOutput = value
        
    

AbstractViewModel:

protocol ViewModelProtocol 
    associatedtype Output
    associatedtype Input

    func bind(_ input: Input) -> Output

视图模型:

final class SwiftUIViewModel: ViewModelProtocol 
    struct Output 
        let dataPublisher: AnyPublisher<String, Never>
        let slider: AnyPublisher<Double, Never>
        let resultPublisher: AnyPublisher<String, Never>
    

    typealias Input = Void

    @SubjectBinding var sliderBinding: Double = 0.0
    @SubjectBinding var buttonBinding: Void = ()

    func bind(_ input: Void) -> Output 
        let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
            .delay(for: 5.0, scheduler: DispatchQueue.main)
            .map "Just for testing - \($0)"
            .replaceError(with: "An error occurred")
            .receive(on: DispatchQueue.main)
            .share()
            .eraseToAnyPublisher()

        let resultPublisher = _buttonBinding.anyPublisher()
            .dropFirst()
            .flatMap  Just("TEST") 
            .share()
            .eraseToAnyPublisher()

        return Output(dataPublisher: dataPublisher,
                      slider: _sliderBinding.anyPublisher(),
                      resultPublisher: resultPublisher)
    

SubjectBinding 属性包装器:

@propertyWrapper
struct SubjectBinding<Value> 
    private let subject: CurrentValueSubject<Value, Never>

    init(wrappedValue: Value) 
        subject = CurrentValueSubject<Value, Never>(wrappedValue)
    

    func anyPublisher() -> AnyPublisher<Value, Never> 
        return subject.eraseToAnyPublisher()
    

    var wrappedValue: Value 
        get 
            return subject.value
        
        set 
            subject.value = newValue
        
    

    var projectedValue: Binding<Value> 
        return Binding<Value>(get:  () -> Value in
            return self.subject.value
        )  (value) in
            self.subject.value = value
        
    

【讨论】:

检查 github.com/serbats/Reactive-Combine-MVVM-Templates 以获得一些 MVVM Xcode 模板【参考方案2】:

所以我最近也想知道如何做到这一点,因为我们还没有开始在 SwiftUI 中写出视图。

我创建了一个辅助对象,它封装了从函数调用到发布者的转换。我称它为中继。

@available(ios 13.0, *)
struct Relay<Element> 
    var call: (Element) -> Void  didCall.send 
    var publisher: AnyPublisher<Element, Never>  
        didCall.eraseToAnyPublisher() 
    

    // MARK: Private

    private let didCall = PassthroughSubject<Element, Never>()

具体来说,您可以声明一个私有中继并像这样使用它;

    Button(action: relay.call, 
    label: 
        Text("Click Me")
    )

然后你就可以随心所欲了。

relay.publisher

【讨论】:

这很好!通过在视图中公开 didTapLogin: Relay 帮助我将 UViewController(它是 UIHostingController 的子类)绑定到 SwiftUI 视图的按钮点击

以上是关于将 SwiftUI 按钮绑定到 AnySubscriber,例如 RxCocoa 的按钮点击的主要内容,如果未能解决你的问题,请参考以下文章

在 SwiftUI 中重置绑定变量

如何使用 SwiftUI 将数组绑定到列表

SwiftUI 绑定到父视图重新渲染子视图

从按钮操作中更改时未触发 SwiftUI 绑定

有没有办法使用 SwiftUI 将选项绑定到 Toggle / Slider

SwiftUI,如何将 EnvironmnetObject Int 属性绑定到 TextField?