Реализуйте DropDown за несколько минут, используя этот небольшой лайфхак

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

Год назад мне пришлось реализовать раскрывающийся список в проекте, над которым я работал. Я понял, что у UIKit нет такого компонента и мне нужно реализовать его самому. Я искал какой-нибудь краткий учебник, но все они либо требовали сторонних библиотек, либо были слишком длинными, и мне было лень их читать. Затем я нашел быстрое решение, используя UIStackView, и теперь хочу поделиться им с вами.

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

Какие элементы пользовательского интерфейса вам нужны?

Чтобы реализовать раскрывающийся список, нам нужна кнопка и табличное представление. И чтобы использовать как можно меньше ограничений, нам нужен UIStackView. Нам нужно поместить и кнопку, и таблицу в стек и переключить свойство таблицы isHidden при нажатии кнопки. Вот и все!

Создание раскрывающегося списка

Чтобы создать раскрывающийся список, нам нужно 2 класса. Один для кнопки раскрывающегося списка и один для ячейки UITableView. В этом туториале я не буду создавать ячейку со сложным UI, потому что не хочу, чтобы эта статья стала лонгридом. Итак, давайте создадим простую ячейку с одним UILabel внутри.

class DropdownTableViewCell: UITableViewCell {

    static let reuseIdentifier = "DropdownTableViewCell"
    
    let label: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    func setup() {
        contentView.addSubview(label)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
            label.topAnchor.constraint(equalTo: contentView.topAnchor),
            label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
    
    func set(_ text: String) {
        label.text = text
    }
 
}

Мы будем использовать метод set(_ text: String) для установки текста в метку при создании ячеек.

Когда ячейка настроена, мы можем сосредоточиться на создании раскрывающегося списка.

Создайте новый класс, назовите его DropDownButton и сделайте его подклассом UIView:

class DropDownButton: UIView {

    init() {
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

}

В приведенном выше фрагменте кода я уже добавил инициализаторы, но пока мы оставим их пустыми.

Теперь давайте добавим подвиды, которые нам нужны для раскрывающегося меню. Как я уже говорил ранее, нам нужны кнопка, таблица и стек.

class DropDownButton: UIView {

    let button: UIButton = {
        let button = UIButton()
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setTitle("Select a reason", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.layer.borderWidth = 1.2
        button.layer.borderColor = UIColor.gray.cgColor
        return button
    }()
    
    let stackView: UIStackView = {
        let stack = UIStackView()
        stack.translatesAutoresizingMaskIntoConstraints = false
        stack.distribution = .fill
        stack.alignment = .fill
        stack.spacing = 4
        stack.axis = .vertical
        return stack
    }()
     
    let tableView: UITableView = {
        let table = UITableView()
        table.translatesAutoresizingMaskIntoConstraints = false
        table.isHidden = true
        table.layer.borderWidth = 1.2
        table.layer.borderColor = UIColor.gray.cgColor
        table.register(DropdownTableViewCell.self, forCellReuseIdentifier: DropdownTableViewCell.reuseIdentifier)
        return table
    }()

    init() {
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

}

Установите для свойств стека alignment и distribution значение .fill, а для оси значение .vertical. Кроме того, я добавил границу для кнопки и таблицы, чтобы мы могли видеть их границы. Если хотите, можете добавить немного теней и углового радиуса. tableView по умолчанию скрыт, так как в исходном состоянии видна только кнопка.

Теперь пришло время добавить все эти подпредставления в представление. Создайте метод setup() и добавьте в него stackView в качестве подпредставления. Затем заполните его button и tableView. Мы также должны добавить несколько ограничений. Давайте сделаем края stackView равными краям супервизора, а высоту кнопки 40.

func setup() {
    addSubview(stackView)
    stackView.addArrangedSubview(button)
    stackView.addArrangedSubview(tableView)
    tableView.delegate = self
    tableView.dataSource = self
    NSLayoutConstraint.activate([
        stackView.topAnchor.constraint(equalTo:topAnchor),
        stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
        stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
        stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
        button.heightAnchor.constraint(equalToConstant: 40)
    ])
}

Чтобы наш tableView работал, нам нужно настроить источник данных и делегировать. Для этого создайте пустой массив String, который мы собираемся использовать в качестве источника данных для нашей таблицы.

var dataSource: [String] = [] 

Теперь давайте настроим расширения для UITableViewDataSource и UITableViewDelegate. Из UITableViewDelegate нам нужны методы: didSelectRowAt для обработки касаний и heightForRowAt для указания высоты ячеек. Я хочу, чтобы высота ячейки была равна высоте кнопки, поэтому внутри метода heightForRowAt я верну 40. Метод didSelectRowAt пока оставим пустым.

extension DropDownButton: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        dataSource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: DropdownTableViewCell.reuseIdentifier, for: indexPath) as? DropdownTableViewCell else { return UITableViewCell() }
        cell.set(dataSource[indexPath.row])
        return cell
    }

}

extension DropDownButton: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
     
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 40
    }
}

