Codementor Events

SwiftUI custom search bar, LazyVStack with sections and section index

Published Mar 24, 2022
SwiftUI custom search bar, LazyVStack with sections and section index

In this short tutorial, we will implement our custom search bar, LazyVStack that contains data with sections and section index.

First things first, lets explain what we want to achieve here. Imagine that we need to implement searchable/filtered list of values, where user needs to pick one of them. In this example we are implementing country code picker, where our list contains list of countries with their respective country code. User pick one item from list, and then goes back to enter phone number.

Our finished product will look like this:
1_HlF0Gv2IsdQKXjt8oaFe1w.png

As we can see, we have list of items with sections. Every section has index on right side of screen and when we click on index value, our list is scrolled to chosen section. Also when we enter text in search bar, our list of items with sections is filtered.
1_QleHVssnPaYL30eqoSYfLQ.png

So lets create our project. Structure of our project will look like this.
1_FDJIStFD27XK7x462MHQLw.png
First we need to add CountryCodes.json file. Structure of file looks like this.
1_26OMxiCPQoUQPeJUMyRqCA.png

So lets create our CountryModel.swift that will hold our data from json file.

struct CountryModel: Codable, Identifiable {
   var id = UUID()
   var name: String?
   var dial_code: String?
   var code: String?
   init(name: String, dial_code: String, code: String){
      self.name = name
      self.dial_code = dial_code
      self.code = code
   }
   enum CodingKeys: String, CodingKey {
      case name
      case dial_code
      case code
   }
}

Next we will implement CountryCodeViewModel.swift, that is responsible for fetching data from json file, and for creating sections.

class CountryCodeViewModel: ObservableObject {
    //MARK: vars
    var countryCodes = [CountryModel]()
    let sections = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"]
    @Published var countryCodeNumber = ""
    @Published var country = ""
    @Published var code = ""
    
    //MARK: init
    init() {
        loadCountryCodes()
    }
    
    //MARK: functions
    func loadCountryCodes(){
        let countryCodesPath = Bundle.main.path(forResource: "CountryCodes", ofType: "json")!
        
        do {
            let fileCountryCodes = try? String(contentsOfFile: countryCodesPath).data(using: .utf8)!
            let decoder = JSONDecoder()
            countryCodes = try decoder.decode([CountryModel].self, from: fileCountryCodes!)
        }
        catch {
          print (error)
        }
    }
}

As we can see in previous code section, when we initialize our view model we initialize array or countries from json file.

Next we will add CountryItemView.swift what will represent our clickable list item.

import Foundation
import SwiftUI

struct CountryItemView: View {
    //MARK: vars
    let countryModel: CountryModel
    var selected: Bool = false
    
    //MARK: init
    init(countryModel: CountryModel, selected: Bool) {
        self.countryModel = countryModel
        self.selected = selected
    }
    
    //MARK: body
    var body: some View {
        VStack {
            HStack {
                Text("\(countryModel.name)\("(")\(countryModel.dial_code)\(")")")
                    .font(Font.system(size: 20))
                    .foregroundColor(Color.textColorPrimary)
                    .fontWeight(.light)
                    .padding(.top, 7)
                    .padding(.bottom, 7)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
                Image(systemName: "checkmark")
                    .resizable()
                    .frame(width: 17, height: 13, alignment: .center)
                    .foregroundColor(Color.colorBackground)
                    .opacity(selected ? 1 : 0)
            }
            Divider().background(Color.gray)
        }
        .padding(.leading, 19)
        .padding(.trailing, 19)
    }
}

So, last thing that is left to implement is CountryCodeView.swift, that combines all previous code. So lets break body of our view into multiple parts.

//MARK: body
var body: some View {
    VStack(alignment: .leading, spacing: 0) {
        searchBar
            .padding(.leading, 15)
            .padding(.trailing, 15)
            .background(Color.barTintColor)
        Divider().background(Color.gray)
            .padding(.top, 10)
        ZStack {
            countriesListView
            lettersListView
        }
    }
    .navigationBarBackButtonHidden(true)
}

Search bar is custom created.

//MARK: searchBar
var searchBar: some View {
    HStack {
        Image(systemName: "magnifyingglass").foregroundColor(.gray)
        TextField("Search", text: $countryName)
            .font(Font.system(size: 21))
    }
    .padding(7)
    .background(Color.searchBarColor)
    .cornerRadius(50)
}

Next important component is countriesListView.

