Reputation: 87
I am currently struggling on my ChatView of my app. This is a sample code of what i want to achieve. It has functionality such as when the user is about to reach the top it fetches older messages and when bottom it fetches newer messages.
The Issue with this is when the user fetches newer messages the scrollview scrolls continuously to bottom and this happens recursively. This was the same issue when fetching older messages (it continuolsy scrolled to top) but i fixed it with flipped() modifier.But the bottom issue is still there.
struct ChatMessage: View {
let text: String
var body: some View {
HStack {
Text(text)
.foregroundStyle(.white)
.padding()
.background(.blue)
.clipShape(
RoundedRectangle(cornerRadius: 16)
)
.overlay(alignment: .bottomLeading) {
Image(systemName: "arrowtriangle.down.fill")
.font(.title)
.rotationEffect(.degrees(45))
.offset(x: -10, y: 10)
.foregroundStyle(.blue)
}
Spacer()
}
.padding(.horizontal)
}
}
struct Message : Identifiable,Equatable {
var id: Int
var text: String
}
struct GoodChatView: View {
@State var messages: [Message] = []
@State private var scrollViewProxy: ScrollViewProxy? // Store the proxy
@State var messageId: Int?
var body: some View {
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(messages, id: \.id) { message in
ChatMessage(text: "\(message.text)")
.flippedUpsideDown()
.onAppear {
if message == messages.last {
print("old data")
loadMoreData()
}
if message == messages.first {
print("new data")
loadNewData()
}
}
}
}
.scrollTargetLayout()
}
.flippedUpsideDown()
.scrollPosition(id: $messageId)
.onAppear {
for i in 1...20 {
let message = Message(id: i, text: "\(i)")
messages.append(message)
}
messageId = messages.first?.id
}
}
}
func loadMoreData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let count = messages.count
var newMessages: [Message] = []
for i in count+1...count+20 {
let message = Message(id: i, text: "\(i)")
newMessages.append(message)
}
messages.append(contentsOf: newMessages)
}
}
func loadNewData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let count = messages.count
var newMessages: [Message] = []
for i in count+1...count+20 {
let message = Message(id: i, text: "\(i)")
newMessages.append(message)
}
newMessages = newMessages.reversed()
messages.insert(contentsOf: newMessages, at: 0)
}
}
}
struct FlippedUpsideDown: ViewModifier {
func body(content: Content) -> some View {
content
.rotationEffect(.radians(Double.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
extension View {
func flippedUpsideDown() -> some View {
modifier(FlippedUpsideDown())
}
}
Any Help is appreciated. If there are another ways to achieve this please let me know 🙂
Upvotes: 1
Views: 604
Reputation: 151
Tried the accepted answer, faced issue with flickering when fetching the history data that I simply couldn't resolve (might be a skill issue). However, after 3 days of digging, I found a native implementation of this done by apple using
All you need to do is store an identifier of your view as a either @State var in your view or @Published in your view model before fetching history.
Identifier is stored right before making the API request to fetch history.
You can find all the details about it here: https://developer.apple.com/documentation/swiftui/view/scrollposition(_:anchor:)
Also, a code sample of how to implement it:
var messagesListView: some View {
ScrollViewReader { scrollView in
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 10) {
ForEach(viewModel.receivedMessages, id: \.externalId) { message in
ChatMessageView(
direction: message.direction,
message: message.message,
hoursMinutes: message.formatTime(),
messageType: message.type,
readStatus: Binding(
get: {
guard let _message = viewModel.receivedMessages.first(where: { $0.externalId == message.externalId }) else {
return .delivered
}
return _message.readStatus
},
set: { _ in }
)
)
.padding(.horizontal)
.background {
GeometryReader { proxy in
let minY = proxy.frame(in: .scrollView).minY
let isReadyForLoad = abs(minY) <= 0.01 && message == viewModel.receivedMessages.first
Color.clear
.onChange(of: isReadyForLoad) { _, newVal in
if newVal && !viewModel.isLoadingHistory {
Task { @MainActor in
await viewModel.fetchMessageHistory()
}
}
}
}
}
}
Color.clear
.frame(height: 1)
.id("bottomAnchor")
.padding(.top, viewModel.isSupportTyping ? 20 : 0)
}
.scrollTargetLayout()
}
.scrollPosition(id: $viewModel.lastMessageId)
.onChange(of: viewModel.receivedMessages) { oldMessages, newMessages in
if !hasScrolledToBottom {
DispatchQueue.main.async {
withAnimation {
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
}
hasScrolledToBottom = true
}
}
.onChange(of: keyboardHeight) { _, newValue in
if newValue > 100 {
scrollToBottom(proxy: scrollView)
}
}
}
.contentShape(Rectangle())
.onTapGesture(perform: dismissKeyboard)
}
Upvotes: 0
Reputation: 1
Both of these solutions seem to fail if we use a random string instead of integer for the value displayed in the Text object. Try with this please...
struct ContentView: View {
var body: some View {
GoodChatView()
}
}
struct Message: Identifiable, Equatable {
let id: Int
let text: String
static func == (lhs: Message, rhs: Message) -> Bool {
return lhs.id == rhs.id
}
}
struct ChatMessage: View {
var text: String
var body: some View {
Text(text)
.frame(width: 200)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
}
}
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 25...50 {
let message = Message(id: i, text: randomString(length: 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 25...50 {
let message = Message(id: i, text: randomString(length: i))
newMessages.append(message)
}
messages.insert(contentsOf: newMessages, at: 0)
}
@MainActor
private func resetLoadingState() async {
try? await Task.sleep(for: .milliseconds(500))
isLoading = false
}
func randomString(length: Int) -> String {
let letters : NSString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
let len = UInt32(letters.length)
var randomString = ""
for _ in 0 ..< length {
let rand = arc4random_uniform(len)
var nextChar = letters.character(at: Int(rand))
randomString += NSString(characters: &nextChar, length: 1) as String
}
return randomString
}
}
Upvotes: 0
Reputation: 262
I used this approach in my SwiftUI project, and it works perfectly to preserve the scroll position when adding new items to the top of the list. Here's the
import SwiftUI
import Combine
struct ContentView: View {
@State private var items: [Int] = Array(0..<100)
@State private var offsetToMaintain: Int? = 0
@State private var tmpOffset: ScrollOffset = .init(isChange: false, index: 0)
@State private var batch: Int = 1
var body: some View {
VStack {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items, id: \.self) { index in
Text("\(index)")
.frame(width: 50, height: 50)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
.padding(4)
.id(index)
}
}
.scrollTargetLayout()
.onChange(of: tmpOffset.isChange) { _, _ in
DispatchQueue.main.async {
proxy.scrollTo(tmpOffset.index, anchor: .top)
}
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
let tmp = offsetToMaintain
let start = -batch * 100
let end = start + 99
let newItems = Array(start...end)
items.insert(contentsOf: newItems, at: 0)
batch += 1
tmpOffset.index = tmp ?? 0
tmpOffset.isChange.toggle()
}
}
.onScrollTargetVisibilityChange(idType: Int.self) { ids in
offsetToMaintain = ids.first
}
}
}
.clipped()
}
}
struct ScrollOffset {
var isChange: Bool
var index: Int
}
How It Works:
Preserve the Scroll Position
The offsetToMaintain stores the first visible item's ID before adding new items. After the new items are added, the scroll view is scrolled back to the preserved position using ScrollViewReader.
Batch Processing
The Timer adds new items at regular intervals (every 5 seconds), with items added to the top of the list in batches of 100. Smooth Scrolling:
The ScrollTargetVisibilityChange modifier detects visible items and updates the offsetToMaintain. The scroll adjustment happens using the .scrollTo method of ScrollViewReader. This approach ensures the scroll position is preserved even when new items are added to the top, providing a seamless user experience.
Upvotes: 0
Reputation: 21710
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
}
}
Upvotes: 2