user-interfaceswiftuivstackswiftui-vstack

VStack Not Resizing Properly to ForEach Contents


Consider the following setup (it's not the neatest display, but it was the quickest way I could think of to replicate my problem):

class IdentifiableString: Identifiable, Hashable {
    static func ==(lhs: IdentifiableString, rhs: IdentifiableString) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
    
    let id = UUID()
    var string = ""
}

struct ContentView: View {
    @State var textField1Text = ""
    @State var textField2Text = ""
    @State var textField3Text = ""
    @State var textField4Text = ""
    @State var textField5Text = ""
    @State var textField6Text = ""
    @State var textField7Text = ""
    @State var textField8Text = ""
    @State var textField9Text = ""
    @State var textField10Text = ""
    @State var textField11Text = ""
    @State var textField12Text = ""
    @State var textField13Text = ""
    @State var textField14Text = ""
    @State var textField15Text = ""
    
    @State var identifibleStrings =  [IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString(), IdentifiableString()]
    
    var body: some View {
        List {
            Button("Reset", role: .destructive) {
                textField1Text = ""
                textField2Text = ""
                textField3Text = ""
                textField4Text = ""
                textField5Text = ""
                textField6Text = ""
                textField7Text = ""
                textField8Text = ""
                textField9Text = ""
                textField10Text = ""
                textField11Text = ""
                textField12Text = ""
                textField13Text = ""
                textField14Text = ""
                textField15Text = ""
                
                for identifibleString in identifibleStrings {
                    identifibleString.string = ""
                }
            }
            
            Section {
                TextField("TextField 1", text: $textField1Text)
                    .onChange(of: textField1Text) { _, newValue in
                        identifibleStrings[0].string = newValue
                    }
                TextField("TextField 2", text: $textField2Text)
                    .onChange(of: textField2Text) { _, newValue in
                        identifibleStrings[1].string = newValue
                    }
                TextField("TextField 3", text: $textField3Text)
                    .onChange(of: textField3Text) { _, newValue in
                        identifibleStrings[2].string = newValue
                    }
                TextField("TextField 4", text: $textField4Text)
                    .onChange(of: textField4Text) { _, newValue in
                        identifibleStrings[3].string = newValue
                    }
                TextField("TextField 5", text: $textField5Text)
                    .onChange(of: textField5Text) { _, newValue in
                        identifibleStrings[4].string = newValue
                    }
                TextField("TextField 6", text: $textField6Text)
                    .onChange(of: textField6Text) { _, newValue in
                        identifibleStrings[5].string = newValue
                    }
                TextField("TextField 7", text: $textField7Text)
                    .onChange(of: textField7Text) { _, newValue in
                        identifibleStrings[6].string = newValue
                    }
                TextField("TextField 8", text: $textField8Text)
                    .onChange(of: textField8Text) { _, newValue in
                        identifibleStrings[7].string = newValue
                    }
                TextField("TextField 9", text: $textField9Text)
                    .onChange(of: textField9Text) { _, newValue in
                        identifibleStrings[8].string = newValue
                    }
                TextField("TextField 10", text: $textField10Text)
                    .onChange(of: textField10Text) { _, newValue in
                        identifibleStrings[9].string = newValue
                    }
                TextField("TextField 11", text: $textField11Text)
                    .onChange(of: textField11Text) { _, newValue in
                        identifibleStrings[10].string = newValue
                    }
                TextField("TextField 12", text: $textField12Text)
                    .onChange(of: textField12Text) { _, newValue in
                        identifibleStrings[11].string = newValue
                    }
                TextField("TextField 13", text: $textField13Text)
                    .onChange(of: textField13Text) { _, newValue in
                        identifibleStrings[12].string = newValue
                    }
                TextField("TextField 14", text: $textField14Text)
                    .onChange(of: textField14Text) { _, newValue in
                        identifibleStrings[13].string = newValue
                    }
                TextField("TextField 15", text: $textField15Text)
                    .onChange(of: textField15Text) { _, newValue in
                        identifibleStrings[14].string = newValue
                    }
            }
            
            Section {
                VStack {
                    ForEach(identifibleStrings) { identifiableString in
                        if identifiableString.string != "" {
                            Text(identifiableString.string)
                        }
                    }
                }
            }
        }
    }
}

I've discovered that when I provide all of my text fields with some kind of input, tap on the reset button, and then scroll down to my VStack, I see this:

Oversized VStack

and only when I scroll my VStack off screen and back again do I find that it has properly resized itself:

Properly Sized VStack

Note: This behavior doesn't occur if the VStack's row is visible on the UI when the reset button is tapped (e.g. comment out text fields 10-15, or run the code on a device with a substantially larger screen (such as an iPad)).

I've also occasionally had the VStack not grow sufficiently to display all of its contents properly, but I haven't been able to consistently replicate that behavior. I attempt to do this by creating a "Set with Dummy Data" button that automatically populated all my text fields with data, but the VStack grew to the expected size.

I tried wrapping my VStack in an if block that would only allow the VStack to show if an @State bool variable in my view struct was set to false:

 @State var resetUpdate = false
 Button("Set with Dummy Data") {
    resetUpdate = true
    ...
 }
 .onChange(of: resetUpdate) { _, newValue in
     if newValue == true {
         resetUpdate = false
     }
 }
if !resetUpdate {
    VStack {
        ...

But that had no effect. Using .fixedSize(horizontal: true, vertical: false) on the Text view in the ForEach loop doesn't do anything either. Does anyone have any guesses as to why this behavior is occurring (as well as how to prevent it)?


Solution

  • Note @State var ... works with value type, eg struct, not reference type, eg class, unless you use the Observation framework.

    Try this approach using @Observable class IdentifiableString and some ForEach loops to simplify the code.

    It seems that the View does not rerender the VStack properly (probably another SwiftUI bug). To force it to do so, I used an .id() and change its value when the reset button is tapped.

    Example code that works for me:

    @Observable class IdentifiableString: Identifiable {
        let id = UUID()
        var string = "xxxx" // <--- for testing
        init() {  }
    }
    
    struct ContentView: View {
        @State private var items = [IdentifiableString]()
        @State private var id = false  // <--- here
    
        var body: some View {
            List {
                Button("Reset", role: .destructive) {
                    items.forEach{ $0.string = ""}
                    id.toggle()  // <--- here
                }
                Section {
                    ForEach($items) { $item in
                        TextField("TextField", text: $item.string)
                    }
                }
                Section {
                    VStack {
                        ForEach(items) { item in
                            if !item.string.isEmpty {
                                Text(item.string)
                            }
                        }
                    }
                    .id(id) // <--- here, or on Section, or List
                }
            }
            .onAppear {
                for _ in 0...24 { items.append(IdentifiableString())}
            }
        }
    }
    

    Edit-1:

    If you really need to have a ObservableObject then use this code, do not nest ObservableObject and do not use your static func == .. based on id, and no need for func hash.... Also use @StateObject not @ObservedObject, such as:

    struct IdentifiableString: Identifiable {  // <--- here
        let id = UUID()
        var string = "xxxx" // <--- for testing
    }
    
    class ItemsModel: ObservableObject {
        @Published var items = [IdentifiableString]()
    }
    
    struct ContentView: View {
        @StateObject private var dataModel = ItemsModel()  // <--- here
        @State private var id = false  // <--- here
    
        var body: some View {
            List {
                Button("Reset", role: .destructive) {
                    dataModel.items.indices.forEach{ dataModel.items[$0].string = ""}
                    id.toggle()  // <--- here
                }
                Section {
                    ForEach($dataModel.items) { $item in
                        TextField("TextField", text: $item.string)
                    }
                }
                Section {
                    VStack {
                        ForEach(dataModel.items) { item in
                            if !item.string.isEmpty {
                                Text(item.string)
                            }
                        }
                    }
                    .id(id) // <--- here, or on Section, or List
                }
            }
            .onAppear {
                for _ in 0...24 { dataModel.items.append(IdentifiableString())}
            }
        }
    }