Scroll List to row in SwiftUI on iOS 13
List view in SwiftUI is very easy to use and provides a lot of functionality. We can quickly create rows and sections, add swipe to delete and implement editing. But, unfortunately, in the current iOS version 13.4.1 it's missing an API to control the scrolling.
In this article we are going to look into how we can use the fact that at the moment List
view in SwiftUI is a UITableView under the hood. We will reach into the underlying table view and scroll it to a specific index path.
# Create helper view for scrolling
First, we need to implement a helper view that will do the scrolling. It accepts a binding for the IndexPath
that it needs to scroll into visible range.
import SwiftUI
struct ScrollManagerView: UIViewRepresentable {
@Binding var indexPathToSetVisible: IndexPath?
func makeUIView(context: Context) -> UIView {
let view = UIView()
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
Inside updateUIView()
method we will find the table view in question and scroll it to the desired position:
- Get the view controller from the
uiView
and search for aUITableView
among its subviews - Assert that the number of sections and the number of rows in the section are greater than in the index path to avoid a crash
- Scroll the table view to the correct row
- Reset the binding for the
IndexPath
tonil
, so that if we set it to the same value again later, the update comes through (we need to dispatch this action asynchronously)
struct ScrollManagerView: UIViewRepresentable {
...
func updateUIView(_ uiView: UIView, context: Context) {
guard let indexPath = indexPathToSetVisible else { return }
let superview = uiView.findViewController()?.view
if let tableView = superview?.subview(of: UITableView.self) {
if tableView.numberOfSections > indexPath.section &&
tableView.numberOfRows(inSection: indexPath.section) > indexPath.row {
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
DispatchQueue.main.async {
self.indexPathToSetVisible = nil
}
}
}
extension UIView {
func subview<T>(of type: T.Type) -> T? {
return subviews.compactMap { $0 as? T ?? $0.subview(of: type) }.first
}
func findViewController() -> UIViewController? {
if let nextResponder = self.next as? UIViewController {
return nextResponder
} else if let nextResponder = self.next as? UIView {
return nextResponder.findViewController()
} else {
return nil
}
}
}
The method findViewController()
was taken from Hacking with Swift website.
# Add helper view to SwiftUI List
We will add the ScrollManagerView
to our SwiftUI view that has the List
. We have to set the helper view as an overlay on the list, so that we only have one for the whole list. To make sure it doesn't get in the way of the touch events, we need to set its frame to zero.
struct ContentView: View {
@State var items = [
"Item 1", "Item 2", "Item 3", "Item 4", "Item 5",
"Item 6", "Item 7", "Item 8", "Item 9", "Item 10",
"Item 11", "Item 12", "Item 13", "Item 14", "Item 15",
"Item 16", "Item 17", "Item 18", "Item 19", "Item 20"
]
@State var indexPathToSetVisible: IndexPath?
var body: some View {
NavigationView {
List {
ForEach(0..<self.items.count, id: \.self) { index in
Text(self.items[index])
}
}
.overlay(
ScrollManagerView(indexPathToSetVisible: $indexPathToSetVisible)
.allowsHitTesting(false).frame(width: 0, height: 0)
)
}
}
}
# Scroll List to the top
To scroll the list to the top we just need to set the indexPathToSetVisible
to the first row and the first section.
struct ContentView: View {
...
@State var indexPathToSetVisible: IndexPath?
var body: some View {
NavigationView {
List {
...
}
.overlay(
ScrollManagerView(indexPathToSetVisible: $indexPathToSetVisible)
.allowsHitTesting(false).frame(width: 0, height: 0)
)
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button("Scroll to top") {
self.indexPathToSetVisible = IndexPath(row: 0, section: 0)
}
)
}
}
}
# Scroll List to newly added (or selected) item
If, for example, we want to scroll the list to the item that was just added, we will need to set the indexPathToSetVisible
to that item's row index path.
struct ContentView: View {
...
@State var indexPathToSetVisible: IndexPath?
var body: some View {
NavigationView {
List {
...
}
.overlay(
ScrollManagerView(indexPathToSetVisible: $indexPathToSetVisible)
.allowsHitTesting(false).frame(width: 0, height: 0)
)
.navigationBarTitle("Items")
.navigationBarItems(
leading:
Button("Scroll to top") {
self.indexPathToSetVisible = IndexPath(
row: 0, section: 0
)
},
trailing:
Button("Add") {
self.items.append("Item \(self.items.count + 1)")
self.indexPathToSetVisible = IndexPath(
row: self.items.count - 1, section: 0
)
}
)
}
}
}
# Scroll List to row on app launch
If we have a default scroll position, for example for state restoration purposes, we will have to set this position to indexPathToSetVisible
inside onAppear()
modifier. It's important to do it after the view has appeared for the scroll to work properly.
struct ContentView: View {
...
@State var indexPathToSetVisible: IndexPath?
var body: some View {
NavigationView {
List {
...
}
.overlay(
ScrollManagerView(indexPathToSetVisible: $indexPathToSetVisible)
.allowsHitTesting(false).frame(width: 0, height: 0)
)
.onAppear {
self.indexPathToSetVisible = IndexPath(row: 18, section: 0)
}
}
}
}
You can get the full code in our GitHub folder for this article.
# Scroll List to row when there are multiple List views in the app
The solution described above will only work if there is just one List
in the whole SwiftUI view hierarchy. If your app has more than one List
, you will need to use the code for hierarchy with multiple List views instead. The difference will be that we need to find the correct tableView
and keep a reference to it.