Sergey
Sergey

Reputation: 507

SwiftUI List with @FocusState and focus change handling

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")
        }
    }
}

Upvotes: 7

Views: 8128

Answers (2)

Shaybc
Shaybc

Reputation: 3147

if you add printChanges to the beginning of the body, you can monitor the views and see that they are being rendered by SwiftUI (all of them on each focus lost and focus gained)

   ...

var body: some View {
let _ = Self._printChanges()  // <<< ADD THIS TO SEE RE-RENDER

   ...

so after allot of testing, it seams that the problem is with .onChange, once you add it SwiftUI will redraw all the Textfields,

the only BYPASS i found is to keep using the deprecated API as it works perfectly, and renders only the two textfields (the one that lost focus, and the one that gained the focus),

so the code should look this:

struct ContentView: View {
    enum Field: Hashable {
        case fieldId(Int)
    }
    
    // @FocusState var focus: Field? /// NO NEED
    @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)) /// NO NEED
            }
        }
//      .onChange(of: focus) { _ in /// NO NEED
//          print("Not printed unless focused manually") /// NO NEED
//      } /// NO NEED
        .focusable(true, onFocusChange: { focusNewValue in
            print("Only textfileds that lost/gained focus will print this")
        })
    }
}

Upvotes: 4

Asperi
Asperi

Reputation: 257543

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
                }
        }
    }
}

Upvotes: 3

Related Questions