2

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 🙂

0

4 Answers 4

4

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

Animation

Sign up to request clarification or add additional context in comments.

8 Comments

Thank you so much, Benzy, for the solution and for thoroughly explaining each step. I had been struggling with this for quite a while. Even though you mentioned you’re new to this area (as am I), I didn’t know those concepts. Thanks again, and have a wonderful day!🙂
You're welcome! Thanks for accepting the answer. Still anticipating some feedback about the use of tasks and might update/correct the answer if necessary.
Ya Sure i'll look out for that. Also as i am new to iOS dev can you give me some guidance on swift and swiftui as how can i upgrade/improve my skills🙂
@Franky11 It just takes practice! But SO is an excellent source of knowledge when you are looking for ways of getting things to work.
what is SO? never heard of that.
|
0

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

Comments

0

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

  • scrollTargetLayout()
  • scrollPosition($scrolledID)

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

Comments

0

This is such a tricky thing to get right. After much experimentation I have created a two-layer solution that works smoothly on iOS 18 and compiles with Swift 6. It also works on macOS when run in iPad mode. (See the gist link below).

You can add content to top and bottom without affecting the scroll position even while scrolling interactively.

The solution involves a wrapped UIScrollView that creates an illusion of infinite scrolling by manipulating its contentInset and content's position on the fly, at the same time. This is something that's currently impossible with SwiftUI's ScrollView, even using the new .contentMargins modifier since it appears to be buggy when used with negative margins.

The solution provides two "layers":

  • InfiniteView - a lower-level infinite scroller for arbitrary content. It calls your async closure when the user approaches one of the edges; the closure is expected to add content to top or bottom and trigger a render of the view. With this component, you should know the total height of content added to the top and pass it to the view via the headroom parameter.

  • InfiniteList - a higher-level lazy list manager based on the infinite scroller view. It expects you to provide a list of Identifiable items that also "know" their heights. The items are rendered in a lazy manner, i.e. those that are too far from the screen are not rendered.

Gist

Example usage that can be found in the preview of the `InfiniteList` component:

private let page = 20
private let cellSize = 100.0

#Preview {

    struct Item: InfiniteListItem {
        let id: Int
        var height: Double { cellSize }
        static func from(range: Range<Int>) -> [Self] { range.map { Self(id: $0) } }
    }

    struct Preview: View {

        @State private var range = 0..<page
        @State private var action: InfiniteViewAction? = .bottom(animated: false)

        var body: some View {
            InfiniteList(Item.from(range: range)) { item in
                Text("Row \(item.id)")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            } onLoadMore: { edge in
                switch edge {
                    case .top:
                        guard range.lowerBound >= -60 else { return true }
                        try? await Task.sleep(for: .seconds(1))
                        range = (range.lowerBound - page)..<(range.upperBound)
                        return false
                    default:
                        return true
                }
            }
            .scrollTo($action)
            .ignoresSafeArea()
        }
    }

    return Preview()
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.