Issues with FocusState in List with views containing Textfields

We are having issues with implementing a List that has Views in it that contain a Textfield.

The criteria we are trying to achieve is

  • Select to edit the quantity of a product
  • Auto focus on the row with that textfield, with the textfield's contents selected
  • Display/Dismiss the keyboard
  • Mask other rows in the list while interacting with a qty field

We explored many routes and are looking for direction on what the designated approach is. This originally was a Tech Support Incident, and I was instructed to post here. There were 2 working project examples available if needed.

  1. In an implementation that has the FocusState on the parent view, we see collisions in animation / weird jumpiness

    // MARK: - Constant

    enum Constant {
        static let logTag = "AddReplenishmentProductView"
    }

    @Binding var state: ContentViewState

    // MARK: - Private Properties

    @State private var focusedLineItemId: String?

    // MARK: - Life cycle

    var body: some View {
        VStack {
            replenishmentProductList
        }
        .background(.tertiary)
        .navigationTitle("Add Products")
        .navigationBarTitleDisplayMode(.inline)
        .toolbarBackground(.visible, for: .navigationBar)
    }

    // MARK: - Private Computed properties

    @ViewBuilder
    private var replenishmentProductList: some View {
        ScrollViewReader { proxy in
            List {
                let list = Array(state.lineItems.enumerated())
                ForEach(list, id: \.1.product.id) { (index, lineItem) in
                    RowView(
                        lineItem: $state.lineItems[index],
                        focusedLineItemId: $focusedLineItemId
                    )
                    .id(lineItem.id.uuidString)
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .alignmentGuide(.listRowSeparatorLeading) { _ in
                        return 0
                    }
                    //  Blocks all the other rows that we are not focusing on.
                    .maskingOverlay(focusId: $focusedLineItemId, elementId: "\(lineItem.id)")
                }
                .listSectionSeparator(.hidden)
            }
            .listStyle(.plain)
            .scrollDismissesKeyboard(.never)
            .scrollContentBackground(.hidden)
            /* 
             We are looking for a solution that doesn't require us to have this onChange modifier
             whenever we want to change a focus.
             */
            .onChange(of: focusedLineItemId) {
                guard let lineItemId = focusedLineItemId else { return }
                /*  
                 We need to scroll to a whole RowView so we can see both done and cancel buttons.
                 Without this, the focus will auto-scroll only to the text field, due to updating FocusState.
                 
                 We are experiencing weird jumping issues. It feels like the animations for focus on
                 text field and RowView are clashing between each other.

                 To fix this, we added a delay to the scroll so the focus animation completes first and then we 
                 scroll to the RowView.
                 However, when we attempt to focus on a row that is partially shown, sometimes the RowView won't
                 update it's focus and won't focus ultimately on the TextField until we scroll.
                 */
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    withAnimation {
                        //  We need to add the withAnimation call to animate the scroll to the whole row.
                        proxy.scrollTo(lineItemId, anchor: .top)
                    }
                }
            }
        }
    }
}
  1. In an implementation where the FocusState is on the row views, we see issues with actually being able to focus. When quantity field we tap is located on a row near the top/bottom of the screen it does not look to be identified correctly, and the ability to scrollTo / the keyboard being presented are broken.

struct ContentView: View {

    // MARK: - Constant

    enum Constant {
        static let logTag = "AddReplenishmentProductView"
    }

    @Binding var state: ContentViewState

    // MARK: - Private Properties

    @State private var focusedLineItemId: String?
    @FocusState private var focus: String?

    // MARK: - Life cycle

    var body: some View {
        VStack {
            replenishmentProductList
        }
        .background(.tertiary)
        .navigationTitle("Add Products")
        .navigationBarTitleDisplayMode(.inline)
        .toolbarBackground(.visible, for: .navigationBar)
    }

    // MARK: - Private Computed properties

