5

TL;DR: Is there some parameter or way to set the offset at which LazyVStack initialises views?

LazyVStack initialises the views lazily, so when I scroll, the next (few?) views are initialised. I am loading an image once a view is drawn, using SDWebImage Package in swift. This takes a view milliseconds, and since I am using a LazyVStack, if one scrolls fast (even within reasonable limits), the placeholder is visible for a short moment, because the view has just been created a (too) short moment ago. If I scroll very slowly, the image loads just before the view appears, so no placeholder is visible.

If I could make the LazyVStack initialise the views just a few milliseconds earlier my problem would be gone...

Once would think this is a pretty common problem, timing this initialisation just right so as not to load too early or too late.. but nothing at all in the docs about this

D. Kee
  • 169
  • 14
  • the feature of lazyStack is to consume less memory in ram and load a view once it's needed. now when it comes to image there it takes few secs to get download and it will be there there is no way to know that on which pace user will scroll then only way is that you preload everything (without lazy loading that will consume memory) either it will take time to load the image – Noor Ahmed Natali Jan 10 '23 at 12:22

3 Answers3

1

this process is called as prefetching -because you're prefetching them so it will look smooth-

And sorry, but there's no way to access prefetching of LazyVStack in SwiftUI right now. Also, keep in mind that both SwiftUI's Grid And LazyH/VStack is not performant as UIKit's UICollectionView. So what you could do here is you can use UICollectionView's UICollectionViewDataSourcePrefetching protocol in your collection view's data.

I used SDWebImage Library to Fetch Images from internet (one of the most popular libraries for UIKit)

I tried to explain everything as comments in the code so give your attention to them, here's what it looks like: gif

here's the code:

import SwiftUI
import SDWebImage

struct CollectionView: UIViewRepresentable {
    let items: [String]
    
    func makeUIView(context: Context) -> UICollectionView {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collectionView.register(ImageCell.self, forCellWithReuseIdentifier: "ImageCell")
        collectionView.delegate = context.coordinator
        collectionView.dataSource = context.coordinator
        return collectionView
    }
    
    func updateUIView(_ uiView: UICollectionView, context: Context) {
        // Reload the collection view data if the items array changes
        uiView.reloadData()
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDataSourcePrefetching {
        let parent: CollectionView
        
        init(_ collectionView: CollectionView) {
            self.parent = collectionView
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return parent.items.count
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCell
            let item = parent.items[indexPath.item]
            
            // Set the progress of the progress view as the image is being downloaded
            cell.progressView.progress = 0.0
            SDWebImageDownloader.shared.downloadImage(with: URL(string: item), options: .highPriority, progress: { (receivedSize, expectedSize, url) in
                DispatchQueue.main.async {
                    cell.progressView.progress = Float(receivedSize) / Float(expectedSize)
                }
            }) { (image, data, error, finished) in
                DispatchQueue.main.async {
                    cell.imageView.sd_setImage(with: URL(string: item))
                    cell.progressView.isHidden = true
                }
            }
            return cell
        }
        
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            return CGSize(width: 100, height: 100)
        }
        
        // MARK: - UICollectionViewDataSourcePrefetching
        
        func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
            // Filter the index paths to only include the ones that are within the desired range, trick relies on here
            // In our example, i'm fetching 6 items beforehand which equals 2 rows, so i'm prefetching 2 rows beforehand. you can increase that amount if you w ant to
            let prefetchIndexPaths = indexPaths.filter { $0.item < collectionView.numberOfItems(inSection: $0.section) - 6 }
            let urls = prefetchIndexPaths.compactMap { URL(string: self.parent.items[$0.item])! }
            SDWebImagePrefetcher.shared.prefetchURLs(urls)
        }
        
        func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
            // Cancel the prefetching for the given index paths, this is not required but i wanted to add it
            let urls = indexPaths.map { URL(string: self.parent.items[$0.item]) }
        }
    }
}

