0

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.

Vincent
  • 356
  • 2
  • 8
  • Does this answer your question https://stackoverflow.com/a/59335397/12299030? – Asperi May 07 '22 at 18:16
  • Thank for your reply. I'm trying to implement the transparent wrapper as you suggest but don't know how to make it works with binding and double value. I tried this `TextField("field1", value: Binding(get: {line.field3 ?? 0.0}, set: {line.field3 = $0}), formatter: numberFormatter)` but I get a conversion error. How can I do with Binding and value? – Vincent May 07 '22 at 20:46
  • @Asperi I checked your suggestion and tried to implement it but it doesn't work in my case. I updated my question with a new playground. It looks like there is something with the loop but I don't know what. My line are identifiable. – Vincent May 08 '22 at 10:24

1 Answers1

1

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 })
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • Unfortunately my app needs to run Mac OS 11.x so I have to find another way (maybe by wrapping a NSTextField). I'll submit the bug report. Anyway, thank you very much for taking the time to try and answer. – Vincent May 07 '22 at 18:09
  • I tried with os x 12.0 and the format as you suggested but I was still able to reproduce the problem and make my app crash. – Vincent May 07 '22 at 21:27
  • @Vincent the only other thin I changed was using `@Binding` instead of `Binding` and `Binding`. I will post the code, If I put in a proxy I actually get a line crash in the `get` – lorem ipsum May 07 '22 at 21:45
  • I updated my question with a new approach but I still get an `Index out of range` exception. It's really weird and feels like the ForEach loop is the problem. – Vincent May 08 '22 at 10:22
  • @Vincent I really think this is a bug with `Binding` since you got the same error with `format` before using the wrapper. It is a puzzling one. I think it is a bug. The focus somehow thinks that row is still there. My next attempt to overcome would be using an `ObservableObject` it is a bit more of a brute force reload of the `View` since it updates the entire object. I'll try an attempt today, I'll post an update if I get to it. – lorem ipsum May 08 '22 at 11:31
  • @Vincent As I was typing the previous message I thought of a workaround. See above. It is bad practice I think because of the performance implications but an ok workaround until a bug is submitted/addressed by Apple. – lorem ipsum May 08 '22 at 12:00
  • I really don't mind the performance impact as it's just a bunch of text fields. I tried and it works, i'm not able to make it crash anymore. Man, you just saved my weekend. I've been stuck on this for days. Thank you so much. – Vincent May 08 '22 at 12:28