Создаем ячейки в iOS

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этой статье мы поговорим про систему ячеек в iOS. Обсудим, какие ячейки бывают в iOS, посмотрим на ячейки в дизайн-системе hh.ru и их реализацию в коде и попробуем собрать свою ячейку.

Собственно, ячейки

Ячейки можно построить по-разному. Первый способ – табличный. Ячейки строятся друг за другом вертикально и могут иметь секции. Как правило, между ячейками не может быть отступов.

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

Мы отказались от табличной верстки и используем только коллекции. Все наши новые экраны уже строятся только на использовании UICollectionView.

При сборе экрана с ячейками может возникнуть ряд проблем. Одна из них – когда дизайнер собирает новый экран, приходит и говорит: «Вот, я сделал ячейку, добавил немного элементов и расположил их немного иначе». Из-за этого создается много однотипного кода, ячейки каждый раз создаются заново, и мы не можем их переиспользовать. Из-за этого значительно снижается и скорость сборки экрана.

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

Отступы в дизайн-системе перечислены от 2 до 32 и имеют следующий набор констант:

Эти отступы используются в макетах. Дизайнеры используют фигма-плагин для того, чтобы на макетах можно было легко включить и отключить отступы. Разработчикам же наглядно можно увидеть, какой отступ между элементами.

В коде всё это великолепие представляет собой обычный протокол. Он реализует все стандартные числовые типы в Swift. Сам же протокол обладает расширением со статичными переменными. Когда мы собираем layout, то есть делаем верстку, мы используем эти отступы через точку. Мы можем указать инсеты: xs, m и прочие.

Spacing.swift
public protocol Spacing {
    init(_ value: Double)
}

extension Int: Spacing { }
extension UInt: Spacing { }
extension Float: Spacing { }
extension Double: Spacing { }
extension CGFloat: Spacing { }

extension Spacing {

    /// L
    ///
    /// Value: 24.0.
    public static var l: Self { Self(24.0) }

    /// M
    ///
    /// Value: 16.0.
    public static var m: Self { Self(16.0) }

    /// MPlus
    ///
    /// Value: 20.0.
    public static var mplus: Self { Self(20.0) }

    /// S
    ///
    /// Value: 12.0.
    public static var s: Self { Self(12.0) }

    /// XL
    ///
    /// Value: 32.0.
    public static var xl: Self { Self(32.0) }

    /// XS
    ///
    /// Value: 8.0.
    public static var xs: Self { Self(8.0) }

    /// XXS
    ///
    /// Value: 4.0.
    public static var xxs: Self { Self(4.0) }

    /// XXXS
    ///
    /// Value: 2.0.
    public static var xxxs: Self { Self(2.0) }
}

Вернемся к ячейке. Сама ячейка в дизайн-системе не совсем простая. Давайте взглянем. У нас есть заголовок, есть chevron. Наша ячейка представляет собой левую и правую части. С левой части расположен контент, у которого есть отступ слева – m, а справа она прибита к нулю. То есть левая часть прибита к правой.

У правой же части есть отступ слева – xs, а справа – m. Левая часть не просто так прибита к правой: а потому что правая часть может отсутствовать.

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

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

Соединив все комбинации левой и правой части, мы можем получить сводную матрицу. Она составляет из себя набор компонентов в Figma, дизайнеры эти компоненты переиспользуют на макетах, а разработчикам – это удобно и наглядно. Мы у себя в коде собрали всю эту матрицу. Дальше мы посмотрим, как она устроена в коде.

Ячейки в дизайн-системе позволяют дизайнерам не дублировать элементы в Figma, а переиспользовать их. В свою очередь мы не дублируем однотипные ячейки в коде как разработчики.

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

Диаграмма ячейки-контейнера

Контейнер-ячейка – это ContainerCell, который наследуется от UICollectionViewCell. У нас она одна на весь проект.

У этой самый ячейки внутри есть ContainerContentView. Это обычный протокол, который должна реализовать любая UIView. То есть в ячейку мы можем поместить любую UIView, которая реализует данный протокол.

Этот протокол также связан с другим протоколом – ContainerContent. ContainerContent – это, в свою очередь, модель для UIView, который ее конфигурирует. ContainerContentView связан с ContainerContent через associated типы, то есть у ContainerContentView есть associatedtype Content. В свою очередь, у ContainerContent есть associatedtype View. Таким образом, мы создаем связку один к одному.