    @ViewBuilder
    private var replenishmentProductList: some View {
        ScrollViewReader { proxy in
            List {
                let list = Array(state.lineItems.enumerated())
                ForEach(list, id: \.1.product.id) { (index, lineItem) in
                    RowView(
                        lineItem: $state.lineItems[index],
                        focusedLineItemId: $focusedLineItemId,
                        focus: $focus
                    )
                    .id(lineItem.id.uuidString)
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .alignmentGuide(.listRowSeparatorLeading) { _ in
                        return 0
                    }
                    //  Blocks all the other rows that we are not focusing on.
                    .maskingOverlay(focusId: $focusedLineItemId, elementId: "\(lineItem.id)")
                }
                .listSectionSeparator(.hidden)
            }
            .listStyle(.plain)
            .scrollDismissesKeyboard(.never)
            .scrollContentBackground(.hidden)
            /* 
             We are looking for a solution that doesn't require us to have this onChange modifier
             whenever we want to change a focus.
             */
            .onChange(of: focusedLineItemId) {
                /* 
                 We need to scroll to a whole RowView so we can see both done and cancel buttons.
                 Without this, the focus will auto-scroll only to the text field, due to updating FocusState.

                 However, we are experiencing weird jumping issues. It feels like the animations for focus on
                 text field and RowView are clashing between each other.
                 */
                focus = focusedLineItemId
                guard let lineItemId = focusedLineItemId else { return }
                withAnimation {
                    //  We need to add the withAnimation call to animate the scroll to the whole row.
                    proxy.scrollTo(lineItemId, anchor: .top)
                }
            }
        }
    }
}

@wwgvmalupo First I'm unable to compile the code snippets to repoduce the issue . Have you tried extrapolating the issue to a small test project and reproducing it? if you have please share a link to your test project. That'll help us better understand what's going on. If you're not familiar with preparing a test project, take a look at Creating a test project.

Secondly, You have the following code snippet:

    @ViewBuilder
    private var replenishmentProductList: some View {
        ScrollViewReader { proxy in
            List {
                let list = Array(state.lineItems.enumerated())
                ForEach(list, id: \.1.product.id) { (index, lineItem) in
                    RowView(
                        lineItem: $state.lineItems[index],
                        focusedLineItemId: $focusedLineItemId,
                        focus: $focus
                    )
                    .id(lineItem.id.uuidString)
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .alignmentGuide(.listRowSeparatorLeading) { _ in
                        return 0
                    }
                    //  Blocks all the other rows that we are not focusing on.
                    .maskingOverlay(focusId: $focusedLineItemId, elementId: "\(lineItem.id)")
                }
                .listSectionSeparator(.hidden)
            }
            .listStyle(.plain)
            .scrollDismissesKeyboard(.never)
            .scrollContentBackground(.hidden)
            /* 
             We are looking for a solution that doesn't require us to have this onChange modifier
             whenever we want to change a focus.
             */
            .onChange(of: focusedLineItemId) {
                /* 
                 We need to scroll to a whole RowView so we can see both done and cancel buttons.
                 Without this, the focus will auto-scroll only to the text field, due to updating FocusState.

                 However, we are experiencing weird jumping issues. It feels like the animations for focus on
                 text field and RowView are clashing between each other.
                 */
                focus = focusedLineItemId
                guard let lineItemId = focusedLineItemId else { return }
                withAnimation {
                    //  We need to add the withAnimation call to animate the scroll to the whole row.
                    proxy.scrollTo(lineItemId, anchor: .top)
                }
            }
        }
    }
}

I would suggest you move:

let list = Array(state.lineItems.enumerated())

outside the scope of the list and make it a state so that it doesn't get re-evaluated whenever the view is updated

Using onChange would be the right way to ensure that SwiftUI enqueues a new update once the change to focusedLineItemId has propagated through.

Here is a link to a small public github repo that contains two .zip files of the 2 implementations in small test project formats. There are some comments to point you all towards in the ContentView in each.

Any insights are greatly appreciated!

@wwgvmalupo I took a look at the sample project and since you're binding the focus state to a value, instead of enqure the changes using onChange you could use focusedValue modifier to observe the values. Take a look at

Focus Cookbook: Supporting and enhancing focus-driven interactions in your SwiftUI app in particular RecipeGrid.swift source file.

Thanks for the reply!

We added the enumerated list separately and used a local variable to do the looping in the list. The issue seems to persist.

We've been trying to apply what you told us for the focusedValue but we don't see how it could be used in our case.

In the FocusCookbook RecipeGrid.swift example provided, focusedValue is used to change the macOS commands that will appear to the user depending on the context provided (in this case a recipe).

We don't see how applying focusedValue to our screen would solve our issue. We're already passing the focus state to each view in the List. Also, how would we change the onChange in there?

To reiterate on our use case, we have multiple cells that the user can focus onto but we want for only one cell to be focused at a time. Because of that, we added a @FocusState property to the main screen and sent bindings to all the rows in our list, so they can summon focus for themselves and also have a way of blocking the other cells to regain focus.

Could you be more specific on how could we apply focusedValue to our solution and what would it improve?

Issues with FocusState in List with views containing Textfields
 
 
Q