1

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.

2
  • Anything that uses SwiftUI can only be used in iOS 13+ Commented Jun 1, 2023 at 14:53
  • @loremipsum you are right minimum for my project is 13 and not 11, updated the question. Commented Jun 1, 2023 at 14:59

1 Answer 1

6

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()))
                
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Here there is only one issue with reverse scroll view one is when i send a message and I have scrolled up even after the message send completed it's not coming down automatically. Is it possible to do
@RonWeasley the last set of code has that feature with ScrollViewReader it is only available for 14+
@RonWeasley if I answered your question can you accept the answer by clicking the green check mark and maybe upvoting?

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.