Your example code is setting the scroll position by using .scrollPosition on the ScrollView, together with .scrollTargetLayout on the LazyVStack. The trouble is, .scrollPosition can only have one anchor. If the anchor is .top then it is difficult to set the position to the last entry at the bottom and the same goes for the first entry at the top when the anchor is .bottom. So:
A ScrollViewReader is perhaps a better way to set the scroll position. This allows different anchors to be supplied to ScrollViewProxy.scrollTo. You had a ScrollViewReader in your example already, but it wasn't being used.
The use of .scrollTargetLayout and .scrollPosition can be removed.
Other notes:
I think you can remove all the flipping. I don't think it is necessary and it just complicates everything. Use a reversed array in the ForEach instead.
To prevent a load of data from triggering another load immediately, I would suggest using a simple flag isLoading. This should be set before loading. When a load completes, it can be reset after a short delay. This ensures that loading remains blocked while scroll adjustments are happening.
When "new" messages are added, they appear at the bottom of the ScrollView. It is not necessary to perform scrollTo after adding the new entries because the ScrollView retains its position anyway.
Adding "old" messages to the top is the harder part. If the ScrollView is being actively scrolled (that is, if the user has their finger in action) then scrollTo is ignored and the highest entry is shown. As a workaround, a GeometryReader can be placed behind each entry to detect the relative scroll offset. A load should only be triggered when the offset is (almost) exactly 0. This might still happen when the user is scrolling manually, but it is much less likely. Usually, a load is only triggered when the ScrollView comes to rest.
I tried to update the code to use Task and async functions instead of DispatchQueue. However, I have to admit, I'm a beginner in this area, as anyone with more experience will probably spot at a glance. Happy to make corrections if any suggestions are made. In particular, I am not sure if it is necessary for @MainActor to be specified so much.
On intial load, the scroll position needs to be set to the bottom after the first load of data has been added. I tried using Task.yield() to let the updates happen, but this wasn't always reliable. So it is now sleeping for 10ms instead. I expect there may be a better way of doing this too.
Here is the updated example:
struct GoodChatView: View {
@State private var messages: [Message] = []
@State private var isLoading = true
var body: some View {
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(messages.reversed(), id: \.id) { message in
ChatMessage(text: "\(message.text)")
.background {
GeometryReader { proxy in
let minY = proxy.frame(in: .scrollView).minY
let isReadyForLoad = abs(minY) <= 0.01 && message == messages.last
Color.clear
.onChange(of: isReadyForLoad) { oldVal, newVal in
if newVal && !isLoading {
isLoading = true
Task { @MainActor in
await loadMoreData()
await Task.yield()
scrollView.scrollTo(message.id, anchor: .top)
await resetLoadingState()
}
}
}
}
}
.onAppear {
if !isLoading && message == messages.first {
isLoading = true
Task {
await loadNewData()
// When new data is appended, the scroll position is
// retained - no need to set it again
await resetLoadingState()
}
}
}
}
}
}
.task { @MainActor in
await loadNewData()
if let firstMessageId = messages.first?.id {
try? await Task.sleep(for: .milliseconds(10))
scrollView.scrollTo(firstMessageId, anchor: .bottom)
}
await resetLoadingState()
}
}
}
@MainActor
func loadMoreData() async {
let lastId = messages.last?.id ?? 0
print("old data > \(lastId)")
var oldMessages: [Message] = []
for i in lastId+1...lastId+20 {
let message = Message(id: i, text: "\(i)")
oldMessages.append(message)
}
messages += oldMessages
}
@MainActor
func loadNewData() async {
let firstId = messages.first?.id ?? 21
print("new data < \(firstId)")
var newMessages: [Message] = []
for i in firstId-20...firstId-1 {
let message = Message(id: i, text: "\(i)")
newMessages.append(message)
}
messages.insert(contentsOf: newMessages, at: 0)
}
@MainActor
private func resetLoadingState() async {
try? await Task.sleep(for: .milliseconds(500))
isLoading = false
}
}
