How to scroll List programmatically in SwiftUI?
Asked Answered
C

11

44

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

Culpable answered 25/3, 2020 at 19:14 Comment(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.Venepuncture
C
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
    }
}
Culpable answered 25/3, 2020 at 19:14 Comment(10)
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?Pentose
@Błażej, I have not migrated to 11.4.1 yet, so probably it's update issue. With 11.4 all works fine.Culpable
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 immediatelyBabbie
i solved it by redeclaring self.scrollingProxy = ListScrollingProxy() after the ScrollView content was fully loadedBabbie
how to scroll to the very specific cell? Let say I want to scroll to the 40th cell.Rarefied
Thank you @Culpable ! I wanted to use a floating button to scroll a ScrollView to the top, but the button wasn't within ScrollViewReader, instead they were side by side in a ZStack. This exactly solved the problem.Zaffer
@FilipeSá how to detect when ScrollView content was fully loaded? i have the same issueGreige
@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() }Babbie
Apple's SwiftUI 2 documentation shows the ScrollViewReader declared as the parent of the ScrollView. Seems to work either way.Springs
Another approach that I took to make it work at the initial loading is that: I added 'weak var sampleView: UIView?' to ListScrollingProxy and on 'catchScrollView' assigned it to 'sampleView = view'. Then in 'scrollToMethod' did the following: 'if scrollView == nil { if let sampleView = sampleView { scrollView = sampleView.enclosingScrollView() } }'. Here is my gist link gist.github.com/gsoykan/4b77614aa827ba3243e0ed9b7e268ad4Heeheebiejeebies
C
18

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

preview

Cartwell answered 28/6, 2020 at 14:18 Comment(1)
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.Materiel
H
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

Histrionics answered 12/11, 2020 at 15:5 Comment(2)
this best answer to target SwiftUI 1.0Flavorful
I needed to use .onChange to scroll to the top and this helped me nail it. Thank youMichelsen
E
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
    }
}
Essy answered 20/6, 2020 at 1:14 Comment(1)
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() ```Alonzoaloof
D
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
        }

    }
    
}

}

Dermis answered 28/3, 2021 at 8:21 Comment(2)
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.Glossolalia
thx Tom! testing on iOS 16Dermis
P
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)
                        }
                    }
                }
            }
        }
    }
}
Painstaking answered 27/6, 2020 at 12:17 Comment(0)
E
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)

Essy answered 8/10, 2020 at 4:19 Comment(0)
O
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
    }
}
Orthocephalic answered 18/12, 2020 at 3:22 Comment(0)
J
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)
    }
}
Jori answered 21/12, 2021 at 17:32 Comment(0)
D
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
        }
    }
}
Dermis answered 27/4, 2023 at 21:28 Comment(0)
R
0

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)
Retroflex answered 15/12, 2022 at 1:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.