Пойдем дальше. Также есть модель ячейки контейнера ContainerItem. Модель ячейки контейнера знает о своей ячейке и какой в нее помещают контент. Это потому что у ContainerContent есть associatedtype View. Также сама ячейка хранит в себе эту модель и помогает нам как-то обработать действие от ячейки и настроить ее.

Время кода

Теперь, когда мы познакомились с общей диаграммой, можно углубиться в код. Как уже упоминали ранее, у нас есть ContainerCell, где живет дженерный тип ContainerView с протоколом ContainerContentView. Данный протокол должна реализовать любая UIView, которая будет помещена в ячейку.

public final class ContainerCell<ContentView: ContainerContentView>: 
	UICollectionViewCell

У ContainerContentView есть associatedtype Content, он также отдает свой размер и может обновляться контентом. Размеры UIView необходимы для того, чтобы мы могли знать размер самой ячейки.

public protocol ContainerContentView: UIView {
    associatedtype Content: ContainerContent
 
    static func size(
        fitting width: CGFloat,
        content: Content
    ) -> CGSize
 
    func update(with content: Content)
}

ContainerContent — это пустой протокол, единственное его требование, чтобы его реализация также соответствовала протоколу Equatable, и у него есть associatedtype View. Благодаря этой связке у нас получается связь один к одному: у одного ContainerContentView может быть только один собственный Content, а у одного Content может быть только свой View. Проще говоря, у каждой View есть только своя модель (Content).

public protocol ContainerContent: Equatable {
    associatedtype View: ContainerContentView where View.Content == Self
}

Пойдем к ячейке. У нашей ячейки своя модель, и она называется ConteinerItem.

public struct ContainerItem<Content: ContainerContent>:
    CollectionViewItem,
    DidSelectHandlerContainable,
    WillDisplayHandlerContainable,
    DidEndDisplayingHandlerContainable {
 
    public typealias Cell = ContainerCell<Content.View>
 
    public let differenceIdentifier: AnyHashable
 
    public let accessibilityIdentifier: String?
    public let content: Content
    public let insets: EdgeInsets
    public let isEnabled: Bool
    public let isSelectedBackgroundNeeded: Bool
 
    @ForceEquatable
    public private(set) var didSelectHandler: (() -> Void)?
 
    @ForceEquatable
    public private(set) var didLongPressHandler: (() -> Void)?
 
    @ForceEquatable
    public private(set) var willDisplayHandler: (() -> Void)?
 
    @ForceEquatable
    public private(set) var didEndDisplayingHandler: (() -> Void)?
}

Она содержит в себе некоторый перечень полей. Первое – она знает о своей ячейке (ContainerCell) и какая UIView (Content.View) там размещена.

Модель ячейки также содержит Content от этой View. Это необходимо для конфигурации View, которая помещена внутри ячейки.

Также в модели ячейки живут такие поля, как:

  • differencеID – он необходим нам для подсчета диффа в коллекции;

  • accessibilityIdentifier – нужен для UI-тестов;

  • insets – позволяет проставить отступы внутри самой ячейки;

  • isEnabled – включает/выключает ячейку. В выключенном состоянии не обрабатываются нажатия и сама ячейка становится полупрозрачной с альфой 0.5;

  • isSelectedBackgroundNeeded – включает/выключает выделение фона при нажатии на ячейку;

И последнее: здесь расположены все необходимые обработчики. Это значит, что мы можем обработать нажатие, долгое нажатие, показ и скрытие ячейки.

Generic контейнеры очень удобные. Они позволяют не дублировать логику ячеек. Мы используем одну ячейку, в которую можно поместить любую UIView. Эта UIView также может быть контейнером с дженерным типом. То есть мы можем установить контейнер в контейнер.

И здесь кроется главный плюс: мы можем сверстать UIView, и использовать хоть в ячейке, хоть просто на UIViewController. Сама UIView не имеет понятия, где она будет использоваться.

Твоя личная ячейка

Сейчас мы соберем свою ячейку. Для начала стоит вспомнить ячейку, которую мы рассматривали выше: Title + Chevron. Соберем ячейку на данном примере, заодно узнаем, как наши ячейки из дизайн-системы устроены в коде.

Создадим общий контейнер – это обычный UIView, в котором мы можем пометить левую и правую части. Также добавим сюда разделитель.

public final class LeftRightContainerView<
	Left: LeftContentView, 
	Right: RightContentView
>: UIView {
 
    public typealias Content = LeftRightContainer<
  		Left.Content, 
  		Right.Content
  	>
 
    private var content: Content?
 
    private let leftView = Left()
    private let rightView = Right()
    private let separatorView = SeparatorView()
    ...
}

Появились протоколы LetfContentView и RightContentView, и также протоколы контента для них LeftContent и RightContent соотвественно. Они схожи с ContainerContentView и ContainerContent, единственное для LetfContentView и RightContentView добавился метод для определения минимальной ширины:

static func minWidth(content: Content) -> CGFloat

Создадим контент для LeftRightContainerView (наш ContainerContent) – он тоже дженерный, в нее помещается контент для левой и правой частей. Также указывается тип разделителя.

public struct LeftRightContainer<
	Left: LeftContent, 
	Right: RightContent
>: ContainerContent {
 
    public typealias View = LeftRightContainerView<Left.View, Right.View>
 
    public let left: Left
    public let right: Right
    public let separator: SeparatorType?
    ...
}

В наш ContainerItem мы помещаем LeftRightContainer с левой и правой частями. Чтобы это было проще использовать в коде, обернем в typealias и назовем LeftRightItem.

public typealias LeftRightItem<
    Left: LeftContent,
    Right: RightContent
> = ContainerItem<LeftRightContainer<Left, Right>>

Теперь же давайте соберем левую часть. Левая часть представляет собой обычный заголовок. То есть это обычный UIlabel, настроим для него Layout, расставим все необходимые отступы – ничего необычного.

public class LeftTitleView: UIView {
 
    private enum Layout {
        enum Title {
            static let insets = UIEdgeInsets(
							top: .m, 
							left: .m, 
							bottom: .m, 
							right: .zero
						)
        
 
        static let minWidth: CGFloat = 100.0
    }
 
    private let titleLabel = UILabel()
    ...
}

Для этой UIView мы создадим контент и добавим в него все необходимые поля – это accessabilityIdentifier, заголовок (который мы хотим проставить), количество линий и стиль.

public struct LeftTitle: LeftContent {
 
    public typealias View = LeftTitleView
 
    public let accessibilityIdentifier: String?
    public let title: String
    public let titleLineCount: UInt
    public let style: LeftTitleStyle
    ...
}

Теперь же нам остается только для UIView реализовать протокол и обновить контент. То есть наша модель с контентом обновляет наш titleLabel.

extension LeftTitleView: LeftContentView {
 
    ...
 
    public func update(with content: LeftTitle) {
        titleLabel.accessibilityIdentifier = content.accessibilityIdentifier ?? content.title
        titleLabel.attributedText = content.styledTitle
        titleLabel.numberOfLines = Int(content.titleLineCount)
    }
}

Для правой части всё немного проще. Мы создаем отдельную UIView и помещаем туда UIImageView со статичной картинкой шеврона. И из-за этого у нас модель остается пустой.

public final class RightChevronView: UIView {
 
    private enum Layout {
        enum ChevronImageView {
            static let size = CGSize(width: 8, height: 14)
            static let leftInset: CGFloat = .xs
            static let rightInset: CGFloat = .m
        }
    }
 
    private let chevronImageView = UIImageView()
    ...
}
 
// MARK: - RightContentView
 
extension RightChevronView: RightContentView {
 
    ...
 
    public func update(with content: RightChevron) { }
}

Код модели контента:

public struct RightChevron: RightContent {
 
    public typealias View = RightChevronView
 
    public init() { }
}

Теперь помещаем LeftTitleItem и RightChevron в контейнер LeftRightItem (typealias объявленный раннее), который мы только что создали. Для удобства мы обернем это также в typealias и называем его TitleChevronItem.

public typealias TitleChevronItem = LeftRightItem<
	LeftTitle, 
	RightChevron
>

Таким образом, мы можем собрать всю матрицу, которую мы до этого видели со всеми левыми и правыми частями.

Подведем итог: что нужно сделать, чтобы создать ячейку со своим контентом?

  1. Создать UIView;

  2. Создать контент. Это будет нашей моделью для UIView, который будет конфигурировать отображение с протоколом ContainerContent;

  3. Наша модель должна быть Equatable – это необходимо для подсчета диффа коллекции при изменении модели;