В методе setup() настройте tableView dataSource и delegate:

tableView.delegate = self
tableView.dataSource = self

Когда dataSource и delegate готовы, мы можем настроить действие кнопки. Нажав на кнопку, мы хотим показать или скрыть наш tableView.

 @objc private func buttonTapped() {
     tableView.isHidden.toggle()
 }

Внутри метода setup() прикрепите это действие к файлу button.

button.addTarget(self, action: #selector(buttonTapped), for: .primaryActionTriggered)

Если вы добавите DropDownButton на экран и нажмете на него, вы не увидите… ничего! Но почему?

Причина в том, что высота tableView равна 0, а dataSource пуста. Чтобы исправить первую проблему, нам нужно указать высоту tableView. Если мы хотим, чтобы выпадающий список работал плавно, мы не можем установить фиксированную высоту tableView. Нам нужно установить высоту в зависимости от контента. Наше содержимое tableView — это наш массив dataSource. Допустим, мы не хотим, чтобы tableView отображало одновременно более 4 ячеек.

Чтобы изменить высоту tableView во время выполнения, нам нужна ссылка на его ограничение высоты. Создайте свойство NSLayoutConstaint и назовите его tableViewHeight. Это должно быть необязательным, потому что мы инициализируем его только после инициализации представления.

var tableViewHeight: NSLayoutConstraint?

В методе setup() нам нужно создать ограничение высоты tableView:

tableViewHeight = tableView.heightAnchor.constraint(equalToConstant: 0)
tableViewHeight?.isActive = true

А теперь мы собираемся создать функцию, в которой мы будем обновлять высоту tableView в зависимости от имеющегося у нас контента.

func updateTableDataSource() {
    if dataSource.count >= 4 {
        tableViewHeight?.constant = 4 * 40
    } else {
        tableViewHeight?.constant = CGFloat(dataSource.count * 40)
    }
    tableView.reloadData()
}

Если в нашем массиве менее 4 вариантов, высота tableView равна размеру ячеек, которые у нас есть. Если у нас больше, то высота tableView равна высоте 4 ячеек.

Лучшее время для вызова этого метода — dataSource изменение. Для этого нам нужно настроить наблюдателя didSet.

 var dataSource: [String] = [] {
     didSet {
         updateTableDataSource()
     }
 }

Теперь вы можете предоставить раскрывающийся список с некоторым контентом и посмотреть, как он работает!

Но мы по-прежнему упускаем одну важную особенность. Если мы нажмем на раскрывающийся список, ничего не произойдет! И мы хотим, чтобы произошли две вещи: обновить заголовок кнопки в соответствии с выбранной опцией и отправить какое-то событие, чтобы сообщить другим, что пользователь выбрал конкретную опцию.

Чтобы обработать нажатие на ячейку с параметрами am, нам нужно использовать метод didSelectRowAt. Нам нужно обновить заголовок кнопки и скрыть представление таблицы.

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    button.setTitle(dataSource[indexPath.row], for: .normal)
    tableView.isHidden = true
}

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

protocol DropDownButtonDelegate: AnyObject {
    func didSelect(_ index: Int)
}

Теперь нам просто нужно добавить к кнопке необязательное свойство делегата и вызвать его метод в функции didSelectRowAt.

var delegate: DropDownButtonDelegate?
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    button.setTitle(dataSource[indexPath.row], for: .normal)
    delegate?.didSelect(indexPath.row)
    tableView.isHidden = true
}

Теперь наша кнопка работает хорошо и выглядит так:

Но он по-прежнему не подлежит повторному использованию и имеет одну проблему. Итак, давайте сделаем его более многоразовым и настраиваемым и исправим эту проблему.

Проблема с нижним ограничением

Давайте добавим нашу кнопку на экран и добавим другой вид прямо под ней. Для второго представления важно обеспечить верхнее ограничение для нижней части раскрывающегося списка. Вы можете сделать это следующим образом:

class ViewController: UIViewController {

    var dropdown = DropDownButton()
    let label = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }
    
    func setup() {
        view.addSubview(label)
        view.addSubview(dropdown)
        
        
        dropdown.dataSource = ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6"]
        label.text = "Some random text"
        
        label.translatesAutoresizingMaskIntoConstraints = false
        dropdown.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            dropdown.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 32),
            dropdown.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            dropdown.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            label.topAnchor.constraint(equalTo: dropdown.bottomAnchor, constant: 16),
            label.centerXAnchor.constraint(equalTo: dropdown.centerXAnchor)
        ])
    }
}

