В этом кратком руководстве мы реализуем пользовательскую поисковую панель 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…
Читайте также:
- Map, CompactMap и FlatMap в Swift
- Шесть принципов, которые помогут лучше писать модули для iOS-приложений
- Реализация ViewPager в Swift 5
Читайте нас в Telegram, VK и Дзен
Перевод статьи Kenan Begić: Build a Custom SwiftUI Search Bar Using LazyVStack With Sections and Section Index