在SwiftUI中创建自定义反向滚动视图

huangapple go评论57阅读模式
英文:

Custom Reverse Scroll view in SwiftUI

问题

我正在构建一个聊天窗口。我们目前正在从Objective-C迁移到SwiftUI的过渡阶段,我们支持iOS 13+。

要实现滚动视图的行为,我希望默认情况下始终指向底部,并且能够无缝地上下滚动。

唯一的问题是只有当我从聊天气泡拖动时,滚动才有效,从其他地方拖动则不起作用。

我已经进行了相当长时间的调试,但无法找到问题。

反向滚动视图的代码我从这里 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)
        })
    }
}

聊天窗口

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

英文:

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&lt;Content&gt;: 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: () -&gt; Content
// Calculate content offset
func offset(outerheight: CGFloat, innerheight: CGFloat) -&gt; 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 =&gt; Content is smaller than screen size. We reset the scroll position on drag end:
if topLimit &lt; 0 {
self.currentOffset = 0
} else {
// We cannot pass bottom limit (negative scroll)
if self.currentOffset + scrollOffset &lt; 0 {
self.currentOffset = 0
} else if self.currentOffset + scrollOffset &gt; 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: () -&gt; Value) {
value = value + nextValue()
}
}
extension ViewHeightKey: ViewModifier {
func body(content: Content) -&gt; 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(&quot;message.text&quot;)
.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(&quot;ormatTime(message.timeUtc)&quot;)
.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 = &quot;&quot;
messageText = &quot;&quot;
}
}
}
}

Chat bubble wrapper

struct ChatBubble: Shape {
var isFromCurrentUser: Bool
func path(in rect: CGRect) -&gt; 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.

答案1

得分: 2

以下是代码的中文翻译部分:

// 一种选项是将内置的 `ScrollView` 上下颠倒。
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)) // 颠倒视图,最旧的在上面,最新的在下面。
        }.rotationEffect(.degrees(180)) // 反转,使其像聊天消息一样工作
    }
}

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

此外,以下是代码中提到的隐藏滚动指示器的部分:

// 这会使滚动指示器显示在左边,但在iOS 16+中可以隐藏
.scrollIndicators(.hidden)

最后,以下是代码中提到的在iOS 14+中使用 ScrollViewReader 滚动到最新消息的部分:

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) // 设置ID
                        
                    }.rotationEffect(.degrees(180))
                }.rotationEffect(.degrees(180))
                    .onChange(of: messages.count) { newValue in
                        proxy.scrollTo(messages.last?.id) // 当计数更改时滚动到最新消息
                    }
            }
            Button("添加") {
                messages.append( ChatMessage(message: Date().description, isCurrent: Bool.random()))
                
            }
        }
    }
}
英文:

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(&quot;add&quot;) {
messages.append( ChatMessage(message: Date().description, isCurrent: Bool.random()))
}
}
}
}

huangapple
  • 本文由 发表于 2023年6月1日 22:45:48
  • 转载请务必保留本文链接:https://go.coder-hub.com/76383130.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定