Запустите приложение и попробуйте развернуть раскрывающийся список, вы увидите следующий результат:

Как видите, метка перемещается вместе с выпадающей таблицей. Это происходит потому, что внутри DropDownButton у нас есть stackView с ограничениями на все грани его супервизора. Когда стек расширяется, DropDownButton тоже расширяется. Нам нужно, чтобы метка закрывалась таблицей, для этого нам нужно прикрепить ее к нижнему якорю кнопки внутри DropDownButton.

Добавьте этот геттер в DropDownButton:

var buttonBottomConstraint: NSLayoutYAxisAnchor {
    button.bottomAnchor
}

Это свойство обеспечивает доступ к нижнему ограничению кнопки. Чтобы добиться желаемого поведения, нам нужно прикрепить нашу метку к этому якорю.

 NSLayoutConstraint.activate([
     dropdown.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 32),
     dropdown.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
     dropdown.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
     label.topAnchor.constraint(equalTo: dropdown.buttonBottomConstraint, constant: 16),
     label.centerXAnchor.constraint(equalTo: dropdown.centerXAnchor)
 ])

Запустите приложение сейчас, и все должно быть хорошо:

Теперь давайте сделаем его более настраиваемым и многоразовым!

Настройка

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

Начнем с самой легкой части. Из названия. Мы настроим его через свойство с наблюдателем. Создайте свойство title типа String внутри класса раскрывающегося списка. И в обозревателе didSet обновите заголовок кнопки.

var title: String = "" {
    didSet {
        button.setTitle(title, for: .normal)
    }
}

Теперь вы можете обновить заголовок одной строкой кода извне.

Пришло время перейти к настраиваемой высоте ячейки и количеству видимых ячеек. Начнем с высоты ячейки. Мы также хотим, чтобы высота наших ячеек была равна высоте кнопки. Чтобы настроить высоту кнопки, нам нужна ссылка на ее ограничение. И снова нам нужно свойство с наблюдателем для значения высоты.

var buttonHeightConstraint: NSLayoutConstraint?
var buttonHeight: CGFloat = 40 {
    didSet {
  
    }
}

Тело didSet пока пусто, мы добавим его позже. Теперь нам нужно инициализировать buttonHeightConstraint в методе setup():

    NSLayoutConstraint.activate([
        stackView.topAnchor.constraint(equalTo:topAnchor),
        stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
        stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
        stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
    ])
    buttonHeightConstraint = button.heightAnchor.constraint(equalToConstant: buttonHeight)
    buttonHeightConstraint?.isActive = true

Круто, теперь мы можем изменить его во время выполнения, если мы хотим обновить высоту кнопки. Но нам также нужно обновить высоту ячеек. Для этого нам нужно вернуть buttonHeight из метода heightForRowAt.

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return buttonHeight
}

А чтобы обновить высоту кнопки, нам нужно обновить свойство ограничения constant. Нам нужно сделать это внутри наблюдателя didSet.

var buttonHeight: CGFloat = 40 {
    didSet {
          buttonHeightConstraint?.constant = buttonHeight
          tableView.reloadData()
    }
}

Мы делаем tableView.reloadData() и здесь, потому что мы хотим, чтобы tableView пересчитывал высоту ячеек.

Теперь нам нужно настроить максимальное количество видимых ячеек. Нам нужно добавить свойство с наблюдателем для него. Внутри тела наблюдателя нам нужно вызвать updateTableDataSource(), в этой функции мы вычисляем высоту tableView.

var maxVisibleCellsAmount: Int = 4 {
    didSet {
        updateTableDataSource()
    }
}

Чтобы это заработало, нам нужно внести некоторые изменения в updateTableDataSource(). Нам просто нужно заменить жестко заданные значения высоты и количества ячеек нашими новыми свойствами.

func updateTableDataSource() {
    if dataSource.count >= maxVisibleCellsAmount {
        tableViewHeight?.constant = CGFloat(maxVisibleCellsAmount) * buttonHeight
    } else {
        tableViewHeight?.constant = CGFloat(dataSource.count) * buttonHeight
    }
    tableView.reloadData()
}

Нам также нужно вызвать эту функцию внутри наблюдателя buttonHeight didSet, потому что изменение высоты ячейки отражает высоту tableView.

var buttonHeight: CGFloat = 40 {
    didSet {
        buttonHeightConstraint?.constant = buttonHeight
        updateTableDataSource()
    }
}

Заключение

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

Исходный код можно найти здесь.

Check out my other articles about iOS Development

https://medium.com/@artem.khalilyaev

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Хлопайте за историю и подписывайтесь на автора 👉
  • 📰 Смотрите больше контента в публикации Level Up Coding
  • 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
  • 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу