Как создать пользовательскую поисковую панель SwiftUI с LazyVStack

В этом кратком руководстве мы реализуем пользовательскую поисковую панель LazyVStack, содержащую данные с разделами и их индексами. Идея заняться решением этой задачи появилась в связи с одним вопросом на StackOverflow

Наша цель  —  реализовать список значений с возможностью поиска или фильтрации, предоставляющий пользователю выбор одного из них. 

В данной статье мы создадим инструмент для выбора кода страны. Пользователю предлагается список стран и соответствующие им коды. Он выбирает один элемент из списка и возвращается назад, чтобы ввести номер телефона. 

Окончательный результат выглядит так:

Перед нами список элементов с разделами, у каждого из которых есть индекс с правой стороны экрана. При нажатии на значение индекса список прокручивается до выбранного раздела. Кроме того, при вводе текста в поисковую панель происходит фильтрация списка элементов с разделами. 

Приступаем к созданию проекта со следующей структурой: 

Сначала добавляем файл CountryCodes.json. Его структура: 

Затем создаем CountryModel.swift для хранения данных из файла JSON:

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
}
}

Далее реализуем CountryCodeViewModel.swift, ответственного за извлечение данных из файлов JSON и создание разделов. 

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)
}
}
}

Как видно из предыдущего фрагмента кода, при инициализации модели представления мы инициализируем массив или страны из файла JSON.

На следующем этапе добавляем CountryItemView.swift, который будет представлять элемент списка, активируемый нажатием. 

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)
}
}

Осталось только реализовать CountryCodeView.swift, который объединяет весь предыдущий код. Разделим тело представления на несколько частей: 

//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)
}

Поисковая строка создается по настройкам пользователя, как показано ниже: 

//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)
}

Следующий важный компонент  —  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)
}
}
}
}
}
}

Здесь мы более подробно рассмотрим самую важную часть приложения. 

Сначала добавляем LazyVStack в ScrollView и ScrollViewReader с разделами, каждый из которых является дочерним представлением LazyVStack и закрепляется с помощью pinnedViews:[.sectionHeaders].

Далее проводим итерацию каждого раздела или буквы в массиве разделов и добавляем его как 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
)
}
}

struct SectionHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
CountrySectionHeaderView(text: "A").environment(\.colorScheme, .light).previewDevice(PreviewDevice(rawValue: "iPhone SE (2nd generation)"))
}
}
}

Первым мы применили фильтр раздела. Он фильтрует массив раздела путем вызова функции searchForSection и в зависимости от первой буквы текста поисковой панели, которую вводит пользователь: 

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)
}

Далее в процессе итерации разделов мы фильтруем названия стран в модели представления, учитывая первую букву каждой страны и сравнивая ее с буквой в текущем разделе. Этот фильтр добавляет отфильтрованные страны в нужный раздел. 

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

В то же время мы используем другой фильтр для ввода поисковой панели. Он фильтрует страны в модели представления, вызывая функцию searchForCountry. Эта функция проводит выборку стран в зависимости от строки, которую пользователь вводит в поисковую панель. 

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 из ScrollViewReader позволяет прокручивать к началу каждого раздела элемента индексного списка, на который мы нажимаем. 

scrollProxy.scrollTo(target, anchor: .topLeading)

Мы делаем это за счет прослушивания значения переменной состояния scrollTarget. Эта переменная изменяется в индексном списке lettersListView при каждом нажатии элемента Button с буквой. 

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

Последний этап  —  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)
})
}
}
}
}

Надеюсь, материал оказался полезным. 

Код доступен в репозитории GitHub по ссылке :

GitHub — kenagt/CustomSearchbarIOS: Custom search bar for IOS with LazyVStack, filtering and…

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Kenan Begić: Build a Custom SwiftUI Search Bar Using LazyVStack With Sections and Section Index

Предыдущая статьяОбзор 8 ключевых команд Npm и Yarn
Следующая статьяКак загружать файлы и изображения в приложении Django