45

It looks like in current tools/system, just released Xcode 11.4 / iOS 13.4, there will be no SwiftUI-native support for "scroll-to" feature in List. So even if they, Apple, will provide it in next major released, I will need backward support for iOS 13.x.

So how would I do it in most simple & light way?

  • scroll List to end
  • scroll List to top
  • and others

(I don't like wrapping full UITableView infrastructure into UIViewRepresentable/UIViewControllerRepresentable as was proposed earlier on SO).

1
  • The Form or List won't scroll it you make the height larger than the scrollable area using .frame(height: x), where x is a value larger than the content height. Commented Aug 13, 2020 at 15:35

11 Answers 11

62

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

demo2

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
                        )
                }
            }
        }
    }
}

demo

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
    }
}
Sign up to request clarification or add additional context in comments.

10 Comments

I have some issue with this solution. It appears to work only if you previously interacted with the list. Did you notice that or it's 11.4.1 thing?
@Błażej, I have not migrated to 11.4.1 yet, so probably it's update issue. With 11.4 all works fine.
I'm using this in a scrollview. It only works if after i interact a little with the scrollview. "if let scroller = scrollView {" doesn't conform immediately
i solved it by redeclaring self.scrollingProxy = ListScrollingProxy() after the ScrollView content was fully loaded
@AmineArous in my case, I populate a List with JSON from the web, after I get and add the data to the list, I do: DispatchQueue.main.async { self.scrollingProxy = ListScrollingProxy() }
|
18
Just scroll use the id:

✅ iOS 17 - Two way binding

From iOS 17, You can use .scrollPosition modifier on ScrollView with the .scrollTargetLayout on its content to have binding to the position, then you can either set or get the position with a single property:

Demo

Demo

Demo Code
struct ContentView: View {
    @State var items: [String] = (1...100).map(String.init)
    @State var scrolledID: String? = "1"

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items, id: \.self, content: Text.init)
            }
            .scrollTargetLayout() // 👈 Apply this on the `content` of scroll view
        }
        .scrollPosition(id: $scrolledID) // 👈 Apply this on the `ScrollView` itself

        .overlay(alignment: .bottom) {
            Button("Scrolled ID: \(scrolledID!)") { // 👈 Get scroll position by id
                scrolledID = "50" // 👈 Set scroll position by id
            }
            .background()
        }
        .animation(.default, value: scrolledID) // 👈 Animate the scroll
    }
}

📜 iOS below 14 - scrollProxy

This would be the call inside the ScrollViewReader:

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

Demo

Demo

Demo Code
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

1 Comment

Hey, thanks for the nice solution. I am trying to do similar things. However, I want to control the animation time. While moving Position 1st to 7th will take a total of 1 second. I tried this way withAnimation (.linear(duration: 1)){ reader.scrollTo(7) }. But there is no control over customize animation time. It's animating with default time. Do you have any idea on this.
14

Preferred way

This answer is getting more attention, but I should state that the ScrollViewReader is the right way to do this. The introspect way is only if the reader/proxy doesn't work for you, because of a version restrictions.

ScrollViewReader { proxy in
    ScrollView(.vertical) {
        TopView().id("TopConstant")
        ...
        MiddleView().id("MiddleConstant")
        ...
        Button("Go to top") {
            proxy.scrollTo("TopConstant", anchor: .top)
        }
        .id("BottomConstant")
    }
    .onAppear{
        proxy.scrollTo("MiddleConstant")
    }
    .onChange(of: viewModel.someProperty) { _ in
        proxy.scrollTo("BottomConstant")
    }
}

The strings should be defined in one place, outside of the body property.

Legacy answer

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

2 Comments

this best answer to target SwiftUI 1.0
I needed to use .onChange to scroll to the top and this helped me nail it. Thank you
10

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
    }
}

1 Comment

you can use this for animations: ``` NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 6.0 let clipView = scroller.contentView var newOrigin = clipView.bounds.origin newOrigin.x = rect.minX newOrigin.y = rect.minY clipView.animator().setBoundsOrigin(newOrigin) NSAnimationContext.endGrouping() ```
8

my two cents for deleting and repositioning list at any point based on other logic.. i.e. after delete/update, for example going to top. (this is a ultra-reduced sample, I used this code after network call back to reposition: after network call I change previousIndex )

struct ContentView: View {

@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
        }

    }
    
}

}

2 Comments

I would just like to point out that this is the only solution, which uses List, as the title of the question is asked. Tested on iOS 15.1, and works. Thank you.
thx Tom! testing on iOS 16
3

This can now be simplified with all new ScrollViewProxy in Xcode 12, like so:

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)
                        }
                    }
                }
            }
        }
    }
}

Comments

2

MacOS 11: In case you need to scroll a list based on input outside the view hierarchy. I have followed the original scroll proxy pattern using the new 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
        }
    }
}

Environmental object: @Published var scrollingHelper = ScrollingHelper()

In the view: ScrollViewReader { reader in .....

Injection in the view: .background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)

Usage outside the view hierarchy: scrollingHelper.scrollTo(3)

Comments

2

As mentioned in @lachezar-todorov's answer Introspect is a nice library to access UIKit elements in SwiftUI. But be aware that the block you use for accessing UIKit elements are being called multiple times. This can really mess up your app state. In my cas CPU usage was going %100 and app was getting unresponsive. I had to use some pre conditions to avoid it.

ScrollView() {
    ...
}.introspectScrollView { scrollView in
    if aPreCondition {
        //Your scrolling logic
    }
}

Comments

2

Another cool way is to just use namespace wrappers:

A dynamic property type that allows access to a namespace defined by the persistent identity of the object containing the property (e.g. a view).

enter image description here

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)
    }
}

Comments

2

Two parts:

  1. Wrap the List (or ScrollView) with ScrollViewReader
  2. Use the scrollViewProxy (that comes from ScrollViewReader) to scroll to an id of an element in the List. You can seemingly use EmptyView().

The example below uses a notification for simplicity (use a function if you can instead!).

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)

Comments

1

after reading some comment about using buttons outside the hierarchy, a rewrote a bit using scrollview, this time, hoping can help:

import SwiftUI
    
struct ContentView: View {
    let colors: [Color] = [.red, .green, .blue]

    @State private var idx : Int? = nil

    var body: some View {
        VStack{
            
            Button("Jump to #12") {
                idx = 12
            }
            Button("Jump to #1") {
                idx = 1
            }
            
            ScrollViewReader { (proxy: ScrollViewProxy) in
                ScrollView(.horizontal) {
                    HStack(spacing: 20) {
                        
                        ForEach(0..<100) { i in
                            Text("Example \(i)")
                                .font(.title)
                                .frame(width: 200, height: 200)
                                .background(colors[i % colors.count])
                                .id(i)
                        }
                    } // HStack
                    
                }// ScrollView
                .frame(height: 350)
                .onChange(of: idx) { newValue in
                    withAnimation {
                        proxy.scrollTo(idx, anchor: .bottom)
                    }
                }

            } // ScrollViewReader
        }
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.