如何使用 MVVM/RxSwift 根据来自其他单元格的值更新 tableview 的单元格?

How to update a tableview's cell based on values from other cells using MVVM/RxSwift?

我是 RxSwift 的新手,正在尝试通过创建一个简单的注册表单来学习。我想用 UITableView 来实现它(作为练习,而且以后会变得更复杂)所以我目前使用两种类型的单元格:

为了表示每个单元格,我创建了一个如下所示的枚举:

enum FormElement {
    case textInput(placeholder: String, text: String?)
    case button(title: String, enabled: Bool)
}

并在 Variable 中使用它来提供表格视图:

    formElementsVariable = Variable<[FormElement]>([
        .textInput(placeholder: "username", text: nil),
        .textInput(placeholder: "password", text: nil),
        .textInput(placeholder: "password, again", text: nil),
        .button(title: "create account", enabled: false)
        ])

像这样绑定:

    formElementsVariable.asObservable()
        .bind(to: tableView.rx.items) {
            (tableView: UITableView, index: Int, element: FormElement) in
            let indexPath = IndexPath(row: index, section: 0)
            switch element {
            case .textInput(let placeholder, let defaultText):
                let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell", for: indexPath) as! TextInputTableViewCell
                cell.textField.placeholder = placeholder
                cell.textField.text = defaultText
                return cell
            case .button(let title, let enabled):
                let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonTableViewCell", for: indexPath) as! ButtonTableViewCell
                cell.button.setTitle(title, for: .normal)
                cell.button.isEnabled = enabled
                return cell
            }
        }.disposed(by: disposeBag)

到目前为止,一切顺利 - 这是我的表单的样子:

现在,我在这里面临的实际问题是 当所有 3 个文本输入都不可用时,我应该如何启用 创建帐户 按钮空并且两个密码文本字段中的密码相同?换句话说,根据一个或多个其他单元格上发生的事件,将更改应用到单元格的正确方法是什么?

我的目标应该是通过 ViewModel 来改变这个 formElementsVariable 还是有更好的方法来实现我想要的?

首先,您可能想尝试 RxDataSources,它是 TableView 的 RxSwift 包装器。其次,为了回答您的问题,我会通过 ViewModel 进行更改——也就是说,为单元格提供一个 ViewModel,然后在 ViewModel 中设置一个将处理验证的可观察对象。当所有这些都设置好后,对所有单元格的验证可观察对象执行 combineLatest

你最好一次发出 table 数据,而不是一次发出一行,否则你无法真正区分 a) 下一个事件是新行还是 b) 是这个下一个事件刷新我已经显示的一行。

考虑到这是一种方法。这将进入 ViewModel 并将 table 数据呈现为可观察数据。然后,您可以将 username/password 的文本字段绑定到属性(行为中继),但最好不要将它们暴露给 UI(隐藏在属性后面)

var userName = BehaviorRelay<String>(value: "")
var password1 = BehaviorRelay<String>(value: "")
var password2 = BehaviorRelay<String>(value: "")

struct LoginTableValues {
    let username: String
    let password1: String
    let password2: String
    let createEnabled: Bool
}

func tableData() -> Observable<LoginTableValues> {
    let createEnabled = Observable.combineLatest(userName.asObservable(), password1.asObservable(), password2.asObservable())
        .map { (username: String, password1: String, password2: String) -> Bool in
            return !username.isEmpty &&
                !password1.isEmpty &&
                password1 == password2
        }

    return Observable.combineLatest(userName.asObservable(), password1.asObservable(), password2.asObservable(), createEnabled)
        .map { (arg: (String, String, String, Bool)) -> LoginTableValues in
            let (username, password1, password2, createEnabled) = arg
            return LoginTableValues(username: username, password1: password1, password2: password2, createEnabled: createEnabled)
        }
}

我建议您稍微更改一下 ViewModel,以便更好地控制文本字段中的更改。如果您从输入字段(例如用户名、密码和确认)创建流,您可以订阅更改并以任何您想要的方式对其做出反应。

以下是我如何对您的代码进行一些重组以处理文本字段中的更改。

internal enum FormElement {
    case textInput(placeholder: String, variable: Variable<String>)
    case button(title: String)
}

视图模型。

internal class ViewModel {

