dormiz1
dormiz1

Reputation: 93

SwiftUI: How to sort/filter @Published array for presentation purposes only

(Using XCode 13.4 / iOS 15)

I have a @Published array that is presented to the user in a List with the ability to make changes to the items. The source of the array is from a file that is updated once the user makes changes to the items.

The sample code below shows how I'm currently doing that.

My question is: I want the List that is presented to the user to be sorted. How can I do that without changing the underlying @Published array, while still enabling the user to make changes to the items?

struct Contact: Identifiable {
    let id = UUID()
    var firstName: String
    var lastName: String
    var dateOfBirth: Date
}

class ViewModel: ObservableObject {
    @Published var contacts: [Contact]
    
    static let shared = ViewModel()
    private init() {
        let formatter = DateFormatter()
        formatter.dateFormat = "dd-MMM-yyyy"
        
        let sampleData = [
            Contact(firstName: "James", lastName: "Smith", dateOfBirth: formatter.date(from: "2-Jan-1965")!),
            Contact(firstName: "John", lastName: "Johnson", dateOfBirth: formatter.date(from: "7-Feb-1982")!),
            Contact(firstName: "David", lastName: "Brown", dateOfBirth: formatter.date(from: "22-Mar-1971")!),
            Contact(firstName: "Michael", lastName: "Jones", dateOfBirth: formatter.date(from: "15-Aug-2002")!),
            Contact(firstName: "Robert", lastName: "Davis", dateOfBirth: formatter.date(from: "11-Nov-2010")!),
        ]
        
        contacts = sampleData
    }
    
}

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel.shared
    
    var body: some View {
        List {
            ForEach($viewModel.contacts) {$contact in
                HStack {
                    Text("\(contact.firstName) \(contact.lastName)")
                    DatePicker("", selection: $contact.dateOfBirth, displayedComponents: [.date])
                }
            }
        }
    }
}

Upvotes: 2

Views: 1012

Answers (2)

dormiz1
dormiz1

Reputation: 93

After quite a bit of experimenting and investigations I came up with a solution. The trick was to transform the Binding Array of the ObservedObject (e.g. Binding<[Contact]>) into Array of Binding (e.g. [Binding<Contact>]). This enabled presenting the data in different ways while having the Binding bounded to the original items.

It boils down to one computed property that transforms and return the data to be presented:

private var filteredContacts: [Binding<Contact>] {
    
    /// `$viewModel.contacts` is of type `Binding<[Contact]>`. We are creating an array
    /// of `Binding<Contact>` from the source `viewModel.contact`
    let contactBindingsArr = $viewModel.contacts.map {$contact in return $contact}
    
    // Sort/Filter the array of Bindings
    let res = contactBindingsArr.filter{contact in ![2,4,6].contains(contact.id) }.sorted{$0.id > $1.id}
    
    return res
}

Here is the code of a fully working example. There are three List views: first is showing the original data, second is showing filtered data and third is showing grouped data. It also shows behavior when adding a new item.

import SwiftUI

struct Contact: Identifiable {
    let id: Int // = UUID()
    var firstName: String
    var lastName: String
    var dateOfBirth: Date
}

class ViewModel: ObservableObject {
    @Published var contacts: [Contact]
    
