如何使 SwiftUI 列表自动滚动?
How to make a SwiftUI List scroll automatically?
向我的 ListView 添加内容时,我希望它自动向下滚动。
我正在使用 SwiftUI List
和 BindableObject
作为控制器。新数据正在附加到列表中。
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
}
我希望列表向下滚动,因为我将新数据添加到消息列表。但是我必须手动向下滚动。
由于目前没有内置此类功能(List 和 ScrollView 都没有),Xcode 11.2,所以我需要使用 ScrollToEnd 行为编写自定义 ScrollView
!!!灵感来自 this 文章。
这是我的实验结果,希望对您有所帮助。当然还有更多参数,这些参数可能是可配置的,比如颜色等,但它看起来微不足道且超出范围。
import SwiftUI
struct ContentView: View {
@State private var objects = ["0", "1"]
var body: some View {
NavigationView {
VStack {
CustomScrollView(scrollToEnd: true) {
ForEach(self.objects, id: \.self) { object in
VStack {
Text("Row \(object)").padding().background(Color.yellow)
NavigationLink(destination: Text("Details for \(object)")) {
Text("Link")
}
Divider()
}.overlay(RoundedRectangle(cornerRadius: 8).stroke())
}
}
.navigationBarTitle("ScrollToEnd", displayMode: .inline)
// CustomScrollView(reversed: true) {
// ForEach(self.objects, id: \.self) { object in
// VStack {
// Text("Row \(object)").padding().background(Color.yellow)
// NavigationLink(destination: Text("Details for \(object)")) {
// Image(systemName: "chevron.right.circle")
// }
// Divider()
// }.overlay(RoundedRectangle(cornerRadius: 8).stroke())
// }
// }
// .navigationBarTitle("Reverse", displayMode: .inline)
HStack {
Button(action: {
self.objects.append("\(self.objects.count)")
}) {
Text("Add")
}
Button(action: {
if !self.objects.isEmpty {
self.objects.removeLast()
}
}) {
Text("Remove")
}
}
}
}
}
}
struct CustomScrollView<Content>: View where Content: View {
var axes: Axis.Set = .vertical
var reversed: Bool = false
var scrollToEnd: Bool = false
var content: () -> Content
@State private var contentHeight: CGFloat = .zero
@State private var contentOffset: CGFloat = .zero
@State private var scrollOffset: CGFloat = .zero
var body: some View {
GeometryReader { geometry in
if self.axes == .vertical {
self.vertical(geometry: geometry)
} else {
// implement same for horizontal orientation
}
}
.clipped()
}
private func vertical(geometry: GeometryProxy) -> some View {
VStack {
content()
}
.modifier(ViewHeightKey())
.onPreferenceChange(ViewHeightKey.self) {
self.updateHeight(with: [=10=], outerHeight: geometry.size.height)
}
.frame(height: geometry.size.height, alignment: (reversed ? .bottom : .top))
.offset(y: contentOffset + scrollOffset)
.animation(.easeInOut)
.background(Color.white)
.gesture(DragGesture()
.onChanged { self.onDragChanged([=10=]) }
.onEnded { self.onDragEnded([=10=], outerHeight: geometry.size.height) }
)
}
private func onDragChanged(_ value: DragGesture.Value) {
self.scrollOffset = value.location.y - value.startLocation.y
}
private func onDragEnded(_ value: DragGesture.Value, outerHeight: CGFloat) {
let scrollOffset = value.predictedEndLocation.y - value.startLocation.y
self.updateOffset(with: scrollOffset, outerHeight: outerHeight)
self.scrollOffset = 0
}
private func updateHeight(with height: CGFloat, outerHeight: CGFloat) {
let delta = self.contentHeight - height
self.contentHeight = height
if scrollToEnd {
self.contentOffset = self.reversed ? height - outerHeight - delta : outerHeight - height
}
if abs(self.contentOffset) > .zero {
self.updateOffset(with: delta, outerHeight: outerHeight)
}
}
private func updateOffset(with delta: CGFloat, outerHeight: CGFloat) {
let topLimit = self.contentHeight - outerHeight
if topLimit < .zero {
self.contentOffset = .zero
} else {
var proposedOffset = self.contentOffset + delta
if (self.reversed ? proposedOffset : -proposedOffset) < .zero {
proposedOffset = 0
} else if (self.reversed ? proposedOffset : -proposedOffset) > topLimit {
proposedOffset = (self.reversed ? topLimit : -topLimit)
}
self.contentOffset = proposedOffset
}
}
}
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)
})
}
}
这可以在 macOS 上通过将 NSScrollView 包装在 NSViewControllerRepresentable 对象中来完成(我假设使用 UIScrollView 和 UIViewControllerRepresentable 在 iOS 上也可以实现同样的效果。)我认为这可能是比此处的其他答案更可靠一些,因为 OS 仍将管理控件的大部分功能。
我刚刚开始工作,我计划尝试让更多的东西工作,例如在我的内容中获取某些行的位置,但这是我目前的代码:
import SwiftUI
struct ScrollableView<Content:View>: NSViewControllerRepresentable {
typealias NSViewControllerType = NSScrollViewController<Content>
var scrollPosition : Binding<CGPoint?>
var hasScrollbars : Bool
var content: () -> Content
init(hasScrollbars: Bool = true, scrollTo: Binding<CGPoint?>, @ViewBuilder content: @escaping () -> Content) {
self.scrollPosition = scrollTo
self.hasScrollbars = hasScrollbars
self.content = content
}
func makeNSViewController(context: NSViewControllerRepresentableContext<Self>) -> NSViewControllerType {
let scrollViewController = NSScrollViewController(rootView: self.content())
scrollViewController.scrollView.hasVerticalScroller = hasScrollbars
scrollViewController.scrollView.hasHorizontalScroller = hasScrollbars
return scrollViewController
}
func updateNSViewController(_ viewController: NSViewControllerType, context: NSViewControllerRepresentableContext<Self>) {
viewController.hostingController.rootView = self.content()
if let scrollPosition = self.scrollPosition.wrappedValue {
viewController.scrollView.contentView.scroll(scrollPosition)
DispatchQueue.main.async(execute: {self.scrollPosition.wrappedValue = nil})
}
viewController.hostingController.view.frame.size = viewController.hostingController.view.intrinsicContentSize
}
}
class NSScrollViewController<Content: View> : NSViewController, ObservableObject {
var scrollView = NSScrollView()
var scrollPosition : Binding<CGPoint>? = nil
var hostingController : NSHostingController<Content>! = nil
@Published var scrollTo : CGFloat? = nil
override func loadView() {
scrollView.documentView = hostingController.view
view = scrollView
}
init(rootView: Content) {
self.hostingController = NSHostingController<Content>(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
struct ScrollableViewTest: View {
@State var scrollTo : CGPoint? = nil
var body: some View {
ScrollableView(scrollTo: $scrollTo)
{
Text("Scroll to bottom").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 1000)
}
ForEach(1...50, id: \.self) { (i : Int) in
Text("Test \(i)")
}
Text("Scroll to top").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 0)
}
}
}
}
更新:在 iOS 14 中,现在有一种本机方法可以执行此操作。
我就是这样做的
ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}
对于iOS13岁及以下的你可以尝试:
我发现翻转视图对我来说似乎非常有效。这会在底部启动 ScrollView,并在向其添加新数据时自动向下滚动视图。
- 将最外面的视图旋转180度
.rotationEffect(.radians(.pi))
- 在垂直平面上翻转它
.scaleEffect(x: -1, y: 1, anchor: .center)
您也必须对您的内部视图执行此操作,因为现在它们都将被旋转和翻转。要翻转它们,请执行上面相同的操作。
如果您需要这么多地方,可能值得为此设置一个自定义视图。
您可以尝试以下操作:
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
这里有一个视图扩展来翻转它
extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
我提出了使用库 Introspect 获取 UITableView 引用的其他解决方案,直到 Apple 改进了可用方法。
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@State private var tableView: UITableView?
private var disposables = Set<AnyCancellable>()
var body: some View {
NavigationView {
VStack {
List(userData.landmarks, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
.introspectTableView { (tableView) in
if self.tableView == nil {
self.tableView = tableView
print(tableView)
}
}
}
.navigationBarTitle(Text("Landmarks"))
.onReceive(userData.$landmarks) { (id) in
// Do something with the table for example scroll to the bottom
self.tableView?.setContentOffset(CGPoint(x: 0, y: CGFloat.greatestFiniteMagnitude), animated: false)
}
}
}
}
iOS 13+
This package called ScrollViewProxy 添加了一个 ScrollViewReader,它提供了一个 ScrollViewProxy,您可以在其上调用 scrollTo(_:)
以获取您提供给视图的任何 ID。在引擎盖下它使用 Introspect 来获取 UIScrollView。
示例:
ScrollView {
ScrollViewReader { proxy in
Button("Jump to #8") {
proxy.scrollTo(8)
}
ForEach(0..<10) { i in
Text("Example \(i)")
.frame(width: 300, height: 300)
.scrollId(i)
}
}
}
您现在可以执行此操作,因为 Xcode 12,使用全新的 ScrollViewProxy
,示例代码如下:
您可以使用 chatController.messages
和调用 scrollViewProxy.scrollTo(chatController.messages.count-1)
更新下面的代码。
什么时候做?也许在 SwiftUI 的新 onChange
!
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { scrollViewProxy in
VStack {
Button("Scroll to top") {
scrollViewProxy.scrollTo(0)
}
Button("Scroll to buttom") {
scrollViewProxy.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
SwiftUI 2.0 - iOS14
这是一个:(将其包装在 ScrollViewReader
)
scrollView.scrollTo(rowID)
随着 SwiftUI 2.0 的发布,您可以在 ScrollViewReader
中嵌入任何可滚动元素,然后您可以访问您需要滚动的确切元素位置。
这是一个完整的演示应用程序:
// A simple list of messages
struct MessageListView: View {
var messages = (1...100).map { "Message number: \([=11=])" }
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages, id:\.self) { message in
Text(message)
Divider()
}
}
}
}
}
struct ContentView: View {
@State var search: String = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
MessageListView()
Divider()
HStack {
TextField("Number to search", text: $search)
Button("Go") {
withAnimation {
scrollView.scrollTo("Message number: \(search)")
}
}
}.padding(.horizontal, 16)
}
}
}
}
预览
这是我针对动态获取数据的观察对象的工作解决方案,例如聊天中通过对话填充的消息数组。
消息数组模型:
struct Message: Identifiable, Codable, Hashable {
//MARK: Attributes
var id: String
var message: String
init(id: String, message: String){
self.id = id
self.message = message
}
}
实际浏览:
@ObservedObject var messages = [Message]()
@State private var scrollTarget: Int?
var scrollView : some View {
ScrollView(.vertical) {
ScrollViewReader { scrollView in
ForEach(self.messages) { msg in
Text(msg).id(message.id)
}
//When you add new element it will scroll automatically to last element or its ID
.onChange(of: scrollTarget) { target in
withAnimation {
scrollView.scrollTo(target, anchor: .bottom)
}
}
.onReceive(self.$messages) { updatedMessages in
//When new element is added to observed object/array messages, change the scroll position to bottom, or last item in observed array
scrollView.scrollTo(umessages.id, anchor: .bottom)
//Update the scrollTarget to current position
self.scrollTarget = updatedChats.first!.messages.last!.message_timestamp
}
}
}
}
看看 GitHub 上这个完整的示例:
https://github.com/kenagt/AutoScrollViewExample
要简化......
.onChange(of: messages) { target in
withAnimation {
scrollView.scrollTo(target.last?.id, anchor: .bottom)
}
}
iOS 14/15:
我是通过使用 ScrollView 的 onChange
修饰符来实现的,如下所示:
// View
struct ChatView: View {
@ObservedObject var viewModel = ChatViewModel()
@State var newText = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
ScrollView(.vertical) {
VStack {
ForEach(viewModel.messages) { message in
VStack {
Text(message.text)
Divider()
}
}
}.id("ChatScrollView")
}.onChange(of: viewModel.messages) { _ in
withAnimation {
scrollView.scrollTo("ChatScrollView", anchor: .bottom)
}
}
Spacer()
VStack {
TextField("Enter message", text: $newText)
.padding()
.frame(width: 400, height: 40, alignment: .center)
Button("Send") {
viewModel.addMessage(with: newText)
}
.frame(width: 400, height: 80, alignment: .center)
}
}
}
}
}
// View Model
class ChatViewModel: ObservableObject {
@Published var messages: [Message] = [Message]()
func addMessage(with text: String) {
messages.append(Message(text: text))
}
}
// Message Model
struct Message: Hashable, Codable, Identifiable {
var id: String = UUID().uuidString
var text: String
}
正如许多人指出的那样。您可以使用 ScrollViewReader 滚动到消息的最后一个 ID。但是,我的 ScrollView 没有完全滚动到底部。另一个版本是将定义的 id 放在所有消息下方的不透明文本中。
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(chatVM.chatMessagesArray, id: \.self) { chat in
MessageBubble(messageModel: chat)
}
}
Text("Hello").font(.caption).opacity(0).id(idString) // <= here!!
}
.onChange(of: chatVM.chatMessagesArray) { _ in
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
.onAppear {
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
}
向我的 ListView 添加内容时,我希望它自动向下滚动。
我正在使用 SwiftUI List
和 BindableObject
作为控制器。新数据正在附加到列表中。
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
}
我希望列表向下滚动,因为我将新数据添加到消息列表。但是我必须手动向下滚动。
由于目前没有内置此类功能(List 和 ScrollView 都没有),Xcode 11.2,所以我需要使用 ScrollToEnd 行为编写自定义 ScrollView
!!!灵感来自 this 文章。
这是我的实验结果,希望对您有所帮助。当然还有更多参数,这些参数可能是可配置的,比如颜色等,但它看起来微不足道且超出范围。
import SwiftUI
struct ContentView: View {
@State private var objects = ["0", "1"]
var body: some View {
NavigationView {
VStack {
CustomScrollView(scrollToEnd: true) {
ForEach(self.objects, id: \.self) { object in
VStack {
Text("Row \(object)").padding().background(Color.yellow)
NavigationLink(destination: Text("Details for \(object)")) {
Text("Link")
}
Divider()
}.overlay(RoundedRectangle(cornerRadius: 8).stroke())
}
}
.navigationBarTitle("ScrollToEnd", displayMode: .inline)
// CustomScrollView(reversed: true) {
// ForEach(self.objects, id: \.self) { object in
// VStack {
// Text("Row \(object)").padding().background(Color.yellow)
// NavigationLink(destination: Text("Details for \(object)")) {
// Image(systemName: "chevron.right.circle")
// }
// Divider()
// }.overlay(RoundedRectangle(cornerRadius: 8).stroke())
// }
// }
// .navigationBarTitle("Reverse", displayMode: .inline)
HStack {
Button(action: {
self.objects.append("\(self.objects.count)")
}) {
Text("Add")
}
Button(action: {
if !self.objects.isEmpty {
self.objects.removeLast()
}
}) {
Text("Remove")
}
}
}
}
}
}
struct CustomScrollView<Content>: View where Content: View {
var axes: Axis.Set = .vertical
var reversed: Bool = false
var scrollToEnd: Bool = false
var content: () -> Content
@State private var contentHeight: CGFloat = .zero
@State private var contentOffset: CGFloat = .zero
@State private var scrollOffset: CGFloat = .zero
var body: some View {
GeometryReader { geometry in
if self.axes == .vertical {
self.vertical(geometry: geometry)
} else {
// implement same for horizontal orientation
}
}
.clipped()
}
private func vertical(geometry: GeometryProxy) -> some View {
VStack {
content()
}
.modifier(ViewHeightKey())
.onPreferenceChange(ViewHeightKey.self) {
self.updateHeight(with: [=10=], outerHeight: geometry.size.height)
}
.frame(height: geometry.size.height, alignment: (reversed ? .bottom : .top))
.offset(y: contentOffset + scrollOffset)
.animation(.easeInOut)
.background(Color.white)
.gesture(DragGesture()
.onChanged { self.onDragChanged([=10=]) }
.onEnded { self.onDragEnded([=10=], outerHeight: geometry.size.height) }
)
}
private func onDragChanged(_ value: DragGesture.Value) {
self.scrollOffset = value.location.y - value.startLocation.y
}
private func onDragEnded(_ value: DragGesture.Value, outerHeight: CGFloat) {
let scrollOffset = value.predictedEndLocation.y - value.startLocation.y
self.updateOffset(with: scrollOffset, outerHeight: outerHeight)
self.scrollOffset = 0
}
private func updateHeight(with height: CGFloat, outerHeight: CGFloat) {
let delta = self.contentHeight - height
self.contentHeight = height
if scrollToEnd {
self.contentOffset = self.reversed ? height - outerHeight - delta : outerHeight - height
}
if abs(self.contentOffset) > .zero {
self.updateOffset(with: delta, outerHeight: outerHeight)
}
}
private func updateOffset(with delta: CGFloat, outerHeight: CGFloat) {
let topLimit = self.contentHeight - outerHeight
if topLimit < .zero {
self.contentOffset = .zero
} else {
var proposedOffset = self.contentOffset + delta
if (self.reversed ? proposedOffset : -proposedOffset) < .zero {
proposedOffset = 0
} else if (self.reversed ? proposedOffset : -proposedOffset) > topLimit {
proposedOffset = (self.reversed ? topLimit : -topLimit)
}
self.contentOffset = proposedOffset
}
}
}
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)
})
}
}
这可以在 macOS 上通过将 NSScrollView 包装在 NSViewControllerRepresentable 对象中来完成(我假设使用 UIScrollView 和 UIViewControllerRepresentable 在 iOS 上也可以实现同样的效果。)我认为这可能是比此处的其他答案更可靠一些,因为 OS 仍将管理控件的大部分功能。
我刚刚开始工作,我计划尝试让更多的东西工作,例如在我的内容中获取某些行的位置,但这是我目前的代码:
import SwiftUI
struct ScrollableView<Content:View>: NSViewControllerRepresentable {
typealias NSViewControllerType = NSScrollViewController<Content>
var scrollPosition : Binding<CGPoint?>
var hasScrollbars : Bool
var content: () -> Content
init(hasScrollbars: Bool = true, scrollTo: Binding<CGPoint?>, @ViewBuilder content: @escaping () -> Content) {
self.scrollPosition = scrollTo
self.hasScrollbars = hasScrollbars
self.content = content
}
func makeNSViewController(context: NSViewControllerRepresentableContext<Self>) -> NSViewControllerType {
let scrollViewController = NSScrollViewController(rootView: self.content())
scrollViewController.scrollView.hasVerticalScroller = hasScrollbars
scrollViewController.scrollView.hasHorizontalScroller = hasScrollbars
return scrollViewController
}
func updateNSViewController(_ viewController: NSViewControllerType, context: NSViewControllerRepresentableContext<Self>) {
viewController.hostingController.rootView = self.content()
if let scrollPosition = self.scrollPosition.wrappedValue {
viewController.scrollView.contentView.scroll(scrollPosition)
DispatchQueue.main.async(execute: {self.scrollPosition.wrappedValue = nil})
}
viewController.hostingController.view.frame.size = viewController.hostingController.view.intrinsicContentSize
}
}
class NSScrollViewController<Content: View> : NSViewController, ObservableObject {
var scrollView = NSScrollView()
var scrollPosition : Binding<CGPoint>? = nil
var hostingController : NSHostingController<Content>! = nil
@Published var scrollTo : CGFloat? = nil
override func loadView() {
scrollView.documentView = hostingController.view
view = scrollView
}
init(rootView: Content) {
self.hostingController = NSHostingController<Content>(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
struct ScrollableViewTest: View {
@State var scrollTo : CGPoint? = nil
var body: some View {
ScrollableView(scrollTo: $scrollTo)
{
Text("Scroll to bottom").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 1000)
}
ForEach(1...50, id: \.self) { (i : Int) in
Text("Test \(i)")
}
Text("Scroll to top").onTapGesture {
self.$scrollTo.wrappedValue = CGPoint(x: 0,y: 0)
}
}
}
}
更新:在 iOS 14 中,现在有一种本机方法可以执行此操作。 我就是这样做的
ScrollViewReader { scrollView in
ScrollView(.vertical) {
LazyVStack {
ForEach(notes, id: \.self) { note in
MessageView(note: note)
}
}
.onAppear {
scrollView.scrollTo(notes[notes.endIndex - 1])
}
}
}
对于iOS13岁及以下的你可以尝试:
我发现翻转视图对我来说似乎非常有效。这会在底部启动 ScrollView,并在向其添加新数据时自动向下滚动视图。
- 将最外面的视图旋转180度
.rotationEffect(.radians(.pi))
- 在垂直平面上翻转它
.scaleEffect(x: -1, y: 1, anchor: .center)
您也必须对您的内部视图执行此操作,因为现在它们都将被旋转和翻转。要翻转它们,请执行上面相同的操作。
如果您需要这么多地方,可能值得为此设置一个自定义视图。
您可以尝试以下操作:
List(chatController.messages, id: \.self) { message in
MessageView(message.text, message.isMe)
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
这里有一个视图扩展来翻转它
extension View {
public func flip() -> some View {
return self
.rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
}
}
我提出了使用库 Introspect 获取 UITableView 引用的其他解决方案,直到 Apple 改进了可用方法。
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@State private var tableView: UITableView?
private var disposables = Set<AnyCancellable>()
var body: some View {
NavigationView {
VStack {
List(userData.landmarks, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
.introspectTableView { (tableView) in
if self.tableView == nil {
self.tableView = tableView
print(tableView)
}
}
}
.navigationBarTitle(Text("Landmarks"))
.onReceive(userData.$landmarks) { (id) in
// Do something with the table for example scroll to the bottom
self.tableView?.setContentOffset(CGPoint(x: 0, y: CGFloat.greatestFiniteMagnitude), animated: false)
}
}
}
}
iOS 13+
This package called ScrollViewProxy 添加了一个 ScrollViewReader,它提供了一个 ScrollViewProxy,您可以在其上调用 scrollTo(_:)
以获取您提供给视图的任何 ID。在引擎盖下它使用 Introspect 来获取 UIScrollView。
示例:
ScrollView {
ScrollViewReader { proxy in
Button("Jump to #8") {
proxy.scrollTo(8)
}
ForEach(0..<10) { i in
Text("Example \(i)")
.frame(width: 300, height: 300)
.scrollId(i)
}
}
}
您现在可以执行此操作,因为 Xcode 12,使用全新的 ScrollViewProxy
,示例代码如下:
您可以使用 chatController.messages
和调用 scrollViewProxy.scrollTo(chatController.messages.count-1)
更新下面的代码。
什么时候做?也许在 SwiftUI 的新 onChange
!
struct ContentView: View {
let itemCount: Int = 100
var body: some View {
ScrollViewReader { scrollViewProxy in
VStack {
Button("Scroll to top") {
scrollViewProxy.scrollTo(0)
}
Button("Scroll to buttom") {
scrollViewProxy.scrollTo(itemCount-1)
}
ScrollView {
LazyVStack {
ForEach(0 ..< itemCount) { i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
}
}
}
}
}
}
}
SwiftUI 2.0 - iOS14
这是一个:(将其包装在 ScrollViewReader
)
scrollView.scrollTo(rowID)
随着 SwiftUI 2.0 的发布,您可以在 ScrollViewReader
中嵌入任何可滚动元素,然后您可以访问您需要滚动的确切元素位置。
这是一个完整的演示应用程序:
// A simple list of messages
struct MessageListView: View {
var messages = (1...100).map { "Message number: \([=11=])" }
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages, id:\.self) { message in
Text(message)
Divider()
}
}
}
}
}
struct ContentView: View {
@State var search: String = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
MessageListView()
Divider()
HStack {
TextField("Number to search", text: $search)
Button("Go") {
withAnimation {
scrollView.scrollTo("Message number: \(search)")
}
}
}.padding(.horizontal, 16)
}
}
}
}
预览
这是我针对动态获取数据的观察对象的工作解决方案,例如聊天中通过对话填充的消息数组。
消息数组模型:
struct Message: Identifiable, Codable, Hashable {
//MARK: Attributes
var id: String
var message: String
init(id: String, message: String){
self.id = id
self.message = message
}
}
实际浏览:
@ObservedObject var messages = [Message]()
@State private var scrollTarget: Int?
var scrollView : some View {
ScrollView(.vertical) {
ScrollViewReader { scrollView in
ForEach(self.messages) { msg in
Text(msg).id(message.id)
}
//When you add new element it will scroll automatically to last element or its ID
.onChange(of: scrollTarget) { target in
withAnimation {
scrollView.scrollTo(target, anchor: .bottom)
}
}
.onReceive(self.$messages) { updatedMessages in
//When new element is added to observed object/array messages, change the scroll position to bottom, or last item in observed array
scrollView.scrollTo(umessages.id, anchor: .bottom)
//Update the scrollTarget to current position
self.scrollTarget = updatedChats.first!.messages.last!.message_timestamp
}
}
}
}
看看 GitHub 上这个完整的示例: https://github.com/kenagt/AutoScrollViewExample
要简化......
.onChange(of: messages) { target in
withAnimation {
scrollView.scrollTo(target.last?.id, anchor: .bottom)
}
}
iOS 14/15:
我是通过使用 ScrollView 的 onChange
修饰符来实现的,如下所示:
// View
struct ChatView: View {
@ObservedObject var viewModel = ChatViewModel()
@State var newText = ""
var body: some View {
ScrollViewReader { scrollView in
VStack {
ScrollView(.vertical) {
VStack {
ForEach(viewModel.messages) { message in
VStack {
Text(message.text)
Divider()
}
}
}.id("ChatScrollView")
}.onChange(of: viewModel.messages) { _ in
withAnimation {
scrollView.scrollTo("ChatScrollView", anchor: .bottom)
}
}
Spacer()
VStack {
TextField("Enter message", text: $newText)
.padding()
.frame(width: 400, height: 40, alignment: .center)
Button("Send") {
viewModel.addMessage(with: newText)
}
.frame(width: 400, height: 80, alignment: .center)
}
}
}
}
}
// View Model
class ChatViewModel: ObservableObject {
@Published var messages: [Message] = [Message]()
func addMessage(with text: String) {
messages.append(Message(text: text))
}
}
// Message Model
struct Message: Hashable, Codable, Identifiable {
var id: String = UUID().uuidString
var text: String
}
正如许多人指出的那样。您可以使用 ScrollViewReader 滚动到消息的最后一个 ID。但是,我的 ScrollView 没有完全滚动到底部。另一个版本是将定义的 id 放在所有消息下方的不透明文本中。
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(chatVM.chatMessagesArray, id: \.self) { chat in
MessageBubble(messageModel: chat)
}
}
Text("Hello").font(.caption).opacity(0).id(idString) // <= here!!
}
.onChange(of: chatVM.chatMessagesArray) { _ in
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
.onAppear {
withAnimation {
scrollView.scrollTo(idString, anchor: .bottom)
}
}
}