  4. И для нашей новой UIView остается только реализовать протокол ConteinerContentView, который уже возвращает необходимую высоту и обновляет отображаемый контент;

Демо

Пришел дизайнер и говорит: «Я собрал новый экран. Нам нужно сделать его завтра». Окей, без проблем. Дизайнер собрал экран из готовых ячеек.

Первая у нас ячейка с картинкой, заголовком и подзаголовком (Image+Title+Subtitle). У второй ячейки заголовок с левой части (Title), а с правой – детальное описание и шеврон (Detail+Chevron). Вторая секция, секция меню, состоит из иконки и заголовка (Icon+Title). С правой стороны у нас пусто. И третья секция – это секция PUSH-уведомлений. Она представляет из себя с левой части Checkbox с заголовком (Checkbox + Title), а с правой – пустоту.

Попробуем собрать это в коде. В нашем проекте мы используем архитектурный паттерн MVVM. У нас, как правило, есть бизнес-состояние и UI-состояние. StateMapper конвертирует бизнес-состояние (в данном примере его нет) в UI-состояние (CellListViewState).

final class CellListStateMapper { }
 
extension CellListStateMapper {
 
    func mapToState() -> CellListViewState {
        ...
    }
}

Итак, у нас есть CellListViewState, и нам нужно вернуть состояние коллекции. Состояние коллекции, CollectionViewState, требует вернуть набор каких-то секций. Давайте создадим эти секции и декларативно их опишем.

Первая секция у нас главная, назовем функцию создания первой секции makeMainSection() и вернем секцию.

final class CellListStateMapper {
 
    private func makeMainSection() -> CollectionViewSection {
        ...
    }
}
 
extension CellListStateMapper {
 
    func mapToState() -> CellListViewState {
        CellListViewState(
            collection: CollectionViewState(sections: {
                ...
            })
        )
    }
}

Но что такое CollectionViewSection?

CollectionViewSection представляет из себя структуру, описывающая секцию коллекции. Она имеет ряд поле:

  • differenceIdentifier – необходим для дифа коллекции;

  • header и footer – структуры, описывающие заголовок и футер секции соответственно;

  • items – структура, описывающая ячейку коллекции;

  • layout – структура, описывающая отображение секции. Это отступы между ячейками, секции и настройка колонок. Колонки могут быть адаптивные (кол-во будет зависеть от ширина экрана) и фиксированные.

  • extras – это свойство, куда можно положить любую структуру или класс, которые реализуют протокол CollectionViewSectionExtras. Она влияет только на расчет диффа.

public struct CollectionViewSection {
 
    public let differenceIdentifier: AnyHashable
 
