英文:
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<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.
答案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("add") {
messages.append( ChatMessage(message: Date().description, isCurrent: Bool.random()))
}
}
}
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论