    let username = Variable("")
    let password = Variable("")
    let confirmation = Variable("")

    lazy var formElementsVariable: Driver<[FormElement]> = {
        return Observable<[FormElement]>.of([.textInput(placeholder: "username",
                                                          variable: username),
                                               .textInput(placeholder: "password",
                                                          variable: password),
                                               .textInput(placeholder: "password, again",
                                                          variable: confirmation),
                                               .button(title: "create account")])
            .asDriver(onErrorJustReturn: [])
    }()

    lazy var isFormValid: Driver<Bool> = {
        let usernameObservable = username.asObservable()
        let passwordObservable = password.asObservable()
        let confirmationObservable = confirmation.asObservable()

        return Observable.combineLatest(usernameObservable,
                                        passwordObservable,
                                        confirmationObservable) { [unowned self] username, password, confirmation in
                                            return self.validateFields(username: username,
                                                                       password: password,
                                                                       confirmation: confirmation)
            }.asDriver(onErrorJustReturn: false)
    }()

    fileprivate func validateFields(username: String,
                                    password: String,
                                    confirmation: String) -> Bool {

        guard username.count > 0,
            password.count > 0,
            password == confirmation else {
                return false
        }

        // do other validations here

        return true
    }
}

ViewController,

internal class ViewController: UIViewController {
    @IBOutlet var tableView: UITableView!

    fileprivate var viewModel = ViewModel()

    fileprivate let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.formElementsVariable.drive(tableView.rx.items) { [unowned self] (tableView: UITableView, index: Int, element: FormElement) in

                let indexPath = IndexPath(row: index, section: 0)

                switch element {

                case .textInput(let placeholder, let variable):

                    let cell = self.createTextInputCell(at: indexPath,
                                                        placeholder: placeholder)

                    cell.textField.text = variable.value
                    cell.textField.rx.text.orEmpty
                        .bind(to: variable)
                        .disposed(by: cell.disposeBag)
                    return cell

                case .button(let title):
                    let cell = self.createButtonCell(at: indexPath,
                                                     title: title)
                    self.viewModel.isFormValid.drive(cell.button.rx.isEnabled)
                        .disposed(by: cell.disposeBag)
                    return cell
                }
            }.disposed(by: disposeBag)
    }

    fileprivate func createTextInputCell(at indexPath:IndexPath,
                                         placeholder: String) -> TextInputTableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell",
                                                 for: indexPath) as! TextInputTableViewCell
        cell.textField.placeholder = placeholder
        return cell
    }

    fileprivate func createButtonCell(at indexPath:IndexPath,
                                      title: String) -> ButtonInputTableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonInputTableViewCell",
                                                 for: indexPath) as! ButtonInputTableViewCell
        cell.button.setTitle(title, for: .normal)
        return cell
    }
}

我们根据三个不同的变量来启用禁用按钮,您可以在这里看到流和 rx 运算符的强大功能。

我认为将普通属性转换为 Rx 总是好的,因为它们在我们的例子中有很多变化,比如用户名、密码和密码字段。可以看到 formElementsVariable 没有太大变化,除了创建单元格的神奇 tableview 绑定之外,它没有 Rx 的真正附加值。

我认为您在 FormElement 中缺少适当的 rx 属性,这些属性将使您能够将 UI 事件绑定到要在 ViewModel 中执行的验证。

FormElement 开始,textInput 应该公开一个 text Variablebutton 一个 已启用 Driver。我做出这种区分是为了展示在第一种情况下您想要使用 UI 事件,而在第二种情况下您只想更新 UI。

enum FormElement {
   case textInput(placeholder: String, text: Variable<String?>)
   case button(title: String, enabled:Driver<Bool>, tapped:PublishRelay<Void>)
}

我冒昧地添加了一个 tapped 事件,当按钮最终启用时,您可以执行您的业务逻辑!

继续 ViewModel,我只公开了 View 需要知道的内容,但在内部我应用了所有必要的运算符:

class FormViewModel {

    // what ViewModel exposes to view
    let formElementsVariable: Variable<[FormElement]>
    let registerObservable: Observable<Bool>

