Scroll List to row in SwiftUI on iOS 13

Note: This article is only valid for iOS 13 when compiled with Xcode 11. Starting from iOS 14 you should use ScrollViewReader. You can read Use ScrollViewReader in SwiftUI to Scroll to a New Item article on how to use it when dynamically adding items.

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 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 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)
                    }
            )
        }
    }
}
Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Check out our book!

Integrating SwiftUI into UIKit Apps

Integrating SwiftUI intoUIKit Apps

UPDATED FOR iOS 17!

A detailed guide on gradually adopting SwiftUI in UIKit projects.

  • Discover various ways to add SwiftUI views to existing UIKit projects
  • Use Xcode previews when designing and building UI
  • Update your UIKit apps with new features such as Swift Charts and Lock Screen widgets
  • Migrate larger parts of your apps to SwiftUI while reusing views and controllers built in UIKit

# 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.