Discussing List and ForEach in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Using List in SwiftUI can be very convenient and quick for creating various types of lists. List is essentially a wrapper around UITableView (for more specific usage of List, please refer to Basic Usage of List).

To add dynamic content in a List, we can use two methods:

Directly using the dynamic content constructor provided by List

Swift
  List(0..<100) { i in
    Text("id:\(id)")
  }

Using ForEach inside List

Swift
  List {
    ForEach(0..<100) { i in
      Text("id:\(id)")
    }
  }

Before encountering the problem I recently had, I always thought these two methods were almost the same except for a few minor differences.

Known differences at that time:

Using ForEach allows adding multiple dynamic sources in the same List, and static content

Swift
  List {
    ForEach(items, id: \.self) { item in
      Text(item)
    }
    Text("Other content")
    ForEach(0..<10) { i in
      Text("id:\(i)")
    }
  }

ForEach allows for layout control of dynamic content

Swift
  List {
    ForEach(0..<10) { i in
      Rectangle()
        .listRowInsets(EdgeInsets()) // Can control boundary insets
    }
  }
  
  List(0..<10) { i in
     Rectangle()
        .listRowInsets(EdgeInsets())
        // Cannot control boundary insets. .listRowInsets(EdgeInsets()) only effective for static content in List
  }

Based on these differences, I mostly used ForEach to populate List content and achieved the desired effects.

But recently, while developing a list similar to the iOS Mail app, I encountered an incredibly frustrating issue — the list was so laggy it was unbearable.

The following video shows the painful performance of my app:

Swift
 List {
    ForEach(0..<10000) { i in
        Cell(id: i)
          .listRowInsets(EdgeInsets())
          .swipeCell(cellPosition: .both, leftSlot: slot1, rightSlot: slot1)
        }
    }

With 10 records, everything was perfect, but with 10,000 records, it completely bogged down to a PowerPoint-like state. Especially, the View initialization took a lot of time.

At first, I thought it might be an issue with the swipe menu I wrote, but after reviewing my code, I ruled out this option. To better understand the lifecycle state of Cells in List, I wrote the following test code.

Swift
    struct Cell: View {
        let id: Int
        @StateObject var t = Test()
        init(id: Int) {
            self.id = id
            print("init:\(id)")
        }
        var body: some View {
            Rectangle()
                .fill(Color.blue)
                .overlay(
                    Text("id:\(id)")
                )
                .onAppear {
                    t.id = id
                }
        }
        
        class Test: ObservableObject {
            var id: Int = 0 {
                didSet {
                    print("get value \(id)")
                }
            }
            init() {
                print("init object")
            }
            deinit {
                print("deinit:\(id)")
            }
        }
    }
    
    class Store: ObservableObject {
        @Published var currentID: Int = 0
    }

After running, I discovered a strange phenomenon: In List, if you use ForEach to handle the data source, all Views of the data source need to be initialized at the creation of the List, which completely goes against the original intent of tableView.

Switching the above code’s data source to List mode for testing:

Swift
 List(0..<10000) { i in
        Cell(id: i)
          .listRowInsets(EdgeInsets())
          .swipeCell(cellPosition: .both, leftSlot: slot1, rightSlot: slot1)
    }

Familiar smoothness returned.

ForEach has to preprocess all data and prepare Views in advance. And after initialization, it does not automatically release these Views (even if they are not visible)! This can be analyzed using the above test code in Debug.

The reason for the lack of smoothness was found, but since the data source handled by List cannot set listRowInsets, especially under iOS 14, Apple strangely blocked many ways to set List properties through UITableView. So, to ensure both performance

and display needs, I had to wrap UITableView myself to meet both conditions.

Fortunately, I have been using the third-party library SwiftUIX, which saved me time from writing wrapping code. After further adjusting the code, the current issue was resolved.

Swift
 CocoaList(item) { i in
           Cell(id: i)
           .frame(height:100)
           .listRowInsets(EdgeInsets())
           .swipeCell(cellPosition: .both, leftSlot: slot1, rightSlot: slot1)
       }.edgesIgnoringSafeArea(.all)

Video showing the improved performance with SwiftUIX.

Through this issue, I learned when to use ForEach. I recorded it in this article, hoping others can avoid such detours.

Postscript:

I have reported this issue to Apple, hoping they will make adjustments (Apple has been quite responsive to developer feedback lately. After the release of Xcode 12, I submitted 5 feedbacks, 4 of which have been responded to, and 3 solved in the latest version).

Regret:

The current solution made me lose the opportunity to use ScrollViewReader.

Explore weekly Swift highlights with developers worldwide

Buy me a if you found this article helpful