Home > Blockchain >  Swift UI - Dynamic List, TextField focus and deletion
Swift UI - Dynamic List, TextField focus and deletion

Time:05-08

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 })
    }
}
  • Related