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