class ImageCell: UICollectionViewCell {
    let imageView = UIImageView()
    let progressView = UIProgressView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(imageView)
        addSubview(progressView)
        // if you're not familiar with uikit this is just a disgusting uikit code to make proper layouts :(
        progressView.translatesAutoresizingMaskIntoConstraints = false
        progressView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        progressView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        progressView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.topAnchor.constraint(equalTo: progressView.bottomAnchor).isActive = true
        imageView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        imageView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        imageView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

and Here's how you can implement it to swiftui:


struct ContentView: View {
    var items : [String] {
        var i = 0
        var _items = [String]()
        while (i < 900) {
            _items.append("https://picsum.photos/\(Int.random(in: 300..<600))/\(Int.random(in: 300..<600))")
            i = i + 1
        }
        return _items
    }
    
    var body: some View {
        CollectionView(items: items)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

I used lorem picsum which is a website for generating random images and that's why you see images reloading randomly in my sample(that white ones), in your case, this shouldn't be a problem

grandsirr
  • 584
  • 4
  • 19
  • Could you elaborate on the statement _"`LazyH/VStack` is not performant as UIKit's `UICollectionView`"_? Do you have any source to back this up or how do you make this conclusion? Also, is it still more performant to use `UICollectionView` if it is wrapped in a `UIViewRepresentable`? – D. Kee Jan 11 '23 at 12:47
  • And one more question (though not directly related, but would be important for us to be able to use the approach suggested by you): in our current setup we have a custom pull to refresh. Could you give some directions how this could be implemented when switching to a `UICollectionView` inside a `UIViewRepresentable`? (i.e. get the offset that the `UICollectionView` is dragged further than its top most position) – D. Kee Jan 11 '23 at 13:02
  • @D.Kee I'm glad you ask that. Yes, [you can check the discussion here](https://developer.apple.com/forums/thread/657902) Also Wrapping UIKit to SwiftUI is totally okay because in the older versions of SwiftUI pretty much [everything was wrapped to UIKit with UIViewRepresentable anyways](https://betterprogramming.pub/how-to-access-the-uikit-components-under-swiftui-objects-4a808568014a). – grandsirr Jan 12 '23 at 12:53
  • @D.Kee for your second request, you can just open a new question. Comments are not the right place for that. -i'm also a full SwiftUI developer but UIKit knowledge is handy-dandy for these situations so you might want to dive into UICollectionView for a couple days- – grandsirr Jan 12 '23 at 12:56
  • how would one go about incorporating a SwiftUI view inside this `UIViewRepresentable` of the `UICollectionView`? Or would this once again defeat the increased performance, by using SwiftUI inside UiKit inside SwiftUI? – D. Kee Jan 14 '23 at 12:42
0

Quick answer to the question: no


That being said, in this case there is still a solution: Since I was using SDWebImageSwiftUI before, simply calling the following already before the view starts to initialise solved my problem:

SDWebImagePrefetcher.shared.prefetchURLs(urls) { finishedCount, skippedCount in
     print("preloading complete")
}

then in my LazyVStack I use:

LazyVStack {
     ForEach(items, id: \.self) { item in
             ItemView(item: item)
                   .onAppear {
                        // calling function to prefetch next x-items by their url
                  }
            }
     }
}
D. Kee
  • 169
  • 14
-1

You can try adding extra space to the ScrollView and removing it using .padding:

private enum Constant {
    static let topInset: CGFloat = UIScreen.main.bounds.height * 0.4
    static let bottomOffset: CGFloat = UIScreen.main.bounds.height * 0.4
}

struct PrefetchedScrollView<Content: View>: View {
    let axes: Axis.Set
    let showsIndicators: Bool
    let content: () -> Content

   var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            Spacer(minLength: Constant.topInset)
            content()
            Spacer(minLength: Constant.bottomOffset)
        }
        .padding(top: -Constant.topInset, bottom: -Constant.bottomOffset)
    }
}

PS: This method adds a bug with pull to refresh, but this bug is easy to fix, using a custom pull to refresh implementation

BarredEwe
  • 79
  • 3