SwiftUI lazy-loading chat-like View [EN]

June 4, 2024

Sections

The Problem

One of my current projects is a Chat-App, built with SwiftUI, Realm and Vapor. That App supports scroll-to-origin for messages, that are replies. For that to work, every Childview needs a unique id assigned, with which the great optimization of SwiftUI's List doesn't work anymore.

If you have thousands of Items at some time that should be displayed, it can take several seconds to appear, since the ForEach has to iterate over thousands of items.

That is unacceptable. The View has to appear seamingly instantly.

The Solution?

The solution seems obvious: Create an Array with an initial amount of items, append it to the necessary direction (start for scrolling to the past, end for sending/recieving new messages).

However, while that fixed the insane loading times and TECHNICALLY worked, there was a bug, that made it useless:

A regular ScrollView, created just like that:

ScrollView {

}

always jumps to the first item, of its POV, when inserted at 0 and the top is visible.

Lets think about that:
You got a View of numbers. The highest number is at the bottom of the display, scrolling to the top the numbers decrease.

Example:

 ______
|  20  |
|  21  |
|  22  |
|  23  |
 ‾‾‾‾‾‾

20 is the first currently loaded item. If we scroll a bit further, we'd expect the 19 to be just above the 20, that means 23 disappears, 19 appears at the top, the rest gets moved 1 "row" down. (Thats just to simplify the explanation of the problem). So it should look about like that:

 ______
|  19  |
|  20  |
|  21  |
|  22  |
 ‾‾‾‾‾‾

However, lets assume we loaded 5 more items into our array, the view now looks like that:

 ______
|  15  |
|  16  |
|  17  |
|  18  |
 ‾‾‾‾‾‾

That behaviour is annoying and confusing for a user. Also as a user you don't care why anything is like it is, if it sucks.

More experienced developers might know the solution by now. However, when I ran into that problem (which is about 3/4 of a year ago) I was pretty new to Swift and SwiftUI and didn't understand why this was happening. I just knew the jump happened, but didn't know that it generaly happened when being at the ScrollView's-POV-top while adding new items.

So I ended up experimenting about an hour, since I was ambitious to fix it myself.

Finally, I ended up finding a solution. The mentioned jumping is actually what we want. Not while going back in history, but when we are at the bottom and new messages are sent.

Maybe now its already obvious for you, what the solution is.

The actual Solution

Its pretty logical. We want jumping at our POV's bottom, but not at the top, the ScrollView does it the other way around. So we just rotate the ScrollView by 180 degrees. This obviously leads to our content to be upside down, so we just rotate it back.

ScrollView {
	LazyVStack { //Items which are not visible on screen are not rendered, which saves system ressources
		ForEach(item, id: \.self) {
			View(item)
				.rotationEffect(.degrees(180))
		}
	}
}
.rotationEffect(.degrees(180))

Now we just need to detect when new items should be loaded. For that use we add any Item in the code below the ForEach, but still inside the LazyVStack.

LazyVStack { 
	ForEach(item, id: \.self) {
		//Content
	}
	ProgressView().progressViewStyle(.circular)
		.onAppear(perform: loadMore)
}

We rotated the ScrollView by 180 degrees, so our View ends up getting rendered like that on device:

 _____
|  ○︎  |
|  6  |
|  7  |
|  8  |
 ‾‾‾‾‾

Now, when you scroll to the top of the currently rendered items the function 'loadMore' gets triggered, which could be implemented like that:

func loadMore() {
	let startIndex = items.index(before: items.firstIndex(where: {
		$0.itemID == currentID // currentID is the id of the item currently at the top
	})!)
	let appendBy = items[max(0, startIndex - 50)...min(startIndex, items.count - 1)].reversed()
	rendered.append(contentsOf: appendBy)
	currentID = rendered.last?.itemID ?? items.count - 1
}

Keep in mind, that for every item that may be changed or added to your source list/array you have to update the rendered array too.

Example

I created a project for the purpose of testing. I used RealmSwift, just like in my actual app, but my solution works with every other datastore or array you might have (e.g. SwiftData)

Model

class Item: Object, ObjectKeyIdentifiable {
	@Persisted(primaryKey: true) var _id: ObjectId
	@Persisted var name: String
	@Persisted var itemID: Int
    
	override init() {
		super.init()
	}
    
	init(name: String, id: Int) {
		self.name = name
		self.itemID = id
	}
}

Functions

You can add those inside your View or in a separate file where you extend your View

extension ContentView {
	func loadMore() {
	    guard let index = items.firstIndex(where: {
	        $0.itemID == currentID
	    }) else { return }
	    let startIndex = items.index(before: index)
	    let appendBy = items[max(0, startIndex - 50)...min(startIndex, items.count - 1)].reversed()
	    rendered.append(contentsOf: appendBy)
	    currentID = rendered.last?.itemID ?? items.count - 1
	}
	
	//fixed values just for testing puposes 
	func add() {
	    let item = Item(name: "\((items.max(of: \.itemID) ?? 10000) + 1)", color: .blue, id: (items.max(of: \.itemID) ?? 10000) + 1)
	    $items.append(item)
	    withAnimation {
	        rendered.insert(item, at: 0)
	    }
	}
	
	//fixed values just for testing puposes
	func update() {
	    guard let realmIndex = items.index(matching: {
	        $0.itemID == 540
	    }) else { return }
	    guard let renIndex = rendered.firstIndex(where: {$0.itemID == 540}) else { return }
	    try! Realm().write {
	        let items = items.thaw()!
	        items[realmIndex].name = "Changed"
	    }
	    rendered[renIndex].name = "Changed"
	}
}

View

struct ContentView: View {
    @State var rendered: [Item] = []
    @ObservedResults(Item.self, sortDescriptor: SortDescriptor(keyPath: "itemID", ascending: true)) var items
    @State var currentID = 0
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(rendered) { item in
                    Text(item.name)
                        .id(item._id)
                        .rotationEffect(.degrees(180))
                }
                if rendered.count != items.count && rendered.count <= 100000 && !(items.isEmpty || rendered.isEmpty) {
                    ProgressView().progressViewStyle(.circular)
                        .onAppear() {
                            loadMore()
                        }
                }
                else if rendered.count != items.count {
                    Text("max loaded items reached")
                }
                else {
                    Rectangle()
                        .frame(height: 0)
                        .hidden()
                }
            }
        }
        .rotationEffect(.degrees(180))
        .onAppear() {
            rendered = items[max(0, items.count - 50)...(items.count - 1)].reversed()
            currentID = rendered.last?.itemID ?? items.count - 1
        }
        .overlay(alignment: .bottomTrailing) {
            VStack{
                Circle()
                    .frame(height: 50)
                    .onTapGesture {
                        add()
                    }
                Rectangle()
                    .frame(width: 50, height: 50)
                    .onTapGesture {
                        update()
                    }
            }
        }
    }
}

Additional Notes

That solution has to be treated with care. When scrolling longer the amount of RAM used can possibly get very big, so I recommend having an eye on that. Also I recommend to replace the rendered array with the initial array after loading additional items once the user is back at the bottom. That should free up used ressources again.

If you'd like to have a look at my experiments regarding scroll, have a look at my github. Looking at it, reading the code and running it yourself could increase your understanding of what SwiftUI does with ScrollViews.