Question: Custom Reverse Scroll view in SwiftUI

I am building a chat window. We are currently in the migration phase from Objective-C to SwiftUI and we do support a minimum of iOS 13+.
To get behaviors of scroll view where I want to point to the bottom always as default and should be able to scroll up and down seamlessly.
Here only problem is here scroll only works when i drag from bubble of chat from other places it doesn't works.
I have debug quite long and not able to find the issue.
Reverse scroll view code which I got from here https://www.process-one.net/blog/writing-a-custom-scroll-view-with-swiftui-in-a-chat-application/
struct ReverseScrollView<Content>: View where Content: View {
    @State private var contentHeight: CGFloat = CGFloat.zero
    @State private var scrollOffset: CGFloat = CGFloat.zero
    @State private var currentOffset: CGFloat = CGFloat.zero
    
    var content: () -> Content
    
    // Calculate content offset
    func offset(outerheight: CGFloat, innerheight: CGFloat) -> CGFloat {        
        let totalOffset = currentOffset + scrollOffset
        return -((innerheight/2 - outerheight/2) - totalOffset)
    }
    
    var body: some View {
        GeometryReader { outerGeometry in
            // Render the content
            //  ... and set its sizing inside the parent
            self.content()
            .modifier(ViewHeightKey())
            .onPreferenceChange(ViewHeightKey.self) { self.contentHeight = $0 }
            .frame(height: outerGeometry.size.height)
            .offset(y: self.offset(outerheight: outerGeometry.size.height, innerheight: self.contentHeight))
            .clipped()
            .animation(.easeInOut)
            .gesture(
                 DragGesture()
                    .onChanged({ self.onDragChanged($0) })
                    .onEnded({ self.onDragEnded($0, outerHeight: outerGeometry.size.height)}))
        }
    }
    
    func onDragChanged(_ value: DragGesture.Value) {
        // Update rendered offset

        self.scrollOffset = (value.location.y - value.startLocation.y)
    }
    
    func onDragEnded(_ value: DragGesture.Value, outerHeight: CGFloat) {
        // Update view to target position based on drag position
        let scrollOffset = value.location.y - value.startLocation.y
        
        let topLimit = self.contentHeight - outerHeight
        
        // Negative topLimit => Content is smaller than screen size. We reset the scroll position on drag end:
        if topLimit < 0 {
             self.currentOffset = 0
        } else {
            // We cannot pass bottom limit (negative scroll)
            if self.currentOffset + scrollOffset < 0 {
                self.currentOffset = 0
            } else if self.currentOffset + scrollOffset > topLimit {
                self.currentOffset = topLimit
            } else {
                self.currentOffset += scrollOffset
            }
        }
        self.scrollOffset = 0
    }
}

struct ViewHeightKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}

extension ViewHeightKey: ViewModifier {
    func body(content: Content) -> some View {
        return content.background(GeometryReader { proxy in
            Color.clear.preference(key: Self.self, value: proxy.size.height)
        })
    }
}

