joncarl
joncarl

Reputation: 642

SwiftUI ForEach not correctly updating in scrollview

I have a SwiftUI ScrollView with an HStack and a ForEach inside of it. The ForEach is built off of a Published variable from an ObservableObject so that as items are added/removed/set it will automatically update in the view. However, I'm running into multiple problems:

  1. If the array starts out empty and items are then added it will not show them.
  2. If the array has some items in it I can add one item and it will show that, but adding more will not.

If I just have an HStack with a ForEach neither of the above problems occur. As soon as it's in a ScrollView I run into the problems.

Below is code that can be pasted into the Xcode SwiftUI Playground to demonstrate the problem. At the bottom you can uncomment/comment different lines to see the two different problems.

If you uncomment problem 1 and then click either of the buttons you'll see just the HStack updating, but not the HStack in the ScrollView even though you see init print statements for those items.

If you uncomment problem 2 and then click either of the buttons you should see that after a second click the the ScrollView updates, but if you keep on clicking it will not update - even though just the HStack will keep updating and init print statements are output for the ScrollView items.

import SwiftUI
import PlaygroundSupport
import Combine

final class Testing: ObservableObject {
    @Published var items: [String] = []

    init() {}

    init(items: [String]) {
        self.items = items
    }
}

struct SVItem: View {
    var value: String

    init(value: String) {
        print("init SVItem: \(value)")
        self.value = value
    }

    var body: some View {
        Text(value)
    }
}

struct HSItem: View {
    var value: String

    init(value: String) {
        print("init HSItem: \(value)")
        self.value = value
    }

    var body: some View {
        Text(value)
    }
}

public struct PlaygroundRootView: View {
    @EnvironmentObject var testing: Testing

    public init() {}

    public var body: some View {
        VStack{
            Text("ScrollView")
            ScrollView(.horizontal) {
                HStack() {
                    ForEach(self.testing.items, id: \.self) { value in
                        SVItem(value: value)
                    }
                }
                .background(Color.red)
            }
            .frame(height: 50)
            .background(Color.blue)
            Spacer()
            Text("HStack")
            HStack {
                ForEach(self.testing.items, id: \.self) { value in
                    HSItem(value: value)
                }
            }
            .frame(height: 30)
            .background(Color.red)
            Spacer()
            Button(action: {
                print("APPEND button")
                self.testing.items.append("A")
            }, label: { Text("APPEND ITEM") })
            Spacer()
            Button(action: {
                print("SET button")
                self.testing.items = ["A", "B", "C"]
            }, label: { Text("SET ITEMS") })
            Spacer()
        }
    }
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = UIHostingController(
    // problem 1
    rootView: PlaygroundRootView().environmentObject(Testing())

    // problem 2
    // rootView: PlaygroundRootView().environmentObject(Testing(items: ["1", "2", "3"]))
)

Is this a bug? Am I missing something? I'm new to iOS development..I did try wrapping the actual items setting/appending in the DispatchQueue.main.async, but that didn't do anything.

Also, maybe unrelated, but if you click the buttons enough the app seems to crash.

Upvotes: 10

Views: 5061

Answers (3)

Simon
Simon

Reputation: 1850

For better readability and also because the answer didn't work for me. I'd suggest @TheLegend27 answer to be slightly modified like this:

if self.items.count != 0 {

   ScrollView(showsIndicators: false) {
       ForEach(self.items, id: \.self) { _ in
           RowItem()
       }
   }

}

Upvotes: 2

TheLegend27
TheLegend27

Reputation: 762

Just ran into the same issue. Solved with empty array check & invisible HStack

ScrollView(showsIndicators: false) {
    ForEach(self.items, id: \.self) { _ in
        RowItem()
    }

    if (self.items.count == 0) {
        HStack{
            Spacer()
        }
    }
}

Upvotes: 8

Asperi
Asperi

Reputation: 257719

It is known behaviour of ScrollView with observed empty containers - it needs something (initial content) to calculate initial size, so the following solves your code behaviour

@Published var items: [String] = [""]

In general, in such scenarios I prefer to store in array some "easy-detectable initial value", which is removed when first "real model value" appeared and added again, when last disappears. Hope this would be helpful.

Upvotes: 4

Related Questions