    init() {
        // form element variables, the middle step that was missing...
        let username = Variable<String?>(nil) // docs says that Variable will deprecated and you should use BehaviorRelay...
        let password = Variable<String?>(nil) 
        let passwordConfirmation = Variable<String?>(nil)
        let enabled: Driver<Bool> // no need for Variable as you only need to emit events (could also be an observable)
        let tapped = PublishRelay<Void>.init() // No need for Variable as there is no need for a default value

        // field validations
        let usernameValidObservable = username
            .asObservable()
            .map { text -> Bool in !(text?.isEmpty ?? true) }

        let passwordValidObservable = password
            .asObservable()
            .map { text -> Bool in text != nil && !text!.isEmpty && text!.count > 5 }

        let passwordConfirmationValidObservable = passwordConfirmation
            .asObservable()
            .map { text -> Bool in text != nil && !text!.isEmpty && text!.count > 5 }

        let passwordsMatchObservable = Observable.combineLatest(password.asObservable(), passwordConfirmation.asObservable())
            .map({ (password, passwordConfirmation) -> Bool in
                password == passwordConfirmation
            })

        // enable based on validations
        enabled = Observable.combineLatest(usernameValidObservable, passwordValidObservable, passwordConfirmationValidObservable, passwordsMatchObservable)
            .map({ (usernameValid, passwordValid, passwordConfirmationValid, passwordsMatch) -> Bool in
                usernameValid && passwordValid && passwordConfirmationValid && passwordsMatch // return true if all validations are true
            })
            .asDriver(onErrorJustReturn: false)

        // now that everything is in place, generate the form elements providing the ViewModel variables
        formElementsVariable = Variable<[FormElement]>([
            .textInput(placeholder: "username", text: username),
            .textInput(placeholder: "password", text: password),
            .textInput(placeholder: "password, again", text: passwordConfirmation),
            .button(title: "create account", enabled: enabled, tapped: tapped)
            ])

        // somehow you need to subscribe to register to handle for button clicks...
        // I think it's better to do it from ViewController because of the disposeBag and because you probably want to show a loading or something
        registerObservable = tapped
            .asObservable()
            .flatMap({ value -> Observable<Bool> in
                // Business login here!!!
                NSLog("Create account!!")
                return Observable.just(true)
            })
    }
}

最后,在您的 View 上:

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    private let disposeBag = DisposeBag()

    var formViewModel: FormViewModel = FormViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.register(UINib(nibName: "TextInputTableViewCell", bundle: nil), forCellReuseIdentifier: "TextInputTableViewCell")
        tableView.register(UINib(nibName: "ButtonTableViewCell", bundle: nil), forCellReuseIdentifier: "ButtonTableViewCell")

        // view subscribes to ViewModel observables...
        formViewModel.registerObservable.subscribe().disposed(by: disposeBag)

        formViewModel.formElementsVariable.asObservable()
            .bind(to: tableView.rx.items) {
                (tableView: UITableView, index: Int, element: FormElement) in
                let indexPath = IndexPath(row: index, section: 0)
                switch element {
                case .textInput(let placeholder, let defaultText):
                    let cell = tableView.dequeueReusableCell(withIdentifier: "TextInputTableViewCell", for: indexPath) as! TextInputTableViewCell
                    cell.textField.placeholder = placeholder
                    cell.textField.text = defaultText.value
                    // listen to text changes and pass them to viewmodel variable
                    cell.textField.rx.text.asObservable().bind(to: defaultText).disposed(by: self.disposeBag)
                    return cell
                case .button(let title, let enabled, let tapped):
                    let cell = tableView.dequeueReusableCell(withIdentifier: "ButtonTableViewCell", for: indexPath) as! ButtonTableViewCell
                    cell.button.setTitle(title, for: .normal)
                    // listen to viewmodel variable changes and pass them to button
                    enabled.drive(cell.button.rx.isEnabled).disposed(by: self.disposeBag)
                    // listen to button clicks and pass them to the viewmodel
                    cell.button.rx.tap.asObservable().bind(to: tapped).disposed(by: self.disposeBag)
                    return cell
                }
            }.disposed(by: disposeBag)
        }
    }
}

希望我有所帮助!

PS。我主要是一名 Android 开发人员,但我发现你的问题(和赏金)很有趣,所以请原谅 (rx)swift

的任何粗糙边缘