Scroll List to Row in SwiftUI


The 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 the 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 the 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 a UITableView 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 to nil, 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 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 the 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 for this article on GitHub.



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 this code instead. The difference will be that we need to find the correct tableView and keep a reference to it.