Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

LazyVGrid inside ScrollView on macOS Sonoma performance

We have a simple SwiftUI app for macOS. Nothing fancy, just LazyVGrid with numbers inside ScrollView. And we run it on a MacBook Pro M2 Max 16' fullscreen. I can hit about 50FPS. Scrolling is not smooth. How can I get 120FPS on 120Hz Pro motion display?

Here is the code:

struct GridTest: View {
    @State var data = (1...30000).map { "\($0)" }

    //doesn't matter if this is adaptive or fixed..it is still slow
    //there is no way to hit 120Hz on mac?
    let columns = [GridItem(.fixed(90)), 
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90)),
                   GridItem(.fixed(90))
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(data, id: \.self)  {item in
                    Text(item).frame(width: 100, height: 30)
                }
            }.frame(width: 1700)
        }
    }
}//GridTest  

FPS measured with Quartz Debug. Can be downloaded from Apple web in more section, Additional_Tools_for_Xcode_15.

like image 613
Juraj Antas Avatar asked Sep 01 '25 23:09

Juraj Antas


1 Answers

Why is this happening?

It seems you have confused the term lazy with the term reusable (also known as recyclable).

Lazy means it won't be initialized at the assignment point. Instead, it will be initialized at the first call. So, for example, using a range of 1...30_000_000 with a LazyVGrid, will NOT immediately take up 8GB of your RAM, but it definitely WILL take it all when you call all of them gradually, like by scrolling to the end.

But Recycling, on the other hand, means creating a limited number of objects and using them again and again instead of having a huge number of objects.


How can I improve the performance?

You should use the lazy method when you don't need all items instantly and use Recycling when the elements can be reusable.

In your case (I have bumped the numbers to make it extreme), the following line has a heavy impact and takes around 500MB memory right at the point:

@State var data = (1...30_000_000).map { "\($0)" } // šŸ‘ˆ Using `map` will cause all 30,000 items to be created instantly!

It can be lazy since we don't need them all at once, like:

@State var data = (1...30_000_000).lazy.map { "\($0)" } // šŸ‘ˆ Using `lazy.map` will postpone the map operation of each individual element

And you can use a View that recycles its content like the native SwiftUI List or a wrapped counter part from the AppKit a UIKit like CollectionView which is totally fine and standard to use and has a lot of built-in performance friendly APIs.

For example You can do the math an calculate how many items you need for each row and chunk the data into separate reusable rows:

struct GridTest: View {
    @State var data = (1...30_000_000).lazy.map(String.init) // šŸ‘ˆ Using a lazy collection

    let columns = Array(repeating: GridItem(.fixed(90)), count: 15)
    var verticalCount: Int { columns.count }
    var chuckedRange: Range<Int> { 0..<data.count/verticalCount }

    var body: some View {
        List(chuckedRange, id: \.self) { rowNumber in // šŸ‘ˆ Using a recycling View
            LazyVGrid(columns: columns) {
                let range = (0..<verticalCount).map { $0 + rowNumber*verticalCount }
                ForEach(range, id: \.self) { index in
                    Text(data[ClosedRange<Int>.Index.inRange(index)])
                }
            }
        }
    }
}

You can also reduce the number of final rendering elements by making compositingGroups and even make them render offscreen by using drawingGroup modifier on each line but it should be benchmarked for the reliable results!

LazyVGrid(columns: columns) { ... }
    .compositingGroup()
    .drawingGroup()

Need more performant way?

SwiftUI is just a tool to make the basic UI tasks easy. If you have some advanced UI logic to implement (like showing millions of data in an infinite canvas), you should implement some more GPU friendly mechanism like using Canvas, Metal or etc. with prefetching, caching and even async loading mechanisms like what the AsyncDisplayKit used to do.

Also

There was a WWDC session (Unfortunately it was a long time ago and I don't remember the session number) that described how Apple implements the Photos App collection view with hundreds of Images to work at maximum FPS and even interactive by holding finger on small pixels to preview the image.

That can be good start if someone can mention the link.

I Hope this helps


šŸ’” Premature optimization is the root of all evil (or at least most of it) in programming. Donald Knuth
like image 139
Mojtaba Hosseini Avatar answered Sep 03 '25 17:09

Mojtaba Hosseini