swiftuichatswiftui-scrollview

Preserve Scroll Position in ScrollView when Adding New Items


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 🙂


Solution

  • 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:

    Other notes:

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

    Animation