SwiftUI lazy-loading chat-like View

June 4, 2024

Kapitel

Das Problem

Eines meiner aktuellen Projekte ist eine Chat-App, entwickelt mit SwiftUI, Realm und Vapor. Die App unterstützt scroll-to-origin für Nachrichten, die Antworten auf eine Nachricht sind. Damit das funktioniert, muss jeder Tochteransicht eine eindeutige ID zugewiesen werden, womit die großartige Optimierung von SwiftUI's List nicht mehr funktioniert.

Wenn tausende von Elementen angezeigt werden sollen, kann es mehrere Sekunden dauern, bis die Ansicht tatsächlich sichtbar wird, da die ForEach über tausende von Elementen iterieren muss.

Das ist inakzeptabel. Die Ansicht muss ohne spürbare Verzögerung erscheinen.

Die Antwort?

Die Lösung erscheint offensichtlich. Erstelle ein Array mit einer Ausgangsmenge von Elementen, ergänze es in der erforderlichen Richtung (Start für das Blättern in der Vergangenheit, Ende für das Senden/Empfangen neuer Nachrichten).

Damit werden zwar die irrsinnigen Ladezeiten behoben und es funktionierte technisch, aber es gibt einen Fehler, der es unbrauchbar macht:

Eine gewöhnliche ScrollView, erstellt wie folgt:

ScrollView {

}

springt immer zum ersten Element aus ihrer Sicht, wenn es bei 0 eingefügt wird und das obere Ende sichtbar ist.

Stellen wir uns das folgende vor:
wir haben eine Ansicht von Nummern. Die höchste nummer ist am unteren Ende des Displays, beim Scrollen nach oben werden die Nummern immer kleiner.

Beispiel:

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

20 ist das erste aktuell geladene Element. Wenn wir ein bisschen weiter scrollen, würden wir erwarten, dass die 19 direkt über der 20 steht, d.h. 23 verschwindet, 19 erscheint oben, der Rest wird um eine "Zeile" nach unten verschoben. (Das ist nur zur Vereinfachung der Erklärung des Problems). Es sollte also ungefähr so aussehen:

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

Angenommen, wir laden 5 weitere Elemente in unser Array, sieht die Ansicht nun aber so aus:

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

Dieses Verhalten ist ärgerlich und verwirrend für einen Benutzer. Außerdem ist es einem als Nutzer egal, warum etwas so ist, wie es ist, wenn es nervt.

Erfahrenere Entwickler wissen jetzt vielleicht die Lösung schon. Als ich auf dieses Problem stieß (das ist etwa ein 3/4 Jahr her), war ich allerdings ziemlich neu in Swift und SwiftUI und verstand nicht, warum dies geschah. Ich wusste nur, dass der Sprung passiert, aber nicht, dass es generell passiert, wenn man sich beim Hinzufügen neuer Elemente an der Spitze aus ScrollView-Sicht befindet.

Am Ende habe ich etwa eine Stunde experimentiert, da ich den Ehrgeiz hatte, es selbst zu schaffen.

Schließlich habe ich eine Lösung gefunden. Das erwähnte Springen ist genau das, was wir wollen. Nicht wenn wir in der Liste zurückgehen, sondern wenn wir ganz unten sind und neue Nachrichten gesendet werden.

Vermutlich ist jetzt auch für weniger erfahrene Leser offensichtlich, wie wir das lösen können.

Die funktionierende Antwort

Eigentlich ist es ziemlich logisch. Wir wollen springen am unteren Ende aus unserer Sicht, aber nicht oben, also das verhalten der ScrollView genau umgekehrt. Also drehen wir die ScrollView einfach um 180 Grad. Das führt dazu, dass unser Inhalt auch auf dem Kopf steht, also drehen wir ihn einfach zurück.

ScrollView {
	LazyVStack { //Für Items die auf dem Bildschirm nicht sichtbar sind wird die Darstellung nicht berechnet, was Systemressourcen schont
		ForEach(item, id: \.self) {
			View(item)
				.rotationEffect(.degrees(180))
		}
	}
}
.rotationEffect(.degrees(180))

Jetzt müssen wir nur noch erkennen, wann neue Elemente geladen werden sollen. Zu diesem Zweck fügen wir ein beliebiges Item in den Code unterhalb von ForEach ein, aber immer noch innerhalb des LazyVStack.

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

Wir haben die ScrollView um 180 Grad gedreht, so dass unsere Ansicht wie folgt gerendert wird:

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

Wenn nun an den Anfang der aktuell gerenderten Elemente gescrollt wird, wird die Funktion "loadMore" ausgelöst, die so implementiert werden könnte:

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
}

Denk daran, dass für jedes Element, das geändert oder zur Quellliste/dem Quellarray hinzugefügt wird, auch das gerenderte Array aktualisiert werden muss.

Beispiel

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
	}
}

Funktionen

Man kann diese in der View oder einer separaten Datei in einer extension der View einfügen.

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
	}
	
	//feste Werte zu Testzwecken
	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)
	    }
	}
	
	//feste Werte zu Testzwecken
	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()
                    }
            }
        }
    }
}

Anmerkungen

Diese Lösung muss mit Vorsicht genossen werden. Wenn man länger scrollt, kann die Menge an belegtem RAM sehr groß werden, daher empfehle ich, das im Auge zu behalten. Außerdem empfehle ich, das gerenderte Array nach dem Laden zusätzlicher Elemente zu durch das initiale Array zu ersetzen, sobald der Benutzer wieder am unteren Ende angekommen ist. Das sollte verbrauchte Ressourcen wieder freigeben.

Wenn du einen Blick auf meine Experimente zum Thema Scroll werfen möchtest, schauen dir gerne mein github an. Wenn du sie dir ansiehst, den Code liest und sie selbst ausführst, verstehst du vielleicht besser, was SwiftUI mit ScrollViews macht.