    static let shared = ViewModel()
    private init() {
        let formatter = DateFormatter()
        formatter.dateFormat = "dd-MMM-yyyy"
        
        let sampleData = [
            Contact(id: 1, firstName: "James", lastName: "Smith", dateOfBirth: formatter.date(from: "2-Jan-1965")!),
            Contact(id: 5, firstName: "John", lastName: "Johnson", dateOfBirth: formatter.date(from: "7-Feb-2022")!),
            Contact(id: 2, firstName: "David", lastName: "Brown", dateOfBirth: formatter.date(from: "22-Mar-1965")!),
            Contact(id: 4, firstName: "Michael", lastName: "Jones", dateOfBirth: formatter.date(from: "15-Aug-2022")!),
            Contact(id: 3, firstName: "Robert", lastName: "Davis", dateOfBirth: formatter.date(from: "11-Nov-1965")!),
        ]
        
        contacts = sampleData
    }
    
}

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel.shared
    
    private var groupedContacts:[GroupedContacts] {
        
        /// `$viewModel.contacts` is of type `Binding<[Contact]>`. We are creating an array
        /// of `Binding<Contact>` from the source `viewModel.contact`
        let contactBindingsArr = $viewModel.contacts.map {$contact in return $contact}
        
        // Group contacts by year of the dateOfBirth
        let dict = Dictionary(grouping: contactBindingsArr, by: {$item in item.dateOfBirth.formatted(.dateTime.year())})
        var arr = dict.keys.map { GroupedContacts(key: $0, contacts: dict[$0]!.sorted{$0.id > $1.id}) }
        arr.sort { $0.key > $1.key }

        return arr
    }
    
    private var filteredContacts: [Binding<Contact>] {
        
        /// `$viewModel.contacts` is of type `Binding<[Contact]>`. We are creating an array
        /// of `Binding<Contact>` from the source `viewModel.contact`
        let contactBindingsArr = $viewModel.contacts.map {$contact in return $contact}
        
        // Sort/Filter the array of Bindings
        let res = contactBindingsArr.filter{contact in ![2,4,6].contains(contact.id) }.sorted{$0.id > $1.id}
        
        return res
    }
    
    var body: some View {
        NavigationView {
            VStack {
                
                // This list shows the original data
                List {
                    ForEach(viewModel.contacts) {contact in
                        HStack {
                            Text("\(contact.id). \(contact.firstName) \(contact.lastName)")
                            Spacer()
                            Text(contact.dateOfBirth.formatted(.dateTime.year().month().day()))
                        }
                    }
                }
                .border(Color.red)
                
                // This list shows sorted/filtered data
                List {
                    ForEach(filteredContacts) {$contact in
                        HStack {
                            Text("\(contact.id). \(contact.firstName) \(contact.lastName)")
                            DatePicker("", selection: $contact.dateOfBirth, displayedComponents: [.date])
                        }
                    }
                }
                .border(Color.blue)
                
                // This list shows grouped data
                List {
                    ForEach(groupedContacts, id: \.key) {group in
                        Section(content: {
                            ForEach(group.contacts) {$contact in
                                HStack {
                                    Text("\(contact.id). \(contact.firstName) \(contact.lastName)")
                                    DatePicker("", selection: $contact.dateOfBirth, displayedComponents: [.date])
                                }
                            }
                        }, header: {Text("Year Of \(group.key)")})
                        
                    }
                }
                .border(Color.orange)
                
            }
            .listStyle(.plain)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button("\(Image(systemName: "plus"))") {
                        let formatter = DateFormatter()
                        formatter.dateFormat = "dd-MMM-yyyy"
                        let maxId = (viewModel.contacts.max {a, b in a.id < b.id})?.id ?? 0
                        let newContact = Contact(id: maxId + 1, firstName: "John", lastName: "Doe", dateOfBirth: formatter.date(from: "18-May-2022")!)
                        viewModel.contacts.append(newContact)
                    }
                }
            }
        }
    }
    
    private struct GroupedContacts {
        let key:String
        let contacts:[Binding<Contact>]
    }
}

Upvotes: 2

Elixir12
Elixir12

Reputation: 33

You can do it just as follows:

var filteredList: [ListItems] {
    modelData.listItems.filter { entry in
        (!showFavoritesOnly || entry.isFavorite)
        
    }
}

var body: some View {
    NavigationView {
        List {
            Toggle(isOn: $showFavoritesOnly) {
                Text("Favorites only")
            }
            ForEach(filteredList) { currentItem in
                NavigationLink{
                    ItemDetail(item: currentItem)
                } label: {
                    ItemRow(landmark: currentItem)
                }
            }
        }
        .navigationTitle("Components")
    }
}

Here I have a List that is being filtered to "show favorites only," when the user determines to do so. To achieve this, the model of my ListItems has a boolean, isFavorite, that is flipped when the user chooses to make the item a favorite. I then filter here (if the toggle switch is on) to check which items are favorites.

For you, you should add a boolean to Contact() that you can look at to specifically filter out anything that is true or false.

Upvotes: 0

Related Questions