SwiftUI: ошибка с ObservableObject во View, дочерние представления имеют разные ссылки на Observable Object
У меня есть View в SwiftUI, который содержит форму с несколькими входами. Входные данные обернуты Label, TextField и Validator в собственные пользовательские представления. Эти входные представления могут быть двух типов:
1) содержащий TextField (например, SwiftUI),
2) содержащий оболочку UIKit вокруг UITextView или UITextField
Вот примеры кода:
class ViewModel: ObservableObject {
@Published var details: String = "" {
didSet {
print("Details: \(details)")
}
}
@Published var detailsValidation = Validation(onEditing: true) {
didSet {
print("Validation: \(detailsValidation.errors)")
}
}
}
struct FormView: View {
@ObservedObject var viewModel : ViewModel
func makeNameInput() -> some View {
LabeledInput(label: "Name", input: self.$viewModel.name, rules: FormRules.name, validation: $viewModel.nameValidation, onEditingChanged: { isEditing in
self.avoider.editingField = 0
})
.textContentType(.name)
.keyboardType(.default)
.autocapitalization(.words)
.avoidKeyboard(tag: 0)
.frame(height: 80)
}
func makeDescriptionInput() -> some View {
LabeledTextArea(label: "Description", input: self.$viewModel.description, rules: FormRules.description, validation: self.$viewModel.descriptionValidation, onEditingChanged: { isEditing in
self.avoider.editingField = 3
})
.keyboardType(.default)
.autocapitalization(.sentences)
.avoidKeyboard(tag: 3)
.frame(height: 150)
}
}
LabeledTextArea - это что-то вроде этого
struct LabeledTextArea: View {
// MARK: - Properties
let label: String
let dividerColor: Color
let dividerHidden: Bool
// MARK: - Binding
@Binding var input: String
// MARK: - Actions
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
// MARK: - Validation
@ObservedObject var validator : FieldValidator<String>
// MARK: - State
//@State private var isEditing = true
init(label: String, input: Binding<String>, rules: [Rule<String>] = [], validation: Binding<Validation>? = nil, dividerColor: Color = Color("FormGray"), dividerHidden: Bool = false, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = { }) {
print("LabeledTextArea init")
self.label = label
self._input = input
self.dividerColor = dividerColor
self.dividerHidden = dividerHidden
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.validator = FieldValidator(input: input, rules: rules, validation: validation ?? .constant(Validation()))
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if label.isNotEmpty {
Text("\(label.uppercased())")
.font(.custom("AvenirNext-Regular", size: 11))
.foregroundColor(!self.validator.validation.isEdited ? Color("LabelBlue")
: self.validator.validation.isValid ? Color("GreenText") : Color(.red))
}
TextView(text: $validator.value, /*isEditing: $isEditing, */ font: UIFont(name: "AvenirNext-Regular", size: 15)!, textColor: UIColor(named: "InputBlue")!, isEditable: true, isSelectable: true, isScrollingEnabled: true, isUserInteractionEnabled: true, onEditingChanged: { editing in
if self.validator.validation.onEditing {
self.validator.validateField()
}
self.onEditingChanged(editing)
self.validator.validation.isEdited = true
//self.isEditing.toggle()
self.validator.objectWillChange.send()
}, onCommit: {
self.validator.validateField()
self.validator.validateFieldAsync()
self.onCommit()
})
.frame(maxHeight: 120)
if dividerHidden == false {
Divider().background(!self.validator.validation.isEdited ? dividerColor
: self.validator.validation.isValid ? Color("GreenText") : Color(.red) )
}
}
.padding(.horizontal, 24)
.padding(.top, 10)
.onReceive(self.validator.objectWillChange) { _ in
print("Validator CHANGE!")
}
}
}
Оболочка TextView
@available(iOS 13.0, *)
struct TextView: UIViewRepresentable {
@ObservedObject private var keyboardEvents = KeyboardEvents()
@Binding var text: String
private var isEditing: Binding<Bool>?
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
///.....
func updateUIView(_ textView: UITextView, context: Context) {
//textView.text = text
if let isEditing = isEditing {
if isEditing.wrappedValue {
textView.becomeFirstResponder()
} else {
textView.resignFirstResponder()
}
}
}
final class Coordinator: NSObject, UITextViewDelegate {
private let parent : TextView
init(_ parent: TextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
parent.onEditingChanged(true)
}
func textViewDidBeginEditing(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.isEditing?.wrappedValue = true
}
parent.onEditingChanged(true)
}
func textViewDidEndEditing(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.isEditing?.wrappedValue = false
}
parent.onEditingChanged(false)
parent.onCommit()
}
}
}
У меня есть TabView, и я использую это представление на одной вкладке, и код отлично работает. Затем я использую его на другой вкладке, но есть NavigationView, и доступ к нему осуществляется со страницы сведений (глубокая навигация). И тот же FormView не работает должным образом, проверка не работает, а модель представления не сохраняет значения из пользовательских входных данных, обернутых в UIKit! Входные данные из SwiftUI работают должным образом.
После нескольких часов отладки я обнаружил, что это представление отображается 3 раза, а ViewModel создается 3 раза, и что интересно, этот LabeledInput, LabeledTextArea помещен внутри того же FormView в методах onEditingChanged (я полагаю, также в случае $viewModel.name, $viewModel.description используют совершенно разные экземпляры (ссылки) ViewModel. Я не знаю, как это возможно в SwiftUI, что дочерние представления внутри родительского представления, имеющие @ObservedObject, ссылаются на совокупность различных экземпляров, в результате чего данные привязки хранятся в неправильном представлении, а представление не обновляется правильно при обновлении содержимого модели представления @Published properties.
Я отлаживал ссылки на эти модели просмотра на этапе создания, а затем в обратных вызовах onEditingChanged в LabeledInputs.
Вот что я получаю:
--------------------------------
(lldb) po self
<NewDealViewModel: 0x12dd1fc00>
NewDealViewModel init
(lldb) po self
<NewDealViewModel: 0x12dca6890>
NewDealViewModel init
(lldb) po self
<NewDealViewModel: 0x13064f630>
NewDealViewModel init
// Text Area Input
(lldb) po self
▿ _viewModel : ObservedObject<NewDealViewModel>
▿ wrappedValue : <NewDealViewModel: 0x12dd1fc00>
(lldb) po self.viewModel
<NewDealViewModel: 0x12dd1fc00>
// SwiftUI Input
po self.viewModel
<NewDealViewModel: 0x13064f630>
Как вы можете видеть, LabeledInput на основе SwiftUI ссылается на последнюю созданную модель представления, в то время как LabeledTextArea на основе UIKit ссылается на первую созданную модель представления!
И это не работает только в том случае, если NavigationLink(destintation: FormView()) помещается внутри View, который помещается в стек NavigationView (как второе представление), помещенный внутри TabView, в то время как он работает нормально, когда этот NavigationLink(destination: FormView()) размещается в корневом представлении NavigationView в TabView на другой вкладке!
Я решил эту проблему после нескольких часов отладки, очень странно используя оболочку свойств @State со ссылочным типом ObservableObject.
Я использовал представление оболочки с таким кодом:
struct FormViewWrapper : View {
// MARK: - Observed
@State var viewModel = ViewModel()
// MARK: - Binding
@Binding var added: String?
init(added: Binding<String?> = .constant(nil)) {
self._added = added
}
var body: some View {
FormView(viewModel: viewModel, added: $added)
}
}