Работа с 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/


Интересные статьи

Интересные статьи

В данной статье мы рассмотрим систему аутентификации пользователей и внешних систем в личном кабинете через сервер аутентификации Blitz Identity Provider. Согласно требованиям проекта, который мы...
Недавно я завершил прохождение специализации Data Science на платформе Яндекс.Практикум и хочу написать, текст, который был бы полезен мне самому 8 месяцев назад, то есть...
Мои ленты в социальных сетях выглядят довольно специфично: большинство «друзей» — либо айтишники, либо менеджеры В2В-сферы. Так вот, последние 2 недели я не вижу посты от любимых и рабочих сообще...
Кто из нас не ронял продакшн, не обнаруживал, что база побилась, а бэкапов нет, не обваливал работу системы перед демо коммитом «ну сейчас рюшечку поправлю только, она же точно ничего не поломает...
В прошлом году команда Program Manager-ов Plesk получила возможность пройти онлайн-курс GoPractice! Simulator от Олега Якубенкова, и теперь мы хотим поделиться своими впечатлениями. Кто мы? P...