Chat window
ReverseScrollView {

    VStack{
        
        HStack {
            VStack(spacing: 5){
                Text("message.text")
                    .padding(.vertical, 8)
                    .padding(.horizontal)
                    .background(Color(.systemGray5))
                    .foregroundColor(.primary)
                    .clipShape(ChatBubble(isFromCurrentUser: false))
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal)
                    .lineLimit(nil) // Allow unlimited lines
                    .lineSpacing(4) // Adjust line spacing as desired
                    .fixedSize(horizontal: false, vertical: true) // Allow vertical expansion

                
                Text("ormatTime(message.timeUtc)")
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .background(Color.red)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal, 5)


            }
            .background(Color.blue)

            
            Spacer()
        }

        
        ForEach(Array(viewModel.chats.indices), id: \.self){ index in
            let message = viewModel.chats[index]
            VStack(alignment: .leading, spacing: 5) {
                // Chat bubble view for received messages
                
                if(message.isIncoming){
                    HStack {
                        VStack(spacing: 5){
                            Text(message.text)
                                .padding(.vertical, 8)
                                .padding(.horizontal)
                                .background(Color(.systemGray5))
                                .foregroundColor(.primary)
                                .clipShape(ChatBubble(isFromCurrentUser: false))
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding(.horizontal)
                                .lineLimit(nil) // Allow unlimited lines
                                .lineSpacing(4) // Adjust line spacing as desired
                                .fixedSize(horizontal: false, vertical: true) // Allow vertical expansion
                                .frame(maxWidth: .infinity, alignment: .leading)

                            
                            Text(formatTime(message.timeUtc))
                                .font(.caption)
                                .foregroundColor(.secondary)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding(.horizontal, 5)
                        }

                        
                        Spacer()
                    }
                }else{
                    
                
                    HStack {
                        Spacer()
                        
                        VStack(spacing: 5){
                            Text(message.text)
                                .padding(.vertical, 8)
                                    .padding(.horizontal)
                                    .background(Color(.systemBlue))
                                    .foregroundColor(.white)
                                    .clipShape(ChatBubble(isFromCurrentUser: true))
                                    .padding(.horizontal)
                                    .lineLimit(nil) // Allow unlimited lines
                                    .lineSpacing(4) // Adjust line spacing as desired
                                    .fixedSize(horizontal: false, vertical: true) // Allow vertical expansion
                            
                            Text(formatTime(message.timeUtc))
                                .font(.caption)
                                .foregroundColor(.secondary)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding(.horizontal, 5)
                        }
                        .frame(maxWidth: .infinity, alignment: .trailing)

                        
                    }
                
                }
            }
          
        
        }
        if(viewModel.messageSending) {
            VStack(spacing: 5){
                HStack {
                    Spacer()
                    Text(sendingText)
                        .padding(.vertical, 8)
                        .padding(.horizontal)
                        .background(Color(.systemBlue))
                        .foregroundColor(.white)
                        .clipShape(ChatBubble(isFromCurrentUser: true))
                        .padding(.horizontal)
                }
                HStack {
                    Spacer()
                    ChatBubbleAnimationView()
                        .padding(.trailing, 8)
                }
            }
            .padding(.bottom, 20)
            .onDisappear(){
                sendingText = ""
                messageText = ""
            }
        }
    }
}

Chat bubble wrapper
struct ChatBubble: Shape {
    var isFromCurrentUser: Bool
    
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: isFromCurrentUser ? [.topLeft, .bottomLeft, .bottomRight] : [.topRight, .bottomLeft, .bottomRight], cornerRadii: CGSize(width: 12, height: 12))
        
        return Path(path.cgPath)
    }
}

Please let me know something other information need. I am looking for suggestions to get the behaviours keeping in mind it should support iOS 13+ or any help to get above code fixed.


Answer:
One option is to just flip the built-in ScrollView upside down.
import SwiftUI

struct ReverseScroll: View {
    var body: some View {
        ScrollView{
            ForEach(ChatMessage.samples) { message in
                HStack {
                    if message.isCurrent {
                        Spacer()
                    }
                    Text(message.message)
                        .padding()
                        .background {
                            RoundedRectangle(cornerRadius: 10)
                                .fill(message.isCurrent ? Color.blue : Color.gray)
                        }
                    if !message.isCurrent {
                        Spacer()
                    }
                }
            }.rotationEffect(.degrees(180)) //Flip View upside down oldest above newest below.
        }.rotationEffect(.degrees(180)) //Reverse so it works like a chat message
    }
}

struct ReverseScroll_Previews: PreviewProvider {
    static var previews: some View {
        ReverseScroll()
    }
}

struct ChatMessage: Identifiable, Equatable{
    let id: UUID = .init()
    var message: String
    var isCurrent: Bool
    
    static let samples: [ChatMessage] = (0...25).map { n in
            .init(message: n.description + UUID().uuidString, isCurrent: Bool.random())
    }
}

The scroll indicators show on the left with this but can be hidden in iOS 16+ with
.scrollIndicators(.hidden)

If you decide to support iOS 14+ you can use ScrollViewReader to scroll to the newest message.
struct ReverseScroll: View {
    @State private var messages = ChatMessage.samples
    var body: some View {
        VStack{
            ScrollViewReader { proxy in
                ScrollView{
                    ForEach(messages) { message in
                        HStack {
                            if message.isCurrent {
                                Spacer()
                            }
                            Text(message.message)
                                .padding()
                                .background {
                                    RoundedRectangle(cornerRadius: 10)
                                        .fill(message.isCurrent ? Color.blue : Color.gray)
                                }
                            if !message.isCurrent {
                                Spacer()
                            }
                        }
                        .id(message.id) //Set the ID
                        
                    }.rotationEffect(.degrees(180))
                }.rotationEffect(.degrees(180))
                    .onChange(of: messages.count) { newValue in
                        proxy.scrollTo(messages.last?.id) //When the count changes scroll to latest message
                    }
            }
            Button("add") {
                messages.append( ChatMessage(message: Date().description, isCurrent: Bool.random()))
                
            }
        }
    }
}