[英]How to scroll List programmatically in SwiftUI?
看起來在當前的工具/系統中,剛剛發布的 Xcode 11.4 / iOS 13.4,不會有 SwiftUI 原生支持List
中的“滾動到”功能。 因此,即使他們 Apple 會在下一個主要版本中提供它,我也需要對 iOS 13.x 的向后支持。
那么我將如何以最簡單和輕松的方式做到這一點?
(我不喜歡將完整的UITableView
基礎結構包裝到UIViewRepresentable/UIViewControllerRepresentable
中,正如之前在 SO 上提出的那樣)。
SWIFTUI 2.0
這是 Xcode 12 / iOS 14 (SwiftUI 2.0) 中可能的替代解決方案,當滾動控件位於滾動區域之外時,可以在同一場景中使用(因為 SwiftUI2 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
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
}
}
scrollView.scrollTo(ROW-ID)
由於 SwiftUI 結構化設計的數據驅動,你應該知道你所有的項目 ID。 因此,您可以使用iOS 14 中的ScrollViewReader
和Xcode 12滾動到任何 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
謝謝 Asperi,很棒的提示。 當在視圖之外添加新條目時,我需要向上滾動列表。 重新設計以適應 macOS。
我將狀態/代理變量帶到環境對象並在視圖外使用它來強制滾動。 我發現我必須更新它兩次,第二次有 0.5 秒的延遲才能獲得最佳結果。 第一次更新可防止視圖在添加行時滾動回頂部。 第二次更新滾動到最后一行。 我是新手,這是我的第一個 stackoverflow 帖子: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
}
}
這是一個適用於 iOS13&14 的簡單解決方案:
使用自省。
我的情況是初始滾動位置。
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
現在可以使用 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)
}
}
}
}
}
}
}
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)
正如@lachezar-todorov 的回答中提到的, Introspect是一個很好的庫,可以訪問SwiftUI
UIKit
元素。 但請注意,您用於訪問UIKit
元素的塊會被多次調用。 這真的會弄亂你的應用程序狀態。 在我的 cas CPU 使用率達到 %100 並且應用程序沒有響應。 我不得不使用一些先決條件來避免它。
ScrollView() {
...
}.introspectScrollView { scrollView in
if aPreCondition {
//Your scrolling logic
}
}
我的兩分錢用於根據其他邏輯在任何時候刪除和重新定位列表。 (這是一個超簡化的示例,我習慣於在網絡回調后重新定位:網絡調用后我更改 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
}
}
}
}
另一種很酷的方法是只使用命名空間包裝器:
一種動態屬性類型,允許訪問由包含該屬性的對象(例如視圖)的持久標識定義的命名空間。
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)
}
}
兩部分:
ScrollViewReader
包裹List
(或ScrollView
)scrollViewProxy
(來自ScrollViewReader
)滾動到List
中元素的id
。 您似乎可以使用EmptyView()
。為簡單起見,下面的示例使用通知(如果可以,請使用函數。)。
ScrollViewReader { scrollViewProxy in
List {
EmptyView().id("top")
}
.onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
// when using an anchor of `.top`, it failed to go all the way to the top
// so here we add an extra -50 so it goes to the top
scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
}
}
extension Notification.Name {
static let ScrollToTop = Notification.Name("ScrollToTop")
}
NotificationCenter.default.post(name: .ScrollToTop, object: nil)
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.