Asperi
Asperi

Reputation: 258345

How to scroll List programmatically in SwiftUI?

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?

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

Upvotes: 45

Views: 46714

Answers (11)

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 119917

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

Upvotes: 18

ingconti
ingconti

Reputation: 11666

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

Upvotes: 1

kgaidis
kgaidis

Reputation: 15639

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)

Upvotes: 2

Lachezar Todorov
Lachezar Todorov

Reputation: 923

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

Upvotes: 14

Asperi
Asperi

Reputation: 258345

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

Upvotes: 62

ingconti
ingconti

Reputation: 11666

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
        }

    }
    
}

}

Upvotes: 8

Peter Kreinz
Peter Kreinz

Reputation: 8666

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

Upvotes: 2

Olcay Ertaş
Olcay Ertaş

Reputation: 6236

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

Upvotes: 2

unB
unB

Reputation: 129

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)

Upvotes: 2

Sajjon
Sajjon

Reputation: 9907

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

Upvotes: 3

unB
unB

Reputation: 129

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

Upvotes: 10

Related Questions