swiftuipickerswiftui-navigationlink

How to get pickerStyle(.navigationLink) to scroll automatically to the selected value


I wish to use a picker with pickerStyle(.navigationLink) to select a value.
The list of values is long and so I would expect the picker list on the new screen to scroll automatically to ensure the currently selected value is visible. However on the new screen the first page is displayed regardless and you need to scroll manually to reach the selected value if it lies outside it. Scrolling automatically occurs for pickerStyle(.menu) and pickerStyle(.wheel). Is there a way of making pickerStyle(.navigationLink) reflect the same behaviour?

Demonstation code below:

import SwiftUI

struct ContentView: View {
    @State private var selectedValue: Int = 56
    let values = 0..<101
    
    var body: some View {
        NavigationStack{
            Form {
                Picker("Pick a value", selection:$selectedValue) {
                    ForEach(values, id:\.self) {
                        Text($0, format: .number)
                    }
                }
                .pickerStyle(.navigationLink)
            }
        }
    }
}


Solution

  • You can create your own picker view that does this. Here is an example:

    struct NavigationLinkPicker<Label: View, Content: View, Options: RandomAccessCollection>: View where Options.Element: Hashable {
        typealias Selection = Options.Element
        let label: Label
        let options: Options
        @Binding var selection: Selection
        let content: (Selection) -> Content
        
        init(options: Options, selection: Binding<Selection>, @ViewBuilder content: @escaping (Selection) -> Content, @ViewBuilder label: () -> Label) {
            self.options = options
            self._selection = selection
            self.content = content
            self.label = label()
        }
        
        var body: some View {
            LabeledContent {
                NavigationLink {
                    NavigationDestination(
                        selection: $selection,
                        options: options,
                        content: content
                    )
                } label: {
                    HStack {
                        Spacer()
                        if let selectedOption = options.first(where: { $0 == selection }) {
                            content(selectedOption)
                        }
                    }
                }
            } label: {
                label
            }
        }
        
        struct NavigationDestination: View {
            @Environment(\.dismiss) var dismiss
            
            @Binding var selection: Selection
            @State var listSelection: Selection?
            let options: Options
            let content: (Selection) -> Content
            
            var body: some View {
                ScrollViewReader { proxy in
                    List(options, id: \.self, selection: $listSelection) { option in
                        HStack {
                            content(option)
                            Spacer()
                            if option == selection {
                                Image(systemName: "checkmark")
                                    .bold()
                                    .foregroundStyle(Color.accentColor)
                            }
                        }
                    }
                    .onChange(of: listSelection) { _, newValue in
                        guard let newValue else { return }
                        Task {
                            // dismiss must be called with a little delay,
                            // or else the navigation destination is pushed again after dismissing
                            // for some reason
                            dismiss()
                        }
                        selection = newValue
                    }
                    .onAppear {
                        proxy.scrollTo(selection)
                    }
                }
            }
        }
    }
    
    // Example usage:
    struct ContentView: View {
        @State private var selectedValue: Int = 56
        let values = 0..<101
        
        var body: some View {
            NavigationStack{
                Form {
                    NavigationLinkPicker(options: values, selection:$selectedValue) {
                        Text($0, format: .number)
                    } label: {
                        Text("Pick a value")
                    }
                }
            }
        }
    }
    

    The above implementation requires you to pass in a RandomAccessCollection to populate the picker with views. If you would like a usage more similar to the built-in picker, you can use this design instead, which depends on View Extractor.

    import ViewExtractor
    
    struct NavigationLinkPicker<Label: View, Content: View, Selection: Hashable>: View {
        let label: Label
        @Binding var selection: Selection
        let content: Content
        
        init(selection: Binding<Selection>, @ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
            self._selection = selection
            self.content = content()
            self.label = label()
        }
        
        var body: some View {
            Extract(content) { options in
                LabeledContent {
                    NavigationLink {
                        NavigationDestination(selection: $selection, options: options)
                    } label: {
                        HStack {
                            Spacer()
                            options.first(where: { $0.id(as: Selection.self) == selection })
                        }
                    }
                } label: {
                    label
                }
            }
        }
        
        struct NavigationDestination: View {
            @Environment(\.dismiss) var dismiss
            
            @Binding var selection: Selection
            @State var listSelection: Selection?
            let options: Views
            
            var body: some View {
                ScrollViewReader { proxy in
                    List(options, selection: $listSelection) { option in
                        if let id = option.id(as: Selection.self) {
                            HStack {
                                option
                                Spacer()
                                if id == selection {
                                    Image(systemName: "checkmark")
                                        .bold()
                                        .foregroundStyle(Color.accentColor)
                                }
                            }
                            .tag(id)
                            .id(id)
                        }
                    }
                    .onChange(of: listSelection) { _, newValue in
                        guard let newValue else { return }
                        Task {
                            dismiss()
                        }
                        selection = newValue
                    }
                    .onAppear {
                        proxy.scrollTo(selection)
                    }
                }
            }
        }
    }
    

    Note that in this design I am checking the ids of the views, not tag, so keep that in mind when using it. Reading tag is something internal to SwiftUI so we can't do that in our code.

    Example usage:

    struct ContentView: View {
        @State private var selectedValue: Int = 56
        let values = 0..<101
        
        var body: some View {
            NavigationStack{
                Form {
                    NavigationLinkPicker(selection:$selectedValue) {
                        ForEach(values, id: \.self) {
                            Text($0, format: .number)
                        }
                    } label: {
                        Text("Pick a value")
                    }
                }
            }
        }
    }