//MARK: countriesListView
var countriesListView: some View {
    ScrollView {
        ScrollViewReader { scrollProxy in
            LazyVStack(pinnedViews:[.sectionHeaders]) {
                ForEach(countryCodeViewModel.sections.filter{ self.searchForSection($0)}, id: \.self) { letter in
                    Section(header: CountrySectionHeaderView(text: letter).frame(width: nil, height: 35, alignment: .leading)) {
                        ForEach(countryCodeViewModel.countryCodes.filter{ (countryModel) -> Bool in countryModel.name.prefix(1) == letter && self.searchForCountry(countryModel.name) }) { countryModel in
                            CountryItemView(countryModel: countryModel, selected: (countryModel.code == countryCodeViewModel.code) ? true : false)
                                .contentShape(Rectangle())
                                .onTapGesture {
                                    selectCountryCode(selectedCountry: countryModel)
                                }
                        }
                    }
                }
            }
            .onChange(of: scrollTarget) { target in
                if let target = target {
                    scrollTarget = nil
                    withAnimation {
                        scrollProxy.scrollTo(target, anchor: .topLeading)
                    }
                }
            }
        }
    }
}

Lets stop right here and explain our most important part of app. First of all, we added LazyVStack inside of ScrollView and ScrollViewReader with sections. Every section that is child view inside of LazyVStack, is pinned with:

pinnedViews:[.sectionHeaders]

Next we iterate through each section or letter in array of sections and add section as CountrySectionHeaderView.

struct CountrySectionHeaderView: View {
    //MARK: vars
    let text: String
    
    //MARK: body
    var body: some View {
        Rectangle()
            .fill(Color.backgroundColor)
            .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
            .overlay(
                Text(text)
                    .font(Font.system(size: 21))
                    .foregroundColor(Color.textColorPrimary)
                    .fontWeight(.semibold)
                    .padding(.leading, 17)
                    .padding(.trailing, 17)
                    .padding(.top, 15)
                    .padding(.bottom, 15)
                    .frame(maxWidth: nil, maxHeight: nil, alignment: .leading),
                alignment: .leading
            )
    }
}

First filter that we have used is section filter, which filter our section array by calling searchForSection function and depending on first letter of search bar text that is entered by user.

self.searchForSection($0)
//MARK: functions
private func searchForCountry(_ txt: String) -> Bool {
    return (txt.lowercased(with: .current).hasPrefix(countryName.lowercased(with: .current)) || countryName.isEmpty)
}

private func searchForSection(_ txt: String) -> Bool {
    return (txt.prefix(1).lowercased(with: .current).hasPrefix(countryName.prefix(1).lowercased(with: .current)) || countryName.isEmpty)
}

Next, while we iterate through sections we filter our country names in our view model by taking first letter of each country and compare it to letter of current section. This filter is adding filtered countries to desired section.

(countryModel) -> Bool in countryModel.name.prefix(1) == letter

At the same time, we use another filter for our search bar input that filter countries in our view model by calling searchForCountry function. This function is filtering countries depending on string that user has entered in search bar.

self.searchForCountry(countryModel.name)
//MARK: functions
private func searchForCountry(_ txt: String) -> Bool {
    return (txt.lowercased(with: .current).hasPrefix(countryName.lowercased(with: .current)) || countryName.isEmpty)
}

private func searchForSection(_ txt: String) -> Bool {
    return (txt.prefix(1).lowercased(with: .current).hasPrefix(countryName.prefix(1).lowercased(with: .current)) || countryName.isEmpty)
}

scrollProxy from ScrollViewReader allows us to scroll to top of each section of our clicked index list item.

scrollProxy.scrollTo(target, anchor: .topLeading)

We are doing this by listening value of our state variable scrollTarget. This variable is changing in lettersListView index list, every time we click letter button item from that list.

Button(action: {
   if countryCodeViewModel.countryCodes.first(where: {    $0.name.prefix(1) == letter }) != nil {
         scrollTarget = letter
   }
}

And last part is lettersListView.

//MARK: lettersListView
var lettersListView: some View {
    VStack {
        ForEach(countryCodeViewModel.sections, id: \.self) { letter in
            HStack {
                Spacer()
                Button(action: {
                    if countryCodeViewModel.countryCodes.first(where: { $0.name.prefix(1) == letter }) != nil {                        
                        scrollTarget = letter
                    }
                }, label: {
                    Text(letter)
                        .font(.system(size: 12))
                        .padding(.trailing, 7)
                        .foregroundColor(Color.textColorPrimary)
                })
            }
        }
    }
}

That is it, hope you have enjoyed this tutorial and stay tuned for more content.
The code is available in the GitHub repository:
https://github.com/kenagt/CustomSearchbarIOS

Discover and read more posts from Kenan Begić
get started
post commentsBe the first to share your opinion
ANIL ERRABELLI
a year ago

Hi Kenan,

One problem I discovered with search is that if I enter a single character after scrolling, no content appears.

Show more replies