Работа с Diffable data source и table views с использованием UIKit

от автора

Руководство по использованию dffable data source
Руководство по использованию dffable data source

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

Канал об iOS-разработке
Канал об iOS-разработке


ссылка на оригинал статьи https://habr.com/ru/post/660121/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *