Mc.Lover
Mc.Lover

Reputation: 4994

SwiftUI: Filtering List with Custom Search bar

I have created a custom search bar with TextField and trying to filter my List with it.

[![enter image description here][1]][1]

My list consists of A to Z sections. Now I have a problem with filtering both section and list while searching which is returning nil. Here is my code:

@State private var searchText = ""
    let lists = loadGlossary().keys.sorted()
    let glossary = loadGlossary()

var body: some View {
        
        VStack(spacing: -10) {
            
            //Navigation bar
            NavigationBarView(title: "Glossary", action: {}, titleColor: .primary, closeColor: .primary.opacity(0.5))
                .padding()
            
            //Search Bar
            TextField("Search", text: $searchText)
                .padding()
                .frame(height: 45)
                .background(.gray.opacity(0.1))
                .clipShape(RoundedRectangle(cornerRadius: 10))
                .padding()
                .overlay {
                    HStack {
                        Spacer()
                        Button {
                            searchText = ""
                        } label: {
                            Label("clear", systemImage: "xmark.circle.fill")
                                .foregroundColor(.gray)
                                .opacity(searchText.isEmpty ? 0 : 1)
                                .padding(30)
                        }
                        .labelStyle(.iconOnly)
                    }
                }
            
            //List
            List {
                ForEach(Array(wordDict.keys).sorted(by: <), id: \.self) { character in
                    ///Section
                    Section(header: Text("\(character)").font(Font.system(size:18, weight: .bold, design: .serif)).foregroundColor(.primary)) {
                        ///List
                        ForEach(wordDict[character] ?? [""], id: \.self) { word in
                            VStack(alignment: .leading, spacing: 5) {
                                Text(word)
                                    .font(.system(size: 17, weight: .bold, design: .serif))
                                Divider()
                                Text(getDescription(word))
                                    .foregroundColor(.secondary)
                            }
                        }
                        .padding()
                    }
                }
                .listRowSeparator(.hidden)
            }
            .listStyle(.plain)
            .padding(.top)
        }
    }

I would be grateful if you help to properly filter sections and items.

EDITED: Changes:

@State private var wordDict: [String:[String]] = [:]

    func loadData() -> [String:[String]] {
        let letters = Set(lists.compactMap( { $0.first } ))
        var dict: [String:[String]] = [:]
        for letter in letters {
            dict[String(letter)] = lists.filter( { $0.first == letter } ).sorted()
        }
        return dict
    }

and load the first data here:

.onAppear {
        wordDict = loadData()
    }

and for TextField I have added:

TextField("Search", text: $searchText)
                    .onChange(of: searchText) {
                        wordDict = getFilteredWords(query: $0)
                    }

But when I clear the textfield of backspace the List won't update anymore

Upvotes: 1

Views: 3659

Answers (2)

Asperi
Asperi

Reputation: 257729

Here is a possible way

  1. have a dynamic property of filtered items (it can be also in view model)
@State private var filteredWords: [String: String] = [:]
  1. iterate UI over that filtered items instead of loaded words directly
ForEach(Array(filteredWords.keys).sorted(by: <), id: \.self) { character in
    ///Section
    Section(header: Text("\(character)").font(Font.system(size:18, weight: .bold, design: .serif)).foregroundColor(.primary)) {
        ///List
        ForEach(filteredWords[character] ?? [""], id: \.self) { word in

  1. on initial load store loaded words and assign them to filtered
func load() {
   // ...
   self.wordDict = loadedData
   // assuming possible re-load apply existed filter
   self.filteredWords = applyFilter(data: loadedData, pattern: self.searchText)
}
  1. apply filter to originally loaded words on search field changes
TextField("Search", text: $searchText)
  .onChange(of: searchText) {
     self.filteredWords = applyFilter(data: self.wordDict, pattern: $0)
  }

or with view model and publishers to debounce (example)

Upvotes: 0

Throvn
Throvn

Reputation: 971

One approach would be that you could do the filtering like this:

  1. Create a function which returns the filtered list
  2. Render the results of the function to the screen.
  3. Rerender the results on change.

Here is the function for your specific need under the following assumptions:

  1. You didn't show us the structure of wordDict, so from your code snippet I assume that it is structured like this: [String: [String]]. (e.g. "A": ["apple", "Atari"])
  2. You only want to show the sections where the entries contain part of the searchText. If you want to have more advanced querying capabilities the method would be the same, you just have to make minor adjustments to the function below.
func getFilteredWords(query: String) -> [String: [String]] {
    wordDict.mapValues({ entry in
        entry.filter({ word in word.lowercased().contains(query.lowercased()) })
    }).filter({ entry in !entry.value.isEmpty})
}

What does the function do?

The function takes the searchText and compares all of the entries of your wordDict with it. If the wordDict entry includes the part of searchText somewhere, it is kept. Otherwise it is discarded and therefore later not rendered. The comparison is case insensitive. After that, all of the categories without any entries are also discarded which prevents that your search still shows all categories even if they don't contain any entries.

How to use it?

  1. Now you have do add the function above to your view struct.
  2. Replace the wordDict instances in your ListView with getFilteredWords(query: searchText).

Upvotes: 1

Related Questions