В этом кратком руководстве мы реализуем пользовательскую поисковую панель 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.

Telegram-канал по теме
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться

На следующем этапе добавляем 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…

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


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

Мы в Telegram — подписывайтесь
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться
NOP::Recruiter RemoteУдалённые ИТ-вакансии с вилками
Подписаться
Предыдущая статьяОбзор 8 ключевых команд Npm и Yarn
Следующая статьяКак загружать файлы и изображения в приложении Django