21

If you have a SwiftUI List with that allows single selection, you can change the selection by clicking the list (presumably this makes it the key responder) and then using the arrow keys. If that selection reaches the end of the visible area, it will scroll the whole list to keep the selection visible.

However, if the selection object is updated in some other way (e.g. using a button), the list will not be scrolled.

Is there any way to force the list to scroll to the new selection when set programmatically?

Example app:

import SwiftUI

struct ContentView: View {
    @State var selection: Int? = 0

    func changeSelection(_ by: Int) {
        switch self.selection {
        case .none:
            self.selection = 0
        case .some(let sel):
            self.selection = max(min(sel + by, 20), 0)
        }
    }

    var body: some View {
        HStack {
            List((0...20), selection: $selection) {
                Text(String($0))
            }
            VStack {
                Button(action: { self.changeSelection(-1) }) {
                    Text("Move Up")
                }
                Button(action: { self.changeSelection(1) }) {
                    Text("Move Down")
                }
            }
        }
    }
}
Brandon Horst
  • 1,921
  • 16
  • 26

3 Answers3

5

I tried several solutions, one of them I'm using in my project (I need horizontal paging for 3 lists). And here are my observations:

  1. I didn't find any methods to scroll List in SwiftUI, there is no mention about it in documentation yet;
  2. You may try ScrollView (my variant below, here is other solution), but these things might look monstroid;
  3. Maybe the best way is to use UITableView: tutorial from Apple and try scrollToRowAtIndexPath method (like in this answer).

As I wrote, here is my example, which, of course, requires refinement. First of all ScrollView needs to be inside GeometryReader and you can understand the real size of content. The second thing is that you need to control your gestures, which might be difficult. And the last one: you need to calculate current offset of ScrollViews's content and it could be other than in my code (remember, I tried to give you example):

struct ScrollListView: View {

    @State var selection: Int?
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    func changeSelection(_ by: Int) {
        switch self.selection {
        case .none:
            self.selection = 0
        case .some(let sel):
            self.selection = max(min(sel + by, 30), 0)
        }
    }

    var body: some View {

        HStack {

            GeometryReader { geometry in

                VStack {
                    ScrollView(.vertical, showsIndicators: false) {

                        ForEach(0...29, id: \.self) { line in
                            ListRow(line: line, selection: self.$selection)
                                .frame(height: 20)
                        }

                        }
                    .content.offset(y: self.isGestureActive ? self.offset : geometry.size.height / 4 - CGFloat((self.selection ?? 0) * 20))
                    .gesture(DragGesture()
                        .onChanged({ value in
                            self.isGestureActive = true
                            self.offset = value.translation.width + -geometry.size.width * CGFloat(self.selection ?? 1)

                        })
                    .onEnded({ value in
                        DispatchQueue.main.async { self.isGestureActive = false }
                    }))

                }

            }


            VStack {
                Button(action: { self.changeSelection(-1) }) {
                    Text("Move Up")
                }
                Spacer()
                Button(action: { self.changeSelection(1) }) {
                    Text("Move Down")
                }
            }
        }
    }
}

of course you need to create your own "list row":

struct ListRow: View {

    @State var line: Int
    @Binding var selection: Int?

    var body: some View {

        HStack(alignment: .center, spacing: 2){

            Image(systemName: line == self.selection ? "checkmark.square" : "square")
                .padding(.horizontal, 3)
            Text(String(line))
            Spacer()

        }
        .onTapGesture {
            self.selection = self.selection == self.line ? nil : self.line
        }

    }

}

hope it'll be helpful.

Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36
  • I went with a solution similar to this myself, but I'll leave this open in case a fully SwiftUI solution is possible. – Brandon Horst Jan 22 '20 at 03:38
  • I have some hack that is relatively simple to use. You can quickly try it. – Michał Ziobro Feb 05 '20 at 11:13
  • Correct me if I am wrong, but this solution suffers from the same issue all of the other solutions I have seen have. You are assuming the width of each view in the list is the width of the screen, so the user will only see one choice at a time on the screen. I am trying to figure out how to have the multiple items on the screen so the user has a visual cue to know that he/she must scroll. – Moebius Jun 19 '20 at 19:39
4

In the new relase of SwiftUI for iOs 14 and MacOs Big Sur they added the ability to programmatically scroll to a specific cell using the new ScrollViewReader:

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

    var body: some View {
        ScrollView {
            ScrollViewReader { value in
                Button("Jump to #8") {
                    value.scrollTo(8)
                }

                ForEach(0..<10) { i in
                    Text("Example \(i)")
                        .frame(width: 300, height: 300)
                        .background(colors[i % colors.count])
                        .id(i)
                }
            }
        }
    }
}

Then you can use the method .scrollTo() like this

value.scrollTo(8, anchor: .top)

Credit: www.hackingwithswift.com

Luca
  • 914
  • 9
  • 18
  • How can I tell the ScrollView to scroll to a specific anchor from outside the ScrollView? – ediheld Oct 14 '20 at 13:28
  • 1
    I don't think its possible because `value` is only usable in the `ScrollViewReader` so the method `.scrollTo()` is only callable inside the `ScrollViewReader` – Luca Oct 15 '20 at 15:47
  • You can move the Reader outside of the Scrollview. (And wrap the ScrollView and other views in it.) That matches the example in the docs btw: https://developer.apple.com/documentation/swiftui/scrollviewreader – calimarkus May 12 '22 at 03:38
1

I am doing it this way:

1) Reusable copy-paste component:

import SwiftUI

struct TableViewConfigurator: UIViewControllerRepresentable {

    var configure: (UITableView) -> Void = { _ in }

    func makeUIViewController(context: UIViewControllerRepresentableContext<TableViewConfigurator>) -> UIViewController {

        UIViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<TableViewConfigurator>) {

        //let tableViews = uiViewController.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()

        let tableViews = UIApplication.nonModalTopViewController()?.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()

        for tableView in tableViews {
            self.configure(tableView)
        }
    }
}

2) Extension on UIApplication to find top view controller in hierarchy

extension UIApplication {

class var activeSceneRootViewController: UIViewController? {

    if #available(iOS 13.0, *) {
        for scene in UIApplication.shared.connectedScenes {
            if scene.activationState == .foregroundActive {
                return ((scene as? UIWindowScene)?.delegate as? UIWindowSceneDelegate)?.window??.rootViewController
            }
        }

    } else {
        // Fallback on earlier versions
        return UIApplication.shared.keyWindow?.rootViewController
    }

    return nil
}

class func nonModalTopViewController(controller: UIViewController? = UIApplication.activeSceneRootViewController) -> UIViewController? {

        print(controller ?? "nil")

        if let navigationController = controller as? UINavigationController {
            return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
        }
        if let tabController = controller as? UITabBarController {
            if let selected = tabController.selectedViewController {
                return nonModalTopViewController(controller: selected)
            }
        }
        if let presented = controller?.presentedViewController {
            let top = nonModalTopViewController(controller: presented)
            if top == presented { // just modal
                return controller
            } else {
                print("Top:", top ?? "nil")
                return top
            }
        }

        if let navigationController = controller?.children.first as? UINavigationController {

            return nonModalTopViewController(controller: navigationController.topViewController ?? navigationController.visibleViewController)
        }

        return controller
        }
}

3) Custom part - Here you implement your solution for UITableView behind List like scrolling:

Use it like modifier on any view in List in View

.background(TableViewConfigurator(configure: { tableView in
                if self.viewModel.statusChangeMessage != nil {
                    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
                        let lastIndexPath = IndexPath(row: tableView.numberOfRows(inSection: 0) - 1, section: 0)
                        tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: true)
                    }
                }
            }))
Michał Ziobro
  • 10,759
  • 11
  • 88
  • 143