В этой статье мы поговорим про систему ячеек в 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.
Эта статья написана по мотивам одного из эпизодов нашего видеоподкаста “Охэхэнные истории”. Его можно посмотреть здесь.