How to scroll List programmatically in SwiftUI?
SWIFTUI 2.0
Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader
can be used only inside ScrollView
)
Note: Row content design is out of consideration scope
Tested with 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+
Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.
Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)
Demo of usage:
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
)
}
}
}
}
}
Solution:
Light view representable being injected into List
gives access to UIKit's view hierarchy. As List
reuses rows there are no more values then fit rows into screen.
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
}
}
Simple proxy that finds enclosing UIScrollView
(needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview
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
}
}
Just scroll to the id:
scrollView.scrollTo(ROW-ID)
Since SwiftUI structured designed Data-Driven, You should know all of your items IDs. So you can scroll to any id with ScrollViewReader
from iOS 14 and with Xcode 12
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!) } }
}
}
}
}
Note that ScrollViewReader
should support all scrollable content, but now it only supports ScrollView
Preview
Thanks Asperi, great tip. I needed to have a List scroll up when new entries where added outside the view. Reworked to suit macOS.
I took the state/proxy variable to an environmental object and used this outside the view to force the scroll. I found I had to update it twice, the 2nd time with a .5sec delay to get the best result. The first update prevents the view from scrolling back to the top as the row is added. The 2nd update scrolls to the last row. I'm a novice and this is my first stackoverflow post :o
Updated for 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
}
}
Here is a simple solution that works on iOS13&14:
Using Introspect.
My case was for initial scroll position.
ScrollView(.vertical, showsIndicators: false, content: {
...
})
.introspectScrollView(customize: { scrollView in
scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
})
If needed the height may be calculated from the screen size or the element itself. This solution is for Vertical scroll. For horizontal you should specify x and leave y as 0