Search code examples
swiftswiftuiscrollview

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.


Solution

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