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:
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.
So lets create our project. Structure of our project will look like this.
First we need to add CountryCodes.json file. Structure of file looks like this.
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
Hi Kenan,
One problem I discovered with search is that if I enter a single character after scrolling, no content appears.