scroll indicator incosistent and stutters with LazyVStack

217 views Asked by At

I use LazyVStack because I need to load a big list of elements, LazyVStack helps with loading fewer elements, hence the scroll bar/indicator does not know the real size of the list, causing inconsistency on the scroll bar position, it stutters, meaning that the scroll goes up and down randomly, not knowing the real size of the list.

How can I change this, make the scroll bar consistent? I think its important to say that I know the real size of the list.

The code:

ScrollViewReader { proxy in
                    
     ScrollView {

         LazyVStack {//this is the LazyVStack that cause the scroll bar to be inconsistent

               ForEach(visibleCategories, id: \.self) { myCategory in
                                                        
                   VStack {
                                                                          
                       let productsInCategory = productsByCategory[myCategory]

                       ForEach(productsInCategory, id: \.self.name) { product in
                              
                          
                            HStack{}.frame(width: UIScreen.main.bounds.width - 70, height: 105)
                                           .padding(.trailing, 30)
                                                            
                                  

Using simple VStack will solve the problem, but because I have a big list of elements, the performance will decrease, so I need to use LazyVStack.

The problem happens here:

LazyVStack{

    ForEach(visibleCategories, id: \.self) { myCategory in

As there are more categories that the LazyVStack did not load.

How can I make the scroll consistent together with LazyVStack, knowing that I know the size of the list?

4

There are 4 answers

2
Taeeun Kim On

The LazyVStack doesn't load all the items at once and therefore doesn't know the full size of the scrollable content initially, which can cause the scroll indicator to behave inconsistently.

I didn't test it, but you could you try this solution.
———

  1. calculate the height: spacing between items, padding top/bottom, item's height.
  • e.g. let contentHeight = topPadding(10) + bottomPaddding(10) + (itemSpacing * items.count) + (item.height * items.count)
  1. set this height to LazyVStack
    LazyVStack(spacing: 10) {}.frame(height: contentHeight)
  2. now your LazyVStack knows the correct height and it should display correct scroll indicator
2
VonC On

You could try and pre-calculate the expected height of each item in the list and use it to set a placeholder within the LazyVStack that mimics the total height of the unloaded content (a bit as in this question).

ScrollViewReader { proxy in
    ScrollView {
        LazyVStack {
            ForEach(visibleCategories, id: \.self) { myCategory in
                VStack {
                    let productsInCategory = productsByCategory[myCategory]
                    ForEach(productsInCategory, id: \.self.name) { product in
                        HStack {}
                            .frame(width: UIScreen.main.bounds.width - 70, height: 105)
                            .padding(.trailing, 30)
                    }
                }
                // Placeholder to mimic the height of unloaded content
                .frame(height: calculateHeightForCategory(myCategory))
            }
        }
    }
}

calculateHeightForCategory would be a function you should implement to estimate the height of each category's content. That function can consider the number of items in each category and any other dynamic content sizes you might have. The key is to estimate the height as closely as possible to the actual content height.
It is important to implement the dynamic height calculation efficiently to avoid performance issues, especially with massive lists. The calculation should be triggered only when necessary (e.g., when items are added or removed from a category) to minimize unnecessary recalculations.

+-------------------------+
| ScrollView              |
| +---------------------+ |
| | LazyVStack          | |
| | +-----------------+ | |
| | | Placeholder     | | |
| | | (Mimic Height)  | | |
| | +-----------------+ | |
| | +-----------------+ | |
| | | HStack          | | |
| | |                 | | |
| | +-----------------+ | |
| |                     | |
| +---------------------+ |
+-------------------------+

That would not eliminate the scroll indicator's adjustment as new items load, but it should significantly reduce stuttering and make the scroll behaviour feel more natural and consistent, since the scroll indicator can better predict the total scrollable content size.


I don't see how this will predict the size of the view if the views are lazy, the scroll will not know the size of the entire view in advance, even calculateHeightForCategory will calculate as a lazy view, right? So the scroll will still be inconsistent.

I agree: The approach of dynamically calculating the height for each category assumes that you can predict the size of each item in the LazyVStack before it is loaded, which is challenging with truly dynamic content and the lazy loading nature of LazyVStack.
Since LazyVStack loads content on demand to improve performance with large datasets, it does not have prior knowledge of the overall content size, leading to the observed inconsistency in the scroll indicator's behaviour.

The core issue is that SwiftUI's LazyVStack does not calculate the total height of its content upfront because it is designed to instantiate views lazily as they come into view to save memory and improve performance. That design choice will cause the scroll indicator to adjust dynamically as new content loads and its size becomes known.

Given the limitations of LazyVStack, you might need to consider some alternative strategies to... mitigate the issue (but yes, they may not eliminate the scroll inconsistency but could offer improved user experience):

  • Estimated average height Instead of trying to calculate the exact height of each category, use an estimated average height for each item and multiply it by the total number of items. That provides a rough estimate of the total scrollable content size, which might reduce the inconsistency of the scroll indicator.

    let estimatedHeightPerItem = 105 // Adjust based on your UI design
    let totalItems = visibleCategories.flatMap { productsByCategory[$0] }.count
    let estimatedTotalHeight = estimatedHeightPerItem * totalItems
    

    You can set this estimated height as a placeholder or background view within the ScrollView to artificially increase its scrollable area. However, this method is still a workaround and might not perfectly reflect the actual content size, especially with variable item heights.

  • Static content size with dynamic loading: If the primary goal is to maintain scroll indicator consistency and you have a reasonable maximum height estimate for your content, consider using a hybrid approach. You can still use LazyVStack for its lazy loading benefits but within a container that has a fixed height based on the maximum possible content size. That fixed-height container can then adjust its size as actual content height becomes known, but it starts with a maximum estimate to keep the scroll indicator more consistent.

  • Pagination or load more button: Another approach to mitigate scrolling issues with large lists is to implement pagination or a "Load More" button. That method allows you to control the amount of content loaded at any one time, potentially reducing the need for lazy loading and making it easier to predict the size of the content. While it changes the user experience, it can offer a more consistent and controlled way to manage large datasets.

0
Roy Rodney On

Lazy UI needs to have a set element size. You have a few options:

  1. Try a workaround as already suggested by others
  2. Try to change which parts of your content are lazy - in this case, your LazyVStack is populated by VStacks. The size of these VStacks is of course, variable. However, the children of these VStacks apparently have a fixed size. What if the parent VStack is lazy, and has LazyVStack Children?
  3. Change your design, to use paging, collapsible content, sections, etc. Maybe a list.
0
Benzy Neez On

First off, a few observations and assumptions, based on the code provided:

  • An outer ForEach is being used to iterate over all visible categories, a nested ForEach is then used to iterate over the products within a category.
  • Visible categories are represented by a Hashable type, perhaps a string or an enum.
  • You mentioned in a comment that it should be possible to remove some categories, perhaps in connection with a filter.
  • The name of a product in a category is used as its id, presumably because the type of a product does not implement Identifiable.
  • The ScrollView is wrapped with a ScrollViewReader, which can be used to .scrollTo a particular entry by its id.
  • UIScreen.main is being used to read the screen width. This is deprecated.

Using string names as ids is ok, provided there are no duplicates. However, if there are products with the same name but in different categories, it would not be possible to scroll to these products reliably.

Both the LazyVStack and the ScrollViewReader might perform more reliably if the view structure would be flattened:

  • The aim would be to have one top-level collection that includes both category headers and category contents.
  • The items in this collection should have ids that are unique over the entire collection.

Each entry in the view can be represented by a simple Identifiable struct like this:

struct ViewPart: Identifiable {
    let id: Int
    let category: Category
    let product: Product?

    init(id: Int, category: Category, product: Product? = nil) {
        self.id = id
        self.category = category
        self.product = product
    }

    var productName: String {
        product?.name ?? ""
    }
}

An ObservableObject could be used to create and manage the view parts. In the example implementation below, the id for a part is built as (category id * multiplier) + (product id). The ids for the categories and products are simply allocated in sequence and recorded in lookup dictionaries:

class ViewPartFactory: ObservableObject {

    private let maxProductsPerCategory = 10000000
    private var categoryIds = [Category: Int]()
    private var productIds = [String: Int]()
    private var latestCategoryId = 0
    private var latestProductId = 0
    @Published private var viewParts = [ViewPart]()

    var allViewParts: [ViewPart] {
        viewParts
    }

    func idForCategory(category: Category) -> Int {
        let result: Int
        if let id = categoryIds[category] {
            result = id
        } else {
            latestCategoryId += 1
            categoryIds[category] = latestCategoryId
            result = latestCategoryId
        }
        return result
    }

    private func idForProduct(product: Product) -> Int {
        let result: Int
        if let id = productIds[product.name] {
            result = id
        } else {
            latestProductId += 1
            productIds[product.name] = latestProductId
            result = latestProductId
        }
        return result
    }

    func idForCategoryAndProduct(category: Category, product: Product) -> Int {
        var result = idForCategory(category: category)
        result *= maxProductsPerCategory
        result += idForProduct(product: product)
        return result
    }

    func buildViewParts(visibleCategories: [Category], productsByCategory: [Category: [Product]]) {
        var newParts = [ViewPart]()
        for category in visibleCategories {

            // Append an entry for the Category
            newParts.append(
                ViewPart(
                    id: idForCategory(category: category),
                    category: category
                )
            )
            if let productsInCategory = productsByCategory[category] {

                // Append entries for each Product in the Category
                for product in productsInCategory {
                    newParts.append(
                        ViewPart(
                            id: idForCategoryAndProduct(category: category, product: product),
                            category: category,
                            product: product
                        )
                    )
                }
            }
        }
        viewParts = newParts
    }
}

The actual view of all categories and products can now be built using the array of parts that is published by the factory. A GeometryReader can be used to measure the screen width, instead of relying on UIScreen.main:

struct CategoriesAndProducts: View {

    let visibleCategories: [Category]
    let productsByCategory: [Category : [Product]]

    @StateObject private var factory = ViewPartFactory()

    @ViewBuilder
    private func viewForPart(viewPart: ViewPart, screenWidth: CGFloat) -> some View {
        if let product = viewPart.product {

            // Category + Product available
            HStack{
                Text(String(describing: viewPart.category))
                Text(product.name)
            }
            .frame(width: screenWidth - 70, height: 105)
            .padding(.trailing, 30)
        } else {

            // Category only
            Text(String(describing: viewPart.category))
                .font(.largeTitle)
        }
    }

    var body: some View {
        GeometryReader { geoProxy in
            let screenWidth = geoProxy.size.width
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack {
                        ForEach(factory.allViewParts) { viewPart in
                            viewForPart(viewPart: viewPart, screenWidth: screenWidth)
                        }
                    }
                }
            }
        }
        // pre iOS 17: use version without initial and oldVal,
        // use onAppear as substitute for initial
        .onChange(of: visibleCategories, initial: true) { oldVal, newVal in
            factory.buildViewParts(
                visibleCategories: newVal,
                productsByCategory: productsByCategory
            )
        }
    }
}

This type of view structure would allow other refinements to be implemented easily:

  • To scroll to a category or a particular product in a category, the unique id of the corresponding entry can be obtained from the factory.

  • If categories (or products within categories) need to be filtered from view, they only need to be hidden, they do not necessarily need to be removed from the collection. The factory function viewForPart could return an EmptyView in this case.

  • If the ScrollView is still stuttering, you could try applying a computed height to the LazyVStack, as described in other answers. Each individual view part could deliver its own estimated height, perhaps by implementing a function such as estimatedHeight. The total estimated height could then be delivered as a computed property.