SwiftUI List with @FocusState and focus change handling

Viewed 124

I want to use a List, @FocusState to track focus, and .onChanged(of: focus) to ensure the currently focused field is visible with ScrollViewReader. The problem is: when everything is setup together the List rebuilds constantly during scrolling making the scrolling not as smooth as it needs to be.

I found out that the List rebuilds on scrolling when I attach .onChanged(of: focus). The issue is gone if I replace List with ScrollView, but I like appearance of List, I need sections support, and I need editing capabilities (e.g. delete, move items), so I need to stick to List view.

I used Self._printChanges() in order to see what makes the body to rebuild itself when scrolling and the output was like:

ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
...

And nothing was printed from the closure attached to .onChanged(of: focus). Below is the simplified example, the smoothness of scrolling is not a problem in this example, however, once the List content is more or less complex the smooth scrolling goes away and this is really due to .onChanged(of: focus) :(

Question: Are there any chances to listen for focus changes and not provoke the List to rebuild itself on scrolling?

struct ContentView: View {
    enum Field: Hashable {
        case fieldId(Int)
    }
    
    @FocusState var focus: Field?
    @State var text: String = ""
    
    var body: some View {
        List {
            let _ = Self._printChanges()
            ForEach(0..<100) {
                TextField("Enter the text for \($0)", text: $text)
                    .id(Field.fieldId($0))
                    .focused($focus, equals: .fieldId($0))
            }
        }
        .onChange(of: focus) { _ in
            print("Not printed unless focused manually")
        }
    }
}
1 Answers

I recommend to consider separation of list row content into standalone view and use something like focus "selection" approach. Having FocusState internal of each row prevents parent view from unneeded updates (something like pre-"set up" I assume).

Tested with Xcode 13.4 / iOS 15.5

struct ContentView: View {

    enum Field: Hashable {
        case fieldId(Int)
    }

    @State private var inFocus: Field?

    var body: some View {
        List {
            let _ = Self._printChanges()
            ForEach(0..<100, id: \.self) {
                ExtractedView(i: $0, inFocus: $inFocus)
            }
        }
        .onChange(of: inFocus) { _ in
            print("Not printed unless focused manually")
        }
    }

    struct ExtractedView: View {
        let i: Int
        @Binding var inFocus: Field?

        @State private var text: String = ""
        @FocusState private var focus: Bool     // << internal !!

        var body: some View {
            TextField("Enter the text for \(i)", text: $text)
                .focused($focus)
                .id(Field.fieldId(i))
                .onChange(of: focus) { _ in
                    inFocus = .fieldId(i)     // << report selection outside
                }
        }
    }
}
Related