如何在双向绑定ViewModel/TextField中保持@Published属性的规范化(即保持小写、删除链接等)



保持@Published属性规范化的最佳实践或技术是什么?假设我们有一个ViewModel,它公开了@Published文本属性,该属性可以从ViewModel/TextField(SwiftUI(更新,并且我们希望删除用户在字段中输入的任何链接。

我用测试写了这个例子,但我的一个问题是,在测试的订阅中,我以错误的顺序接收值(听起来改变handleEvents中的文本不太正确(

class ViewModel: ObservableObject {
@Published var text: String?
private var cancellables = Set<AnyCancellable>()
init() {
setupObservers()
}
private func setupObservers() {
$text
.removeDuplicates() // use removeDuplicates to avoid infinite loop
.compactMap({ $0 })
.handleEvents(receiveOutput: { [weak self] in self?.cleanupLinks(from: $0) })
.sink(receiveValue: { _ in })
.store(in: &cancellables)
}
private func cleanupLinks(from text: String) {
//dummy implementation
self.text = text.replacingOccurrences(of: "https://any-url.com", with: "")
}
}
// TEST
class SimpleReproTests: XCTestCase {
func test_text_cleanupLinks() {
let sut = ViewModel()
let exp = expectation(description: "Wait for text updates")
exp.expectedFulfillmentCount = 2
var receivedValues = [String?]()
let cancellable = sut.$text.dropFirst(2).sink {
receivedValues.append($0)
exp.fulfill()
}

sut.text = "Hello World! https://any-url.com"
wait(for: [exp], timeout: 1.0)
XCTAssertEqual(receivedValues, ["Hello World! https://any-url.com", "Hello World! "])
cancellable.cancel()
}
}

现在,当运行测试时,我收到了无序的值(哪个ofc与预期不匹配(:

["Hello World! ", "Hello World! https://any-url.com"]

做事没有最好的方法;(

当视图变得比最简单的用例更复杂时,我会这样做:

通常,您将所有逻辑都放入视图模型中。视图模型可以以超出本主题的各种方式来实现。

基本上,视图模型接收一个";事件";可选地携带附加信息;待修改";字符串,只要用户键入并打算修改当前字符串。然后视图模型开始基于该事件执行其逻辑;状态";由视图模型管理。这导致了新的";视图状态";(在您的示例中也称为字符串(,视图会相应地观察并渲染该字符串。这样,视图模型完全控制用户看到的内容,并且这实际上是"视图"的当前值的表示;真理的单一来源";。

在SwiftUI中,你可以使用一些父视图来实现这一点,这些父视图将用视图模型初始化并观察它。在它的主体中,它将视图状态(或相关的子状态(传递给它的子视图。这些子视图接收将其作为常数值的状态。此外,父视图进入"0";动作回调";子视图调用用户操作,如键入字符或点击按钮:

struct MyView: View {
let state: String
let typing: (String) -> Void
let dismiss: () -> Void
var body: some View { ... }
}

父视图将子视图的回调与视图模型的操作连接起来。它也从不修改viewModel的viewState。这里有一个例子:

struct ParentView: View {
@StateObject var viewModel = MyViewModel()
var body: some View {
MyView(state: viewModel.viewState.input,
typing: { viewModel.send(.typing($0)) }, 
dismiss: { viewModel.send(.dismiss) })
}
}

也就是说,为了将视图与视图模型连接起来,父视图只调用视图模型上的一个函数,即:

func send(_ action: Action)

其中Action是有目的的枚举:

enum Action {
case typing(String) 
case dismiss
}

所以,你的视图模型现在可能会有一些功能来执行这个";"归一化":

static func normalise(_ input: String) -> String

这是一个同步的纯函数(没有副作用(,每当接收到键入操作时都会调用它。由于它是一个纯函数,所以测试非常简单,不需要mock。

最后,视图模型更新其状态并生成一个视图状态,视图会对该状态做出相应的反应(绘制唯一的真相来源!(。由于视图状态是模型状态的函数,

static func view(state: MyViewModel.State) -> ViewState

这也是一个纯函数,视图状态也可以很容易地测试。

注意,没有";双向绑定";通过视图状态绑定。相反,视图状态会被相应渲染的视图捕获为常量。视图不执行逻辑,而是将操作发送到视图模型。

视图在必须管理私有内部状态时仅(很少(使用@State变量。

最新更新