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.
CodePudding user response:
I can replicate with your steps, I believe it is a bug.
You can circumvent the issue by using the "new" format
and .number
instead of formatter
TextField("field3", value: $line.field3, format: .number).textFieldStyle(.roundedBorder)
TextField("field4", value: $line.field4, format: .number).textFieldStyle(.roundedBorder)
You should submit a bug report
Working code
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)
}
}
Crash at a 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:
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
}()
}
WORKAROUND
Here is a workaround for now. It affects performance because it forces a full redraw of the View
, you won't see much with a simple View
like this but it will slow everything down if your views become longer and more complex.
Add .id(myContainer.lines.count)
to the 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: { $0.id == line.id })
}.id(myContainer.lines.count)
}
Button("Toggle hidden", action: { hidden = !hidden })
}
}