    public let header: CollectionViewDiffableSupplementaryElement?
    public let items: [CollectionViewDiffableItem]
    public let footer: CollectionViewDiffableSupplementaryElement?
    public let layout: CollectionViewSectionLayout
    public let extras: CollectionViewSectionExtras?
    ...
}

Теперь собираем. Для первой секции нам понадобятся только items. Как нам собрать из дизайна? Первую ячейку мы видим – она называется Image+Title+Subtitle. И справа у нас Chervon. Так и напишем.

private func makeMainSection() -> CollectionViewSection {
    CollectionViewSection {
        ImageTitleSubtitleChevronItem(
           	...
        )
    }
}

Здесь нам надо будет настроить только левую и правую части. Слева пропишем LeftImageTitleSubtitle и сконфигурируем, как нам нужно. Проставим картинки. У нас есть Default-аватарка, она будет круглой. Заголовок и подзаголовок возьмем из дизайна. В правой части находится шеврон, поэтому так и пишем: RightChevron(). Модель пустая, нам необходимо только ее инициализировать. Также у нас здесь есть разделитель. Разделитель равен и слева, и справа – 16.

private func makeMainSection() -> CollectionViewSection {
    CollectionViewSection {
        ImageTitleSubtitleChevronItem(
            left: LeftImageTitleSubtitle(
                image: UI.Image.Common.avatarDefaultIcon,
                imageStyle: .circular,
                title: "Имя пользователя",
                subtitle: "Настройки профиля"
            ),
            right: RightChevron(),
            separator: .leftRight16
        )
    }
}

Вторая ячейка представляет из себя страну поиска. Это у нас TitleDetailChevron. Так же настроим правые и левые части. Заголовок слева, пишем: LeftTitle и настраиваем. Справа Detail + Chevron. Указываем заголовок из макета.

Сепаратора у нас нет, поэтому здесь можем смело поставить none.

private func makeMainSection() -> CollectionViewSection {
    CollectionViewSection {
        ...
        TitleDetailChevronItem(
            left: LeftTitle(title: "Страна поиска"),
            right: RightDetailChevron(text: "Россия"),
            separator: .none
        )
    }
}

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

Во второй секции у нас уже имеется ячейка с иконкой и заголовком. Здесь мы проставляем левый контент, правого у нас нет – опускаем.

Еще нам нужно настроить layout. Он позволяет настроить минимальные отступы между ячейками, между колонками и расставить некоторые инсеты. Как раз инсеты нам и необходимы. Как видно по дизайну, сверху и снизу у нас будет отступ L, а слева и справа – 0.

private func makeMenuSection() -> CollectionViewSection {
    CollectionViewSection(
        items: {
            IconTitleItem(
                left: LeftIconTitle(
                    icon: UI.Image.Common.notification,
                    iconTintColor: Colors.blue,
                    title: "Уведомления"
                ),
                separator: .none
            )
 
            IconTitleItem(
                left: LeftIconTitle(
                    icon: UI.Image.Common.article,
                    iconTintColor: Colors.blue,
                    title: "Статьи"
                ),
                separator: .none
            )
        },
        layout: CollectionViewSectionLayout(
            insets: UIEdgeInsets(
                top: .l, 
                left: .zero, 
                bottom: .l, 
                right: .zero
            )
        )
    )
}

Остается только собрать последнюю секцию PUSH-уведомлений. Здесь у нас заголовок (header) типа Large. Наши заголовки также вынесены в дизайн-систему, что позволяет нам легко их переиспользовать в коде.

private func makePushNotificationSection() -> CollectionViewSection {
    CollectionViewSection(
        header: SectionLargeHeader(
            content: SectionLargeHeaderContent(title: "PUSH-уведомления")
        ),
        items: {
            CheckboxItem(
                left: LeftCheckbox(
                    title: "Просмотры вашего резюме", 
                    isOn: false
                ),
                separator: .left56Right16
            )
 
            CheckboxItem(
                left: LeftCheckbox(
                    title: "Приглашения на вакансию", 
                    isOn: true
                ),
                separator: .left56Right16
            )
        }
    )
}

Остается только прописать секции и отдать нашему состоянию.

func mapToState() -> CellListViewState {
    CellListViewState(
        collection: CollectionViewState(sections: {
            makeMainSection()
            makeMenuSection()
            makePushNotificationSection()
        })
    )
}

Теперь попробуем собрать и посмотреть, что у нас получилось. Итак, у нас появилась ячейка с именем пользователя, страной поиска и PUSH-уведомлениями.

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

ImageTitleSubtitleChevronItem(
    left: LeftImageTitleSubtitle(
        image: .local(UI.Images.Common.avatarDefaultIcon),
        imageStyle: .circular,
        title: "Имя пользователя",
        subtitle: "Настройки профиля"
    ),
    right: RightChevron(),
    separator: .leftRight16,
    didSelectHandler: {
        // Handle
    }
)

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

Заключение

Теперь дизайнеры могут быстро собирать экраны из готовых компонент-ячеек. Мы ушли от дублирования в коде и можем легко собрать новую ячейку. Собирать экраны стало намного быстрее.

Вся эта история несовместима с xib и Storyboard. Для кого-то это может быть плюсом, для кого-то – минусом, но для нас это скорее преимущество. Поскольку мы не используем xib и Storyboard, нам будет немного легче перейти на SwiftUI.


Эта статья написана по мотивам одного из эпизодов нашего видеоподкаста “Охэхэнные истории”. Его можно посмотреть здесь.

Источник: https://habr.com/ru/company/hh/blog/574206/


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

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

Меня зовут Стас Гаранжа, я выпускник курса «Python-разработчик» в Яндекс.Практикуме. Я хочу помочь начинающим разработчикам, которые приступили к изучению Django Rest Framework (DRF) и хо...
Всем привет. Если вы когда-либо работали с универсальными списками в Битрикс24, то, наверное, в курсе, что страница детального просмотра элемента полностью идентична странице редак...
Предыстория Когда-то у меня возникла необходимость проверять наличие неотправленных сообщений в «1С-Битрикс: Управление сайтом» (далее Битрикс) и получать уведомления об этом. Пробле...
1С Битрикс: Управление сайтом (БУС) - CMS №1 в России по версии портала “Рейтинг Рунета” за 2018 год. На рынке c 2003 года. За это время БУС не стоял на месте, обрастал новой функциональностью...
Как обновить ядро 1С-Битрикс без единой секунды простоя и с гарантией работоспособности платформы? Если вы не можете закрыть сайт на техобслуживание, и не хотите экстренно разворачивать сайт из бэкапа...