
В этом руководстве мы создадим экран, позволяющий осуществлять одиночный и множественный выбор, используя Diffable data source и table view.
Настройка проекта
Мы будем использовать обычный проект Xcode на основе storyboard, поскольку мы работаем с UIKit.
Нам также понадобится таблица, для этого мы могли бы использовать традиционную установку, но поскольку мы используем современные методы работы с UIKit, в этот раз мы поступим немного иначе.
Примечание
Если вы найдёте статью интересной, то в этом канале я пишу об iOS-разработке и своем опыте.
Довольно печально, что нам все еще приходится предоставлять собственные типобезопасные переиспользуемые расширения для классов UITableView и UICollectionView. В любом случае, вот небольшой фрагмент, который мы будем использовать.
import UIKit extension UITableViewCell { static var reuseIdentifier: String { String(describing: self) } var reuseIdentifier: String { type(of: self).reuseIdentifier } } extension UITableView { func register<T: UITableViewCell>(_ type: T.Type) { register(T.self, forCellReuseIdentifier: T.reuseIdentifier) } func reuse<T: UITableViewCell>(_ type: T.Type, _ indexPath: IndexPath) -> T { dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as! T } }
Я также создал подкласс для UITableView, чтобы можно было настроить всё внутри функции initialize, которая нам понадобится в этом руководстве.
import UIKit open class TableView: UITableView { public init(style: UITableView.Style = .plain) { super.init(frame: .zero, style: style) initialize() } @available(*, unavailable) required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } open func initialize() { translatesAutoresizingMaskIntoConstraints = false allowsMultipleSelection = true } func layoutConstraints(in view: UIView) -> [NSLayoutConstraint] { [ topAnchor.constraint(equalTo: view.topAnchor), bottomAnchor.constraint(equalTo: view.bottomAnchor), leadingAnchor.constraint(equalTo: view.leadingAnchor), trailingAnchor.constraint(equalTo: view.trailingAnchor), ] } }
Мы собираемся создать экран настроек с областями одиночного и множественного выбора, поэтому хорошо иметь некоторые расширения, которые помогут нам управлять выбранными ячейками таблицы.
import UIKit public extension UITableView { func select(_ indexPaths: [IndexPath], animated: Bool = true, scrollPosition: UITableView.ScrollPosition = .none) { for indexPath in indexPaths { selectRow(at: indexPath, animated: animated, scrollPosition: scrollPosition) } } func deselect(_ indexPaths: [IndexPath], animated: Bool = true) { for indexPath in indexPaths { deselectRow(at: indexPath, animated: animated) } } func deselectAll(animated: Bool = true) { deselect(indexPathsForSelectedRows ?? [], animated: animated) } func deselectAllInSection(except indexPath: IndexPath) { let indexPathsToDeselect = (indexPathsForSelectedRows ?? []).filter { $0.section == indexPath.section && $0.row != indexPath.row } deselect(indexPathsToDeselect) } }
Теперь мы можем сосредоточиться на создании пользовательской ячейки. Мы будем использовать новый API конфигурации ячеек, но сначала нам нужна модель для нашего класса.
import Foundation protocol CustomCellModel { var text: String { get } var secondaryText: String? { get } } extension CustomCellModel { var secondaryText: String? { nil } }
Теперь мы можем использовать эту модель ячейки и настроить CustomCell, используя её свойства. Эта ячейка будет иметь два состояния. Если ячейка выбрана, мы будем отображать заполненный значок галочки, в противном случае просто пустой круг. Мы также обновим лейблы, используя значения абстрактной модели.
import UIKit class CustomCell: UITableViewCell { var model: CustomCellModel? override func updateConfiguration(using state: UICellConfigurationState) { super.updateConfiguration(using: state) var contentConfig = defaultContentConfiguration().updated(for: state) contentConfig.text = model?.text contentConfig.secondaryText = model?.secondaryText contentConfig.imageProperties.tintColor = .systemBlue contentConfig.image = UIImage(systemName: "circle") if state.isHighlighted || state.isSelected { contentConfig.image = UIImage(systemName: "checkmark.circle.fill") } contentConfiguration = contentConfig } }
Внутри класса ViewController мы можем легко настроить созданную таблицу. Поскольку мы используем storyboard, мы можем переопределить метод init(coder:), но если вы создаете контроллер программно, то можно просто создать свой собственный метод init.
Кстати, я также обернул этот контроллер внутри navigation controller, так что я отображаю пользовательский заголовок, используя large style по умолчанию, и вот недостающие части кода, которые мы должны написать:
import UIKit class ViewController: UIViewController { var tableView: TableView required init?(coder: NSCoder) { self.tableView = TableView(style: .insetGrouped) super.init(coder: coder) } override func loadView() { super.loadView() view.addSubview(tableView) NSLayoutConstraint.activate(tableView.layoutConstraints(in: view)) } override func viewDidLoad() { super.viewDidLoad() title = "Table view" navigationController?.navigationBar.prefersLargeTitles = true tableView.register(CustomCell.self) tableView.delegate = self } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) reload() } func reload() { /// coming soon... } } extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { /// coming soon... } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { /// coming soon... } }
Сейчас мы не реализовывали методы датасорса таблицы, так как мы собираемся использовать для этой цели diffable data source. Позвольте мне показать вам, как это работает.
Diffable data source
Я уже приводил один пример, содержащий diffable data source, но это был учебник по созданию современных коллекций. Diffable data source — это буквально датасорс, привязанный к view. В нашем случае общий класс UITableViewDiffableDataSource будет выступать в качестве источника данных четырех нашей таблицы. Эти источники данных хороши тем, что вы можете легко манипулировать секциями и ячейками внутри таблиц без необходимости работы с indexPath.
Итак, основная идея заключается в том, что мы хотим отобразить две секции, одна из которых содержит возможность одиночного выбора, а вторая — мультивыбора с некоторыми буквами из алфавита. Вот модели данных для элементов секций.
enum NumberOption: String, CaseIterable { case one case two case three } extension NumberOption: CustomCellModel { var text: String { rawValue } } enum LetterOption: String, CaseIterable { case a case b case c case d } extension LetterOption: CustomCellModel { var text: String { rawValue } }
Теперь мы сможем отобразить эти элементы внутри таблицы, если мы реализуем обычные методы источника данных. Но поскольку мы собираемся работать с diffabe data source, нам нужны некоторые дополнительные модели.
Чтобы устранить необходимость indexPaths, мы можем использовать перечисление Hashable для определения наших секций. У нас будет две секции, одна для цифр, другая для букв. Мы собираемся обернуть соответствующий тип внутри перечисления с типобезопасными значениями.
enum Section: Hashable { case numbers case letters } enum SectionItem: Hashable { case number(NumberOption) case letter(LetterOption) } struct SectionData { var key: Section var values: [SectionItem] }
Мы также введем SectionData, с ним будет проще вставлять необходимые секции и ячейки секций, используя источник данных.
final class DataSource: UITableViewDiffableDataSource<Section, SectionItem> { init(_ tableView: UITableView) { super.init(tableView: tableView) { tableView, indexPath, itemIdentifier in let cell = tableView.reuse(CustomCell.self, indexPath) cell.selectionStyle = .none switch itemIdentifier { case .number(let model): cell.model = model case .letter(let model): cell.model = model } return cell } } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { let id = sectionIdentifier(for: section) switch id { case .numbers: return "Pick a number" case .letters: return "Pick some letters" default: return nil } } func reload(_ data: [SectionData], animated: Bool = true) { var snapshot = snapshot() snapshot.deleteAllItems() for item in data { snapshot.appendSections([item.key]) snapshot.appendItems(item.values, toSection: item.key) } apply(snapshot, animatingDifferences: animated) } }
Мы можем предоставить пользовательский метод init для источника данных, где мы можем использовать cell provider для настройки наших ячеек с заданным идентификатором.
Как вы можете видеть, идентификатор — это перечисление SectionItem, которое мы создали несколько минут назад. Мы можем использовать переключатель (switch), чтобы получить обратно базовую модель, а поскольку эти модели соответствуют протоколу CustomCellModel, мы можем установить свойство cell.model. Также можно реализовать обычный метод titleForHeaderInSection, при этом мы можем переключать идентификатор секции и возвращать соответствующий лейбл для каждой секции.
Последний метод является вспомогательным, я использую его для релоада источника данных с заданными элементами секции.
import UIKit class ViewController: UIViewController { var tableView: TableView var dataSource: DataSource required init?(coder: NSCoder) { self.tableView = TableView(style: .insetGrouped) self.dataSource = DataSource(tableView) super.init(coder: coder) } override func loadView() { super.loadView() view.addSubview(tableView) NSLayoutConstraint.activate(tableView.layoutConstraints(in: view)) } override func viewDidLoad() { super.viewDidLoad() title = "Table view" navigationController?.navigationBar.prefersLargeTitles = true tableView.register(CustomCell.self) tableView.delegate = self } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) reload() } func reload() { dataSource.reload([ .init(key: .numbers, values: NumberOption.allCases.map { .number($0) }), .init(key: .letters, values: LetterOption.allCases.map { .letter($0) }), ]) } } extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // coming soon... } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { // coming soon... } }
Итак, внутри контроллера можно отобразить вид таблицы и показать обе секции. Даже ячейки по умолчанию выбираются, но я хотел бы показать вам, как создать общий подход для хранения и возврата выбранных значений. Конечно, мы могли бы использовать свойство indexPathsForSelectedRows, но у меня есть небольшой вспомогательный инструмент, который позволит использовать одиночный и множественный выбор для каждой секции.
struct SelectionOptions<T: Hashable> { var values: [T] var selectedValues: [T] var multipleSelection: Bool init(_ values: [T], selected: [T] = [], multiple: Bool = false) { self.values = values self.selectedValues = selected self.multipleSelection = multiple } mutating func toggle(_ value: T) { guard multipleSelection else { selectedValues = [value] return } if selectedValues.contains(value) { selectedValues = selectedValues.filter { $0 != value } } else { selectedValues.append(value) } } }
Используя общее расширение класса UITableViewDiffableDataSource, мы можем превратить значения выбранных элементов в indexPaths, что поможет нам сделать ячейки выбранными при загрузке view.
import UIKit extension UITableViewDiffableDataSource { func selectedIndexPaths<T: Hashable>(_ selection: SelectionOptions<T>, _ transform: (T) -> ItemIdentifierType) -> [IndexPath] { selection.values .filter { selection.selectedValues.contains($0) } .map { transform($0) } .compactMap { indexPath(for: $0) } } }
Осталось сделать только одно — обработать одиночный и множественный выбор с помощью методов делегата didSelectRowAt и didDeselectRowAt.
import UIKit class ViewController: UIViewController { var tableView: TableView var dataSource: DataSource var singleOptions = SelectionOptions<NumberOption>(NumberOption.allCases, selected: [.two]) var multipleOptions = SelectionOptions<LetterOption>(LetterOption.allCases, selected: [.a, .c], multiple: true) required init?(coder: NSCoder) { self.tableView = TableView(style: .insetGrouped) self.dataSource = DataSource(tableView) super.init(coder: coder) } override func loadView() { super.loadView() view.addSubview(tableView) NSLayoutConstraint.activate(tableView.layoutConstraints(in: view)) } override func viewDidLoad() { super.viewDidLoad() title = "Table view" navigationController?.navigationBar.prefersLargeTitles = true tableView.register(CustomCell.self) tableView.delegate = self } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) reload() } func reload() { dataSource.reload([ .init(key: .numbers, values: singleOptions.values.map { .number($0) }), .init(key: .letters, values: multipleOptions.values.map { .letter($0) }), ]) tableView.select(dataSource.selectedIndexPaths(singleOptions) { .number($0) }) tableView.select(dataSource.selectedIndexPaths(multipleOptions) { .letter($0) }) } } extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let sectionId = dataSource.sectionIdentifier(for: indexPath.section) else { return } switch sectionId { case .numbers: guard case let .number(model) = dataSource.itemIdentifier(for: indexPath) else { return } tableView.deselectAllInSection(except: indexPath) singleOptions.toggle(model) print(singleOptions.selectedValues) case .letters: guard case let .letter(model) = dataSource.itemIdentifier(for: indexPath) else { return } multipleOptions.toggle(model) print(multipleOptions.selectedValues) } } func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { guard let sectionId = dataSource.sectionIdentifier(for: indexPath.section) else { return } switch sectionId { case .numbers: tableView.select([indexPath]) case .letters: guard case let .letter(model) = dataSource.itemIdentifier(for: indexPath) else { return } multipleOptions.toggle(model) print(multipleOptions.selectedValues) } } }
Именно поэтому мы создали методы-помощники выбора в начале статьи. С помощью этой техники относительно легко реализовать секцию с одиночным или мультиселектом, но, конечно, эти вещи становятся еще более простыми, если вы умеете работать со SwiftUI.
В любом случае, я надеюсь, что это руководство поможет некоторым из вас, мне по-прежнему очень нравится UIKit, и я рад, что Apple добавляет в него новые возможности. Diffable data source — отличный способ настройки таблиц и коллекций. С помощью этих маленьких помощников вы можете легко создавать свои собственные настройки или другие экраны.
Больше историй, подходов к реализации и инструментов для iOS-разработчика можно найти в авторском канале об iOS-разработке.

ссылка на оригинал статьи https://habr.com/ru/post/660121/
Добавить комментарий