如何使用 MVVM/RxSwift 根据来自其他单元格的值更新 tableview 的单元格?
How to update a tableview's cell based on values from other cells using MVVM/RxSwift?
我是 RxSwift 的新手,正在尝试通过创建一个简单的注册表单来学习。我想用 UITableView
来实现它(作为练习,而且以后会变得更复杂)所以我目前使用两种类型的单元格:
- 一个
TextInputTableViewCell
只有一个UITextField
- 一个
ButtonTableViewCell
只有一个UIButton
为了表示每个单元格,我创建了一个如下所示的枚举:
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 Variable
和 button
一个 已启用 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
的任何粗糙边缘
我是 RxSwift 的新手,正在尝试通过创建一个简单的注册表单来学习。我想用 UITableView
来实现它(作为练习,而且以后会变得更复杂)所以我目前使用两种类型的单元格:
- 一个
TextInputTableViewCell
只有一个UITextField
- 一个
ButtonTableViewCell
只有一个UIButton
为了表示每个单元格,我创建了一个如下所示的枚举:
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 Variable
和 button
一个 已启用 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
的任何粗糙边缘