swiftuipicker

How do I implement a Picker to pick one or more items from an array of local file URLs


I'm using macOS 14 and Xcode 15 and am new at Swift/SwiftUI.

I have been trying to implement a Picker for the last few days that will allow more than one item to picked in the same manner that Finder does, that is, the user can cancel, pick one item or, using the Shift key, pick two or more contiguous items.

Before I attempted to use "onChange" it kind of worked, the data displayed by Text worked but were not updated and Picker would not let me pick more than one item.

I don't know how to implement onChange as it has been recently updated. My web searches only return code for IOS or code that is old.

I get several errors that I don't understand with my attempted 'onChange' modification. zzzcode.ai doesn't produce code that compiles no matter how I phrase my question.

Note that when a URL is displayed I only display the last component.

I'd like to resolve the 'onChange' issue first as it might fix some of the errors.

import SwiftUI

extension URL: Identifiable {
    public var id: Self { self }
}

struct PickTransectView: View {
    @Binding  var datList: [URL] // local file URLs w/.dat
    @Binding  var selectedURLs: [URL]? // subset of datList

    // only display last URL component in this view
    var body: some View {
        VStack {
            Text("Selected Transects:")
            if let selectedURLs = selectedURLs { // if nil take from datList
                if(selectedURLs.count > 1) {
                    Text("\(selectedURLs.first!.lastPathComponent) - \(selectedURLs.last!.lastPathComponent)")
                } else {
                    Text("\(selectedURLs.last!.lastPathComponent)")
                }
            } else if let firstURL = datList.first, let lastURL = datList.last {
                Text("\(firstURL.lastPathComponent) - \(lastURL.lastPathComponent)")
            }

            // expected return 1 or several contiguous URLs
            Picker(selection: $selectedURLs, label: Text("Detailed List")) {
                ForEach(datList, id: \.self) { url in
                    Text(url.lastPathComponent)
                }
            }
            .pickerStyle(MenuPickerStyle())
            .onChange(of: selectedURLs) { _ in // it fails here !
                // Update Text on change of selectedURLs
                if selectedURLs.count > 1 { // first & last URLs in selection
                    Text("\(selectedURLs.first!.lastPathComponent) - \(selectedURLs.last!.lastPathComponent)")
                } else if let firstURL = selectedURLs.first {
                    Text("\(firstURL.lastPathComponent)")
                }
            } 
        }
    }
}

Solution

  • When the user taps the Button in my TransectPickerView a pop-up sheet is presented that does the work.

    struct TransectPickerView: View {
        @Binding  var selectedURLs: [URL]
        @Binding  var datList: [URL] // local file URLs w/.dat
        
        @State private var isPresentingSelection: Bool = false
        
        var body: some View {
            VStack {
                Button(action: {
                    isPresentingSelection = true
                }) {
                    Text("Select Transects")
                }
                .buttonStyle(ButtonStyleControls())
                .padding()
                
            } // POPUP
            .sheet(isPresented: $isPresentingSelection) {
                FileURLSelectionSheetView(
                    selectedURLs: $selectedURLs,
                    datList: $datList)
            }
        }
    }
    

    The code below displays the last component of an array of local file URLs and allows the user to select (and deselect) a file. If a second file is selected an array of the files between and including the two selected files is built. Buttons also allow the user to: select all the files, Accept the selections which closes the View, or close the View without saving.

    struct FileURLSelectionSheetView: View {
        @Binding var selectedURLs: [URL] // to be populated
        @Binding var datList: [URL] // local file URLs w/.dat
        @Environment(\.presentationMode) var presentationMode
        // local
        @State var urlIndexSet = Set<Int>()  // effanescent
        @State var firstURL: URL?
        @State var showSelectAll = true
        
        var body: some View {
            VStack {
                Text(" Select Transects ")
                    .font(.title)
                    .padding()
                ScrollView {
                    ForEach(datList.indices, id: \.self) { index in   // present all URLs
                        let url = datList[index] // user selection
                        Text("\(url.lastPathComponent)")
                            .background(urlIndexSet.contains(index) ? Color.yellow.opacity(0.4) : Color.clear)
                            .onTapGesture {  // tap logic tree starts here ...
                                if urlIndexSet.isEmpty {
                                    urlIndexSet.insert(index) // first endpoint
                                } else if urlIndexSet.count == 1 { // endpoint
                                    if urlIndexSet.contains(index) {  // edit
                                        urlIndexSet.removeAll()
                                    } else {  // 2nd end point, create list
                                        if index > urlIndexSet.first! { // ascending
                                            for item in urlIndexSet.first!...index {
                                                urlIndexSet.insert(item)
                                            }
                                        } else {  // descending
                                            for item in index...urlIndexSet.first!{
                                                urlIndexSet.insert(item)
                                            }
                                        }
                                    }
                                } // onTapGesture
                            } // 1st ForEach
                    }  // ScrollView
                    .frame(maxHeight: .infinity)
                    
                    Button(action: {
                        if showSelectAll {
                            for item in 0..<datList.count {
                                urlIndexSet.insert(item)
                            }
                            showSelectAll = false
                        } else {
                            urlIndexSet.removeAll()
                            showSelectAll = true
                        }
                    }) {
                        if showSelectAll {
                            Text("Select All")
                                .padding(.top, 10)
                        } else {
                            Text("Reset All")
                                .padding(.top, 10)
                        }
                        
                    }
                    .buttonStyle(ButtonStyleControls())
                    
                    
                    HStack() {
                        Button(action: {  // This appears to present correctly
                            selectedURLs.removeAll() //
                            presentationMode.wrappedValue.dismiss()
                        }) {
                            Text("Cancel")
                        }
                        .buttonStyle(ButtonStyleControls())
                        
                        Spacer()
                        // =============  populaate selectedURLs ===========
                        Button(action: {
                            
                            selectedURLs.removeAll()
                            let indexList = urlIndexSet.sorted()
                            for item in indexList
                            {
                                selectedURLs.append(datList[item])
                            }
                            presentationMode.wrappedValue.dismiss()
                        }) {
                            Text("Accept")
                        }
                        .buttonStyle(ButtonStyleControls())
                        .disabled(urlIndexSet.isEmpty)
                        .opacity(urlIndexSet.isEmpty ? 0.5 : 1.0) // Mute if disabled
                        
                    } // HStack
                    .padding()
                    Divider()
                    Text("\(urlIndexSet.count) selected")
                        .font(.title2)
                    
                }  // VStack
                .frame(maxWidth: .infinity, maxHeight: .infinity) // Adjust the height of the VStack
            }
        }  // end popup view struct
    }
    

    I've successfully tested this for up to 28 URLs. I expect it to scroll if it would otherwise exceed the height of the monitor.