那么我应该用最简单、最轻量的方式来实现呢? - 滚动列表到末尾 - 滚动列表到顶部 - 其他
(我不喜欢将完整的UITableView基础设施包装成UIViewRepresentable / UIViewControllerRepresentable,这是之前在SO上提出的建议)。
SWIFTUI 2.0
在Xcode 12 / iOS 14(SwiftUI 2.0)中,这里是一个可能的备选方案,可在滚动控件位于滚动区域之外的相同场景中使用(因为SwiftUI 2的ScrollViewReader
仅可在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
中注入可视化轻量级控件可以访问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
}
}
ScrollViewReader
被声明为ScrollView
的父级。不管怎样似乎都可以工作。 - AdamscrollView.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
这个答案引起了更多的关注,但我应该指出 ScrollViewReader
是正确的方法来实现这一点。Introspect 方法只有在阅读器/代理因版本限制而无法工作时才适用。
ScrollViewReader { proxy in
ScrollView(.vertical) {
TopView().id("TopConstant")
...
MiddleView().id("MiddleConstant")
...
Button("Go to top") {
proxy.scrollTo("TopConstant", anchor: .top)
}
.id("BottomConstant")
}
.onAppear{
proxy.scrollTo("MiddleConstant")
}
.onChange(of: viewModel.someProperty) { _ in
proxy.scrollTo("BottomConstant")
}
}
字符串应该在body属性之外的一个地方定义。
这里有一个简单的解决方案,适用于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。感谢Asperi提供的好建议。我需要在视图之外添加新条目时使列表向上滚动。我将状态/代理变量转移到环境对象中,并在视图之外使用它来强制滚动。我发现我需要更新两次,第二次需要延迟0.5秒才能获得最佳结果。第一次更新操作可以防止视图在添加行时滚动到顶部。第二次更新则将其滚动到最后一行。作为一个新手,这也是我的第一篇Stack Overflow帖子 :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
}
}
NSAnimationContext.current.duration = 6.0
let clipView = scroller.contentView
var newOrigin = clipView.bounds.origin
newOrigin.x = rect.minX
newOrigin.y = rect.minY
clipView.animator().setBoundsOrigin(newOrigin)
NSAnimationContext.endGrouping()
- Mihriban Minaz我对删除和重新定位列表的建议是:根据其他逻辑,在任意位置进行操作。例如,在删除/更新后,将其移动到顶部。 (这只是一个极简的示例,我在网络回调后使用此代码重新定位:在网络调用后,我更改了previousIndex)
结构体 ContentView: View {
@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
}
}
}
}
现在,使用 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)
}
}
}
}
}
}
}
另一种很酷的方法是使用命名空间包装器:
动态属性类型,允许访问由包含属性的对象(例如视图)的持久标识定义的命名空间。
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
中访问 UIKit
元素。但是请注意,用于访问 UIKit
元素的块会被调用多次。这可能会导致应用程序状态混乱。在我的情况下,CPU 使用率达到了 100%,应用程序无响应。我不得不使用一些预先条件来避免这种情况。ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
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)
在阅读了一些有关在层次结构之外使用按钮的评论后,我重新编写了一下代码,这一次使用了ScrollView,希望能够有所帮助:
import SwiftUI
结构体 ContentView: View { let colors: [Color] = [.red, .green, .blue]
@State private var idx : Int? = nil
var body: some View {
VStack{
Button("Jump to #12") {
idx = 12
}
Button("Jump to #1") {
idx = 1
}
ScrollViewReader { (proxy: ScrollViewProxy) in
ScrollView(.horizontal) {
HStack(spacing: 20) {
ForEach(0..<100) { i in
Text("Example \(i)")
.font(.title)
.frame(width: 200, height: 200)
.background(colors[i % colors.count])
.id(i)
}
} // HStack
}// ScrollView
.frame(height: 350)
.onChange(of: idx) { newValue in
withAnimation {
proxy.scrollTo(idx, anchor: .bottom)
}
}
} // ScrollViewReader
}
}
}
.frame(height: x)
将高度设置为大于可滚动区域的值(其中x是大于内容高度的值),则表单或列表将无法滚动。 - Alex Brown