Swift UI - 动态列表、TextField 焦点和删除

Swift UI - Dynamic List, TextField focus and deletion

我在 Mac OS 上对 Swift UI 有一个非常奇怪的行为。 这个想法是我有一个可编辑元素的动态列表(我可以添加、编辑和删除)。 如果我不关注 TextField,我可以毫无问题地添加/删除元素。 但是,如果我开始将焦点放在 TextField 上并使用 tab 浏览我的列表 TextField,它最终会使我的应用程序崩溃。

为了说明这个问题,我创建了一个小游乐场。

import Foundation
import SwiftUI
import PlaygroundSupport

struct Container {
    var lines: [Line]
}

struct Line: Identifiable {
    var id = UUID()
    var field1: String
    var field2: String
    var field3: Double = 0
    var field4: Double = 0
}

struct ContainerEditor: View {
    @State var hidden = false
    @State var myContainer = Container(lines: [    
        Line(field1: "Line1.1", field2: "Line1.2"),    
        Line(field1: "Line2.1", field2: "Line2.2"),    
        Line(field1: "Line3.1", field2: "Line3.2"),    
        Line(field1: "Line4.1", field2: "Line4.2"),
    ])
    var body: some View {
        if !hidden {
            ContainerView(container: $myContainer) { line in 
                print("Removing:")
                print(line)
                myContainer.lines.removeAll(where: { [=12=].id == line.id })
            }
            Button("Add line", action: { myContainer.lines.append(Line(field1: "New1", field2: "New2"))})
                .buttonStyle(.bordered)
        }
        Button("Toggle hidden", action: { hidden = !hidden })
    }
}

struct ContainerView: View {
    var container: Binding<Container>
    var onRemove: (_ line: Line) -> Void
    var body: some View {
        ForEach(container.lines) { line in
            LineView(line: line) {
                onRemove(line.wrappedValue)
            }
        }
        
    }
}

struct LineView: View {
    var line: Binding<Line>
    var onRemove: () -> Void
    private var numberFormatter: NumberFormatter {
        get {
            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            formatter.maximumFractionDigits = 4
            return formatter
        }
    }
    var body : some View {
        HStack {
            TextField("field1", text: line.field1).textFieldStyle(.roundedBorder)
            TextField("field2", text: line.field2).textFieldStyle(.roundedBorder)
            TextField("field3", value: line.field3, formatter: numberFormatter).textFieldStyle(.roundedBorder)
            TextField("field4", value: line.field4, formatter: numberFormatter).textFieldStyle(.roundedBorder)
            Button("remove") {
                print("Remove insider")
                onRemove()
            }.buttonStyle(.bordered)
        }.frame(maxWidth: 300)
    }
}

PlaygroundPage.current.setLiveView(ContainerEditor())

示例以 4 行开头。
例如,如果我使用按钮删除第二行,聚焦第一行的第一个 TextField 并开始使用 tab 导航,当焦点到达最后一个字段时应用程序崩溃。

尝试删除行和焦点以及在焦点时删除,还有其他方法可以使 playground 崩溃。

我还怀疑 TextFieldNumberFormatter 有关系。

我做错了什么吗?从其他与动态列表相关的线程来看,我觉得还不错。

在我的应用程序中,当它崩溃时,我没有得到任何问题的痕迹。
就一个 Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range 指示此行:

_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

欢迎任何帮助!

更新

所以我尝试了一种不同的方法,即使用 NSViewRepresentable 和带有协调器的常规 NSTextField。 这里是Xcodemacos游乐场

import Foundation
import SwiftUI
import AppKit
import PlaygroundSupport

struct Container {
    var lines: [Line]
}

struct Line: Identifiable {
    var id = UUID()
    var field1: String
    var field2: String
    var field3: Double = 0
    var field4: Double = 0
}

struct ContainerEditor: View {
    @State var hidden = false
    @State var myContainer = Container(lines: [
        Line(field1: "Line1.1", field2: "Line1.2"),
        Line(field1: "Line2.1", field2: "Line2.2"),
        Line(field1: "Line3.1", field2: "Line3.2"),
        Line(field1: "Line4.1", field2: "Line4.2"),
    ])
    var body: some View {
        if !hidden {
            ContainerView(container: $myContainer) { line in
                print("Removing:")
                print(line)
                myContainer.lines.removeAll(where: { [=14=].id == line.id })
            }
            Button("Add line", action: { myContainer.lines.append(Line(field1: "New1", field2: "New2"))})
                .buttonStyle(.bordered)
        }
        Button("Toggle hidden", action: { hidden = !hidden })
    }
}

struct ContainerView: View {
    @Binding var container: Container
    var onRemove: (_ line: Line) -> Void
    var body: some View {
        ForEach($container.lines) { line in
            LineView(line: line) {
                onRemove(line.wrappedValue)
            }
        }
    }
}

