I have a really strange behaviour with Swift UI on Mac OS.
The idea is that I have a dynamic list of editable elements (I can add, edit & remove).
If I don't focus the TextField, I can add / remove element without problem.
But If I start to give focus to TextField and navigate through my list TextField using tab
, it eventually crash my app.
To illustrate the issue, I created a little Playground.
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: { $0.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())
The example starts with 4 lines.
If for example I delete the second line with the button, focus the first TextField of the first line and start to navigate with tab
the application crashes when the focus arrives on the last field.
Playing with the delete of rows and focus and remove while focus, there is others ways to make the playground to crash.
I also suspect there is something related to the TextField
with the NumberFormatter
.
Is there something I'm doing wrong? From other threads related to dynamic list, it looks fine to me.
And within my app, when it crashes, I don't get any trace if the problem.
Just a Swift/ContiguousArrayBuffer.swift:575: Fatal error: Index out of range
indicating this line:
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
Any help is welcome!
UPDATE
So I tried a different approach by using an NSViewRepresentable
with a regular NSTextField
with a coordinator.
Here is the Xcode mac os playground
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: { $0.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())
It works as long as I don't remove any line. If I do, and try to edit a line after the deleted one, the app crashes with an Index out of range
at this line:
self.parent.value = textView.doubleValue
It's like the coordinator is no longer in sync with the view.
It really feels like there is an issue in the loop when line are removed.