The capital of SwiftUI code debugging is caused by “change of heart”

0. Overview

This is a very simple piece of SwiftUI code, we pass the Item array to the subview and modify it in the subview, and the modified results are immediately reflected in the main view.

Unfortunately, when we modified the Item name, we found that we couldn’t input continuously: every time we typed a character, the keyboard would immediately close and the original input focus would be lost immediately. What’s going on?

In this blog post you will learn the following

  • 0. Overview
  • 1. Errors that shouldn’t happen
  • 2. Ineffective attempt: wrapping with subviews
  • 3. Get to the bottom of things
  • 4. Solution
  • Summarize

This problem is a common mistake that beginners often make in SwiftUI development, but after reading this article, I believe everyone will be confident about it!

No more nonsense, let’s fix it! ! !

1. Errors that should not occur

As usual, let’s take a look at the source code first.

In the example we created the Item structure to serve as the “source of truth” in the Model.

Friends who want to know more about SwiftUI programming and the secrets of the “source of truth”, please read the following articles in my special column:

  • “Chapter 10” Swift in various forms: UIKit and SwiftUI

Note that we made Item comply with the Identifiable protocol, which can better adapt to the display in the SwiftUI list:

struct Item: Identifiable {<!-- -->
    
    var id: String {<!-- -->
        name
    }
    
    var name: String
    var count: Int
}

let g_items: [Item] = [
    .init(name: "Universe Cube", count: 11),
    .init(name: "Gem Glove", count: 1),
    .init(name: "Bumblebee", count: 1)
]

Next is the main view ItemListView. You can see that we pass the items state to the ForEach loop of the subview:

struct ItemListView: View {<!-- -->
    @State var items = g_items
    
    private var total: Int {<!-- -->
        items.reduce(0) {<!-- --> $0 + $1.count}
    }
    
    private var desc: [String] {<!-- -->
        items.reduce([String]()) {<!-- --> $0 + [$1.name]}
    }
    
    var body: some View {<!-- -->
        NavigationStack {<!-- -->
            // Subview ForEach loop...
            ForEach($items) {<!-- --> $item in
// Code coming soon...
}
            
            VStack {<!-- -->
                Text(desc.joined(separator: ","))
                    .font(.title3)
                    .foregroundStyle(.pink)
                HStack {<!-- -->
                    Text("Total quantity of baby:\(total)")
                        .font(.headline)
                    
                    Spacer().frame(width: 20)
                    
                    Button("all + 1"){<!-- -->
                        for idx in items.indices {<!-- -->
                            guard items[idx].count < 100 else {<!-- --> continue}
                            
                            items[idx].count + = 1
                        }
                    }
                    .font(.headline)
                    .buttonStyle(.borderedProminent)
                }
            }.offset(y: 200)
        }
    }
}

Finally, there is the content in the ForEach loop. As shown below, we use the value binding of a single item to modify its content:

ForEach($items) {<!-- --> $item in
    HStack {<!-- -->
        
        TextField("Enter the item name", text: $item.name)
            .font(.title2.weight(.heavy))
        
        
        Text("Quantity:\(item.count)")
            .foregroundStyle(.gray)
        
        Slider(value: .init(get: {<!-- -->
            Double(item.count)
        }, set: {<!-- -->
            item.count = Int($0)
        }), in: 0.0...100.0)
    }
}
.padding()

Why does such a seemingly “seamless” piece of code have problems such as the keyboard being closed repeatedly and the input focus being lost when changing the Item name?

2. Ineffective attempt: wrapping with subviews

Our first guess was that the change in the Item name in the subview caused a “redundant” refresh of the parent view, causing the keyboard to be reset incorrectly.

For more examples of SwiftUI and Swift code debugging, please read the blog posts in my special column:

  • “Chapter 13” Swift’s self-cultivation: Swift debugging skills (Part 1)
  • “Chapter 14” Swift’s self-cultivation: Swift debugging skills (Part 2)

Because the view to which the keyboard belongs is rebuilt, the keyboard itself will also be reset. So how to verify our guess? One way is to use the following debugging techniques:

  • How does SwiftUI quickly identify which state change caused the refresh of the View interface?

Here we assume that this is indeed the case. Then a common solution immediately comes to mind: we can wrap the subview fragment that causes the refresh in a new View structure. The reason for this is that the SwiftUI renderer is smart enough to refresh only the subview and not the large portion of the superview. Changes in section content.

For more detailed principles, please refer to the following link:

  • Why should you often replace large sections of content in parent views with subviews in SwiftUI?

So, let me roll up my sleeves and get started!

First, wrap the View for editing a single Item in the ForEach loop into a new view, ItemEditView:

struct ItemEditView: View {<!-- -->
    @Binding var item: Item
    
    var body: some View {<!-- -->
        HStack {<!-- -->
            
            TextField("Enter the item name", text: $item.name)
                .font(.title2.weight(.heavy))
            
            
            Text("Quantity:\(item.count)")
                .foregroundStyle(.gray)
            
            Slider(value: .init(get: {<!-- -->
                Double(item.count)
            }, set: {<!-- -->
                item.count = Int($0)
            }), in: 0.0...100.0)
        }
    }
}

Next, we replace the ForEach loop itself with a new view:

struct EditView: View {<!-- -->
    
    @Binding var items: [Item]
    
    var body: some View {<!-- -->
        ForEach($items) {<!-- --> $item in
            ItemEditView(item: $item)
        }
        .padding()
    }
}

Finally, all we have to do is change the ForEach loop in the parent view ItemListView into the EditView view:

NavigationStack {<!-- -->
    EditView(items: $items)

    //Other code remains unchanged...
}

Run the code again… unfortunately the problem remains:

It seems that this is not a simple problem of “excessive” refresh of the parent view. There must be some inappropriate behavior that triggers the refresh of the parent view. What is it?

3. Get to the bottom of things

The problem must be in the ForEach loop!

Looking back at the previous definition of Item, we used the Identifiable protocol to satisfy ForEach’s pickiness about the uniqueness of sub-items, and we used Item.name to build the id attribute.

When a Model element complies with the Identifiable protocol, it should ensure that the id attribute value of all Items is unique at any time! From the current point of view, the above code does not have duplicate names when modifying the Item name (although it may happen), so there is no problem with uniqueness.

Of course, in actual code, users are likely to enter repeated Item names, so it is still unacceptable.

However, this code is only used as an example to show you the reasoning process to solve the problem, so there is no need to delve into it

But id has another important feature: stability!

Generally, when the id attribute of an Identifiable entity object changes, SwiftUI will consider that it is no longer the same object and immediately refresh its corresponding view interface.

So, as you can see:Every time the user enters a new character in name, the keyboard is immediately closed and focus is lost!

4. Solution

Once you know the cause of the problem, it’s easy to solve it.

We only need to ensure the stability of the id during the Item life cycle, which means that the name value can no longer be used as the “associated” value of the id:

struct Item: Identifiable {<!-- -->
    let id = UUID()
    
    var name: String
    var count: Int
}

As shown in the above code, we generate a unique UUID object for the id when the Item is created, which guarantees two things:

  • The uniqueness of Item at any time;
  • The stability of any Item during its life cycle;

After making the above modifications, let’s run the code again to see the results:

As you can see, now we can continue to input the names of Items without any problem, the focus will no longer be lost, everything is back to normal, great! ! !

Summary

In this blog post, we discuss a very common problem in SwiftUI development, and use step-by-step reasoning to find the root cause, and finally solve it perfectly! I believe that all my friends will benefit a lot from this.

Thanks for watching, see you soon!