struct LineView: View {
    @Binding var line: Line
    var onRemove: () -> Void
    private var numberFormatter: NumberFormatter {
        get {
            let formatter = NumberFormatter()
            formatter.numberStyle = .decimal
            formatter.maximumFractionDigits = 4
            return formatter
        }
    }
    var body : some View {
        HStack {
            TextNumberField(value: $line.field3)
            TextNumberField(value: $line.field4)
            Button("remove") {
                print("Remove insider")
                onRemove()
            }.buttonStyle(.bordered)
        }.frame(width: 400)
    }
}

struct TextNumberField: NSViewRepresentable {
    @Binding var value: Double
    var font = NSFont.systemFont(ofSize: 12, weight: .medium)
    var onEnter: (() -> Void)? = nil
    var initialize: ((NSTextField) -> Void)? = nil
    
    func makeNSView(context: Context) -> NSTextField {
        let view = NSTextField()
        view.delegate = context.coordinator
        view.isEditable = true
        
        let formatter = NumberFormatter()
        formatter.hasThousandSeparators = false
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 4
        view.formatter = formatter
        
        return view
    }
    
    func updateNSView(_ nsView: NSTextField, context: Context) {
        nsView.doubleValue = value
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    
    class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: TextNumberField
        
        init(_ parent: TextNumberField) {
            self.parent = parent
        }
        
        func controlTextDidChange(_ obj: Notification) {
            guard let textView = obj.object as? NSTextField else {
                return
            }
            self.parent.value = textView.doubleValue
        }
    }
}


PlaygroundPage.current.setLiveView(ContainerEditor())

只要我不删除任何一行,它就可以工作。如果我这样做,并尝试在删除的行之后编辑一行,应用程序会在该行崩溃并显示 Index out of rangeself.parent.value = textView.doubleValue 就像协调器不再与视图同步一样。 确实感觉去掉line的时候循环有问题

我可以复制你的步骤,我相信这是一个错误。

您可以使用“新”format.number 而不是 formatter

来规避此问题
TextField("field3", value: $line.field3, format: .number).textFieldStyle(.roundedBorder)
TextField("field4", value: $line.field4, format: .number).textFieldStyle(.roundedBorder)

您应该提交错误报告

工作代码

struct ContainerView: View {
    @Binding var container: Container
    var onRemove: (_ line: Line) -> Void
    var body: some View {
        ForEach($container.lines) { $line in
            LineView(line: $line) {
                onRemove(line)
            }
        }
        
    }
}
struct LineView: View {
    @Binding var line:Line
    var onRemove: () -> Void
    
    var body : some View {
        HStack {
            TextField("field1", text: $line.field1)
            TextField("field2", text: $line.field2)
            TextField("field3", value: $line.field3, format: .number)
            TextField("field4", value: $line.field4, format: .number)
            Button("remove") {
                print("Remove insider")
                onRemove()
            }.buttonStyle(.bordered)
        }.frame(maxWidth: 300)
            .textFieldStyle(.roundedBorder)
    }
}

一行崩溃

struct LineView: View {
    @Binding var line:Line
    var onRemove: () -> Void
    
    var body : some View {
        HStack {
            TextField("field1", text: $line.field1)
            TextField("field2", text: $line.field2)
            TextField("field3", value:
                        Binding(get: {
                line.field3 //**Crash at this line**
            }, set: { new in
                line.field3 = new
            })
                      , formatter: .numberFormatter)
            TextField("field4", value:
                        Binding(get: {
                line.field4
            }, set: { new in
                line.field4 = new
            }), formatter: .numberFormatter     )
            Button("remove") {
                print("Remove insider")
                onRemove()
            }.buttonStyle(.bordered)
        }.frame(maxWidth: 300)
            .textFieldStyle(.roundedBorder)
    }
}

extension Formatter{
    static var numberFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 4
        return formatter
    }()
}

解决方法

这是目前的解决方法。它会影响性能,因为它会强制完全重绘 View,你不会看到像这样的简单 View 太多,但如果你的视图变得更长和更复杂,它会减慢一切。

.id(myContainer.lines.count)添加到ContainerView

struct ContainerEditor: View {
    @State var hidden = false
    @State var myContainer = Container(lines: [
        Line(field1: "Line1.1", field2: "Line1.2"),
        Line(field1: "Line2.1", field2: "Line2.2"),
        Line(field1: "Line3.1", field2: "Line3.2"),
        Line(field1: "Line4.1", field2: "Line4.2"),
    ])
    var body: some View {
        if !hidden {
            ContainerView(container: $myContainer) { line in
                print("Removing:")
                print(line)
                myContainer.lines.removeAll(where: { [=13=].id == line.id })
            }.id(myContainer.lines.count)
           
        }
        Button("Toggle hidden", action: { hidden = !hidden })
    }
}