在我看来,SwiftUI 的工作方式越来越令人困惑。乍一看,它接缝快速且易于掌握。但是,如果您添加越来越多的视图,那么看起来很简单的事情就会开始表现得很奇怪,并且需要很多时间来解决。
我有Input
验证字段。这是自定义输入,我可以在很多地方重复使用。但是在不同的屏幕上,这可能会完全不同并且完全不可靠。
用表格查看
struct LoginView {
@ObservedObject private var viewModel = LoginViewModel()
var body: some View {
VStack(spacing: 32) {
Spacer()
LabeledInput(label: "Email", input: self.$viewModel.email, isNuemorphic: true, rules: LoginFormRules.email, validation: self.$viewModel.emailValidation)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.frame(height: 50)
LabeledInput(label: "Password", isSecure: true, input: self.$viewModel.password, isNuemorphic: true, rules: LoginFormRules.password, validation: self.$viewModel.passwordValidation)
.textContentType(.password)
.keyboardType(.asciiCapable)
.autocapitalization(.none)
.frame(height: 50)
self.makeSubmitButton()
Spacer()
}
}
LabeledInput - 具有验证支持的可重用自定义输入视图
struct LabeledInput: View {
// MARK: - Properties
let label: String?
let isSecure: Bool
// MARK: - Binding
@Binding var input: String
var isEditing: Binding<Bool>?
// MARK: - Actions
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
// MARK: - Validation
@ObservedObject var validator: FieldValidator<String>
// MARK: - Init
init(label: String? = nil,
isSecure: Bool = false,
input: Binding<String>,
isEditing: Binding<Bool>? = nil,
// validation
rules: [Rule<String>] = [],
validation: Binding<Validation>? = nil,
// actions
onEditingChanged: @escaping (Bool) -> Void = { _ in },
onCommit: @escaping () -> Void = { }) {
self.label = label
self.isSecure = isSecure
self._input = input
self.isEditing = isEditing
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.validator = FieldValidator(input: input, rules: rules, validation: validation ?? .constant(Validation()))
}
var useUIKit: Bool {
self.isEditing != nil
}
var body: some View {
GeometryReader { geometry in
ZStack {
RoundedRectangle(cornerRadius: 4.0)
.stroke(lineWidth: 1)
.foregroundColor(!self.validator.validation.isEdited ? Color("LightGray")
: self.validator.validation.isValid ? Color("Green") : Color("Red"))
.frame(maxHeight: geometry.size.height)
.offset(x: 0, y: 16)
VStack {
HStack {
self.makeLabel()
.offset(x: self.isNuemorphic ? 0 : 16,
y: self.isNuemorphic ? 0 : 8)
Spacer()
}
Spacer()
}
self.makeField()
.frame(maxHeight: geometry.size.height)
.offset(x: 0, y: self.isNuemorphic ? 20 : 16)
.padding(10)
}
}
}
private func makeField() -> some View {
Group {
if useUIKit {
self.makeUIKitTextField(secure: self.isSecure)
} else {
if self.isSecure {
self.makeSecureField()
} else {
self.makeTextField()
}
}
}
}
private func makeLabel() -> some View {
Group {
if label != nil {
Text("\(self.label!.uppercased())")
.font(.custom("AvenirNext-Regular", size: self.isNuemorphic ? 13 : 11))
.foregroundColor(!self.validator.validation.isEdited ? Color("DarkBody")
: self.validator.validation.isValid ? Color("Green") : Color("Red"))
.padding(.horizontal, 8)
} else {
EmptyView()
}
}
}
private func makeSecureField() -> some View {
SecureField("", text: self.$input, onCommit: {
self.validator.onCommit()
self.onCommit()
})
.font(.custom("AvenirNext-Regular", size: 15))
.foregroundColor(Color("DarkBody"))
.frame(maxWidth: .infinity)
}
private func makeTextField() -> some View {
TextField("", text: self.$input, onEditingChanged: { editing in
self.onEditingChanged(editing)
self.validator.onEditing(editing)
if !editing { self.onCommit() }
}, onCommit: {
self.validator.onCommit()
self.onCommit()
})
.font(.custom("AvenirNext-Regular", size: 15))
.foregroundColor(Color("DarkBody"))
.frame(maxWidth: .infinity)
}
private func makeUIKitTextField(secure: Bool) -> some View {
let firstResponderBinding = Binding<Bool>(get: {
self.isEditing?.wrappedValue ?? false //?? self.isFirstResponder
}, set: {
//self.isFirstResponder = $0
self.isEditing?.wrappedValue = $0
})
return UIKitTextField(text: self.$input, isEditing: firstResponderBinding, font: UIFont(name: "AvenirNext-Regular", size: 15)!, textColor: UIColor(named: "DarkBody")!, placeholder: "", onEditingChanged: { editing in
self.onEditingChanged(editing)
self.validator.onEditing(editing)
}, onCommit: {
self.validator.onCommit()
self.onCommit()
})
}
}
下面是我在 ObservableObject 即 LoginViewModel 中存储模型(输入值和验证)的方式。
final class LoginViewModel: ObservableObject {
// MARK: - Published
@Published var email: String = ""
@Published var password: String = ""
@Published var emailValidation: Validation = Validation(onEditing: true)
@Published var passwordValidation: Validation = Validation(onEditing: true)
@Published var validationErrors: [String]? = nil
@Published var error: DescribableError? = nil
}
当我根据视图父视图(屏幕)创建 ViewModel(在 LoginView 属性中或注入到 LoginView 构造函数)的方式使用此代码时,它嵌入的工作方式可能完全不同,可能会导致数小时的调试和意外行为。
- 有时似乎有 1 个 ViewModel 实例有时似乎每次 View 刷新都会创建此实例
- 有时 LabeledInput 正文令人耳目一新,并且标签的验证着色工作正常。其他时候它似乎根本没有刷新,也没有任何反应
- 有时刷新如此频繁键盘立即隐藏
- 其他时候根本没有验证
- 退出字段后或将手机横向旋转为纵向时,其他时间输入丢失
- 如果有一些事件导致父视图刷新,它可能会导致输入丢失数据和验证。
- 有时它会经常刷新,有时它根本不会刷新。
我尝试添加 .id(UUID) 、自定义 .id(refreshId) 或其他 Equatable 协议实现,但它不能按预期工作,因为它是可重用的自定义输入,验证可在多个屏幕上的多个表单之间重用。
这是简单的验证结构
struct Validation {
let onEditing: Bool
init(onEditing: Bool = false) {
self.onEditing = onEditing
}
var isEdited: Bool = false
var errors: [String] = []
}
而这里 FieldValidator ObservableObject
class FieldValidator<T>: ObservableObject {
// MARK: - Properties
private let rules: [Rule<T>]
// MARK: - Binding
@Binding private var input: T
@Binding var validation: Validation
// MARK: - Init
init(input: Binding<T>, rules: [Rule<T>], validation: Binding<Validation>) {
#if DEBUG
print("[FieldValidator] init: \(input.wrappedValue)")
#endif
self._input = input
self.rules = rules
self._validation = validation
}
private var disposables = Set<AnyCancellable>()
}
// MARK: - Public API
extension FieldValidator {
func validateField() {
validation.errors = rules
.filter { !$0.isAsync }
.filter { !$0.validate(input) }
.map { $0.errorMessage() }
}
func validateFieldAsync() {
rules
.filter { $0.isAsync }
.forEach { rule in
rule.validateAsync(input)
.filter { valid in
!valid
}.sink(receiveValue: { _ in
self.validation.errors.append(rule.errorMessage())
})
.store(in: &disposables)
}
}
}
// MARK: - Helper Public API
extension FieldValidator {
func onEditing(_ editing: Bool) {
self.validation.isEdited = true
if editing {
if self.validation.onEditing {
self.validateField()
}
} else {
// on end editing
self.validateField()
self.validateFieldAsync()
}
}
func onCommit() {
self.validateField()
self.validateFieldAsync()
}
}
规则只是
class Rule<T> {
var isAsync: Bool { return false }
func validate(_ value: T) -> Bool { return false }
func errorMessage() -> String { return "" }
func validateAsync(_ value: T) -> AnyPublisher<Bool, Never> {
fatalError("Async validation is not implemented!")
}
}
更新
完整的 UIKitTextField 示例
@available(iOS 13.0, *)
struct UIKitTextField: UIViewRepresentable {
// MARK: - Observed
@ObservedObject private var keyboardEvents = KeyboardEvents()
// MARK: - Binding
@Binding var text: String
var isEditing: Binding<Bool>?
// MARK: - Actions
let onBeginEditing: () -> Void
let onEndEditing: () -> Void
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
// MARK: - Proprerties
private let keyboardOffset: CGFloat
private let textAlignment: NSTextAlignment
private let font: UIFont
private let textColor: UIColor
private let backgroundColor: UIColor
private let contentType: UITextContentType?
private let keyboardType: UIKeyboardType
private let autocorrection: UITextAutocorrectionType
private let autocapitalization: UITextAutocapitalizationType
private let isSecure: Bool
private let isUserInteractionEnabled: Bool
private let placeholder: String?
public static let defaultFont = UIFont.preferredFont(forTextStyle: .body)
private var hasDoneToolbar: Bool = false
init(text: Binding<String>,
isEditing: Binding<Bool>? = nil,
keyboardOffset: CGFloat = 0,
textAlignment: NSTextAlignment = .left,
font: UIFont = UIKitTextField.defaultFont,
textColor: UIColor = .black,
backgroundColor: UIColor = .white,
contentType: UITextContentType? = nil,
keyboardType: UIKeyboardType = .default,
autocorrection: UITextAutocorrectionType = .default,
autocapitalization: UITextAutocapitalizationType = .none,
isSecure: Bool = false,
isUserInteractionEnabled: Bool = true,
placeholder: String? = nil,
hasDoneToolbar: Bool = false,
onBeginEditing: @escaping () -> Void = { },
onEndEditing: @escaping () -> Void = { },
onEditingChanged: @escaping (Bool) -> Void = { _ in },
onCommit: @escaping () -> Void = { }) {
self._text = text
self.isEditing = isEditing
self.keyboardOffset = keyboardOffset
self.onBeginEditing = onBeginEditing
self.onEndEditing = onEndEditing
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.textAlignment = textAlignment
self.font = font
self.textColor = textColor
self.backgroundColor = backgroundColor
self.contentType = contentType
self.keyboardType = keyboardType
self.autocorrection = autocorrection
self.autocapitalization = autocapitalization
self.isSecure = isSecure
self.isUserInteractionEnabled = isUserInteractionEnabled
self.placeholder = placeholder
self.hasDoneToolbar = hasDoneToolbar
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.delegate = context.coordinator
textField.keyboardType = keyboardType
textField.textAlignment = textAlignment
textField.font = font
textField.textColor = textColor
textField.backgroundColor = backgroundColor
textField.textContentType = contentType
textField.autocorrectionType = autocorrection
textField.autocapitalizationType = autocapitalization
textField.isSecureTextEntry = isSecure
textField.isUserInteractionEnabled = isUserInteractionEnabled
//textField.placeholder = placeholder
if let placeholder = placeholder {
textField.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [
NSAttributedString.Key.foregroundColor: UIColor.lightGray
])
}
textField.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .editingChanged)
keyboardEvents.didShow = {
if textField.isFirstResponder {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(350)) {
textField.adjustScrollView(offset: self.keyboardOffset, animated: true)
}
}
}
if hasDoneToolbar {
textField.addDoneButton {
print("Did tap Done Toolbar button")
textField.resignFirstResponder()
}
}
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
textField.text = text
if let isEditing = isEditing {
if isEditing.wrappedValue {
textField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
}
}
final class Coordinator: NSObject, UITextFieldDelegate {
let parent: UIKitTextField
init(_ parent: UIKitTextField) {
self.parent = parent
}
@objc func valueChanged(_ textField: UITextField) {
parent.text = textField.text ?? ""
parent.onEditingChanged(true)
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.onBeginEditing()
parent.onEditingChanged(true)
}
func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) {
//guard textField.text != "" || parent.shouldCommitIfEmpty else { return }
DispatchQueue.main.async {
self.parent.isEditing?.wrappedValue = false
}
parent.text = textField.text ?? ""
parent.onEditingChanged(false)
parent.onEndEditing()
parent.onCommit()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.isEditing?.wrappedValue = false
textField.resignFirstResponder()
parent.onCommit()
return true
}
}
}
extension UIView {
func adjustScrollView(offset: CGFloat, animated: Bool = false) {
if let scrollView = findParent(of: UIScrollView.self) {
let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y + offset)
scrollView.setContentOffset(contentOffset, animated: animated)
} else {
print("View is not in ScrollView - do not adjust content offset")
}
}
}
这是示例 EmailRule 实现
class EmailRule : RegexRule {
static let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
public convenience init(message : String = "Email address is invalid"){
self.init(regex: EmailRule.regex, message: message)
}
override func validate(_ value: String) -> Bool {
guard value.count > 0 else { return true }
return super.validate(value)
}
}