SwiftUI: как он обновляет представление и почему свойства @Published ObservableObject работают случайным образом - PullRequest
1 голос
/ 23 марта 2020

SwiftUI мне кажется все более запутанным в том, как он работает. На первый взгляд швы быстро и легко шлифуются asp. Но если вы добавляете все больше и больше представлений, то что-то, что кажется простым, начинает вести себя очень странно и решать много времени.

У меня есть 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. Иногда кажется, что существует 1 экземпляр ViewModel, иногда создается впечатление, что этот экземпляр создается при каждом представлении refre sh
  2. иногда тело LabeledInput обновляет и проверяет правильность окраски меток. corretly. В других случаях кажется, что он вообще не обновляет sh и ничего не происходит
  3. иногда обновляется, поэтому клавиатура сразу скрывается
  4. В других случаях проверка вообще не выполняется
  5. В других случаях вход теряется после выхода из поля или при повороте телефонного пейзажа в портретное положение
  6. Если есть какое-либо событие, которое вызывает родительский просмотр refre sh, это может привести к потере данных и проверке входных данных.
  7. Иногда он обновляется, часто в другое время он вообще не обновляет sh, как следует.

Я пытался добавить .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!")
    }
}

UPDATE

Полный пример 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)
    }
}
...