如何在 SwiftUI 中以编程方式滚动列表?
Posted
技术标签:
【中文标题】如何在 SwiftUI 中以编程方式滚动列表?【英文标题】:How to scroll List programmatically in SwiftUI? 【发布时间】:2020-03-25 19:14:34 【问题描述】:看起来在当前的工具/系统中,刚刚发布了 Xcode 11.4 / ios 13.4,List
中将没有对“滚动到”功能的 SwiftUI 原生支持。因此,即使他们,Apple,将在下一个 major 发布时提供它,我也需要对 iOS 13.x 的向后支持。
那么我将如何以最简单和轻松的方式做到这一点?
滚动列表结束 将列表滚动到顶部 及其他(我不喜欢将完整的 UITableView
基础架构包装到 UIViewRepresentable/UIViewControllerRepresentable
中,就像之前在 SO 中提出的那样)。
【问题讨论】:
表单或列表不会滚动,您可以使用.frame(height: x)
使高度大于可滚动区域,其中 x 是大于内容高度的值。
【参考方案1】:
SWIFTUI 2.0
这是 Xcode 12 / iOS 14 (SwiftUI 2.0) 中可能的替代解决方案,当滚动控件位于滚动区域之外时,可以在相同的场景中使用(因为 SwiftUI2 ScrollViewReader
只能在内部使用 em>ScrollView
)
注意:行内容设计不在考虑范围内
使用 Xcode 12b / iOS 14 测试
class ScrollToModel: ObservableObject
enum Action
case end
case top
@Published var direction: Action? = nil
struct ContentView: View
@StateObject var vm = ScrollToModel()
let items = (0..<200).map $0
var body: some View
VStack
HStack
Button(action: vm.direction = .top ) // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
Button(action: vm.direction = .end ) // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
Divider()
ScrollViewReader sp in
ScrollView
LazyVStack
ForEach(items, id: \.self) item in
VStack(alignment: .leading)
Text("Item \(item)").id(item)
Divider()
.frame(maxWidth: .infinity).padding(.horizontal)
.onReceive(vm.$direction) action in
guard !items.isEmpty else return
withAnimation
switch action
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
SWIFTUI 1.0+
这是一种可行的、看起来合适的、需要几个屏幕代码的方法的简化变体。
使用 Xcode 11.2+ / iOS 13.2+ 测试(也使用 Xcode 12b / iOS 14)
使用演示:
struct ContentView: View
private let scrollingProxy = ListScrollingProxy() // proxy helper
var body: some View
VStack
HStack
Button(action: self.scrollingProxy.scrollTo(.top) ) // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
Button(action: self.scrollingProxy.scrollTo(.end) ) // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
Divider()
List
ForEach(0 ..< 200) i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
解决方案:
注入List
的可表示的Light view 可以访问UIKit 的视图层次结构。由于List
重复使用行,因此没有更多的值可以将行放入屏幕。
struct ListScrollingHelper: UIViewRepresentable
let proxy: ListScrollingProxy // reference type
func makeUIView(context: Context) -> UIView
return UIView() // managed by SwiftUI, no overloads
func updateUIView(_ uiView: UIView, context: Context)
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
找到封闭UIScrollView
的简单代理(需要执行一次),然后将所需的“滚动到”操作重定向到存储的滚动视图
class ListScrollingProxy
enum Action
case end
case top
case point(point: CGPoint) // << bonus !!
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView)
if nil == scrollView
scrollView = view.enclosingScrollView()
func scrollTo(_ action: Action)
if let scroller = scrollView
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default:
// default goes to top
()
scroller.scrollRectToVisible(rect, animated: true)
extension UIView
func enclosingScrollView() -> UIScrollView?
var next: UIView? = self
repeat
next = next?.superview
if let scrollview = next as? UIScrollView
return scrollview
while next != nil
return nil
【讨论】:
我对这个解决方案有一些疑问。它似乎仅在您之前与列表交互时才有效。你注意到了还是11.4.1
的事情?
@Błażej,我还没有迁移到 11.4.1,所以可能是更新问题。 11.4 一切正常。
我在滚动视图中使用它。只有在我与滚动视图进行一些交互后,它才有效。 "if let scroller = scrollView " 不立即符合
我通过在 ScrollView 内容完全加载后重新声明 self.scrollingProxy = ListScrollingProxy() 解决了这个问题
@AmineArous 在我的例子中,我使用来自网络的 JSON 填充一个列表,在我获取数据并将数据添加到列表之后,我这样做: DispatchQueue.main.async self.scrollingProxy = ListScrollingProxy( ) 【参考方案2】:
只需滚动到 id:
scrollView.scrollTo(ROW-ID)
由于 SwiftUI 结构化设计的数据驱动,您应该知道所有项目 ID。因此,您可以在 iOS 14 和 Xcode 12
中使用ScrollViewReader
滚动到任何 id
struct ContentView: View
let items = (1...100)
var body: some View
ScrollViewReader scrollProxy in
ScrollView
ForEach(items, id: \.self) Text("\($0)"); Divider()
HStack
Button("First!") withAnimation scrollProxy.scrollTo(items.first!)
Button("Any!") withAnimation scrollProxy.scrollTo(50)
Button("Last!") withAnimation scrollProxy.scrollTo(items.last!)
注意ScrollViewReader
应该支持所有可滚动的内容,但是现在它只支持ScrollView
预览
【讨论】:
【参考方案3】:感谢 Asperi,非常棒的提示。当在视图之外添加新条目时,我需要向上滚动一个列表。重新设计以适应 macOS。
我将状态/代理变量带到环境对象中,并在视图外部使用它来强制滚动。我发现我必须更新它两次,第二次延迟 0.5 秒才能获得最佳结果。第一次更新防止视图在添加行时滚动回顶部。第二次更新滚动到最后一行。我是新手,这是我的第一篇 *** 帖子:o
已针对 MacOS 更新:
struct ListScrollingHelper: NSViewRepresentable
let proxy: ListScrollingProxy // reference type
func makeNSView(context: Context) -> NSView
return NSView() // managed by SwiftUI, no overloads
func updateNSView(_ nsView: NSView, context: Context)
proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
class ListScrollingProxy
//updated for mac osx
enum Action
case end
case top
case point(point: CGPoint) // << bonus !!
private var scrollView: NSScrollView?
func catchScrollView(for view: NSView)
//if nil == scrollView //unB - seems to lose original view when list is emptied
scrollView = view.enclosingScrollView()
//
func scrollTo(_ action: Action)
if let scroller = scrollView
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action
case .end:
rect.origin.y = scroller.contentView.frame.minY
if let documentHeight = scroller.documentView?.frame.height
rect.origin.y = documentHeight - scroller.contentSize.height
case .point(let point):
rect.origin.y = point.y
default:
// default goes to top
()
//tried animations without success :(
scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
scroller.reflectScrolledClipView(scroller.contentView)
extension NSView
func enclosingScrollView() -> NSScrollView?
var next: NSView? = self
repeat
next = next?.superview
if let scrollview = next as? NSScrollView
return scrollview
while next != nil
return nil
【讨论】:
谢谢,macOS 需要它:)【参考方案4】:这是一个适用于 iOS13&14 的简单解决方案: 使用Introspect. 我的情况是初始滚动位置。
ScrollView(.vertical, showsIndicators: false, content:
...
)
.introspectScrollView(customize: scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
)
如果需要,可以根据屏幕大小或元素本身计算高度。 此解决方案适用于垂直滚动。对于水平,您应该指定 x 并将 y 保留为 0
【讨论】:
这是针对 SwiftUI 1.0 的最佳答案【参考方案5】:现在可以使用 Xcode 12 中的所有新 ScrollViewProxy
来简化这一点,如下所示:
struct ContentView: View
let itemCount: Int = 100
var body: some View
ScrollViewReader value in
VStack
Button("Scroll to top")
value.scrollTo(0)
Button("Scroll to buttom")
value.scrollTo(itemCount-1)
ScrollView
LazyVStack
ForEach(0 ..< itemCount) i in
Text("Item \(i)")
.frame(height: 50)
.id(i)
【讨论】:
【参考方案6】:我的两分钱用于根据其他逻辑在任何时候删除和重新定位列表..例如在删除之后,敌人的例子会进入顶部。 (这是一个超精简的示例,我习惯在网络调用后重新定位:在网络调用后我更改 previousIndex )
结构内容视图:查看
@State private var previousIndex : Int? = nil
@State private var items = Array(0...100)
func removeRows(at offsets: IndexSet)
items.remove(atOffsets: offsets)
self.previousIndex = offsets.first
var body: some View
ScrollViewReader (proxy: ScrollViewProxy) in
List
ForEach(items, id: \.self) Text("\($0)")
.onDelete(perform: removeRows)
.onChange(of: previousIndex) (e: Equatable) in
proxy.scrollTo(previousIndex!-4, anchor: .top)
//proxy.scrollTo(0, anchor: .top) // will display 1st cell
【讨论】:
我只想指出这是唯一的解决方案,它使用列表,因为问题的标题被问到了。在 iOS 15.1 上测试,并且可以工作。谢谢。【参考方案7】:MacOS 11:如果您需要根据视图层次结构之外的输入滚动列表。我使用新的 scrollViewReader 遵循了原始滚动代理模式:
struct ScrollingHelperInjection: NSViewRepresentable
let proxy: ScrollViewProxy
let helper: ScrollingHelper
func makeNSView(context: Context) -> NSView
return NSView()
func updateNSView(_ nsView: NSView, context: Context)
helper.catchProxy(for: proxy)
final class ScrollingHelper
//updated for mac os v11
private var proxy: ScrollViewProxy?
func catchProxy(for proxy: ScrollViewProxy)
self.proxy = proxy
func scrollTo(_ point: Int)
if let scroller = proxy
withAnimation()
scroller.scrollTo(point)
else
//problem
环境对象:
@Published var scrollingHelper = ScrollingHelper()
在视图中:ScrollViewReader reader in .....
在视图中注入:
.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)
在视图层次结构之外使用:scrollingHelper.scrollTo(3)
【讨论】:
【参考方案8】:正如@lachezar-todorov 的回答中提到的Introspect 是一个很好的库,可以访问SwiftUI
中的UIKit
元素。但请注意,您用于访问 UIKit
元素的块被多次调用。这真的会弄乱您的应用程序状态。在我的 cas CPU 使用率达到 %100 并且应用程序变得无响应。我不得不使用一些前置条件来避免它。
ScrollView()
...
.introspectScrollView scrollView in
if aPreCondition
//Your scrolling logic
【讨论】:
【参考方案9】:另一种很酷的方法是只使用命名空间包装器:
一种动态属性类型,允许访问由包含该属性的对象(例如视图)的持久标识定义的命名空间。
struct ContentView: View
@Namespace private var topID
@Namespace private var bottomID
let items = (0..<100).map $0
var body: some View
ScrollView
ScrollViewReader proxy in
Section
LazyVStack
ForEach(items.indices, id: \.self) index in
Text("Item \(items[index])")
.foregroundColor(.black)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green.cornerRadius(16))
header:
HStack
Text("header")
Spacer()
Button(action:
withAnimation
proxy.scrollTo(bottomID)
)
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
.padding(.vertical)
.id(topID)
footer:
HStack
Text("Footer")
Spacer()
Button(action:
withAnimation
proxy.scrollTo(topID)
)
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
.padding(.vertical)
.id(bottomID)
.padding()
.foregroundColor(.white)
.background(.black)
【讨论】:
以上是关于如何在 SwiftUI 中以编程方式滚动列表?的主要内容,如果未能解决你的问题,请参考以下文章
如何在 SwiftUI 中以编程方式计算 MKMapCamera centerCoordinateDistance
如何在 iOS 应用程序中以编程方式检测 SwiftUI 的使用情况