В этой статье мы поговорим про систему ячеек в 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
>
Таким образом, мы можем собрать всю матрицу, которую мы до этого видели со всеми левыми и правыми частями.
Подведем итог: что нужно сделать, чтобы создать ячейку со своим контентом?
-
Создать UIView;
-
Создать контент. Это будет нашей моделью для
UIView, который будет конфигурировать отображение с протоколомContainerContent; -
Наша модель должна быть
Equatable– это необходимо для подсчета диффа коллекции при изменении модели; -
И для нашей новой
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.
Эта статья написана по мотивам одного из эпизодов нашего видеоподкаста “Охэхэнные истории”. Его можно посмотреть здесь.