Типичный день мобильного разработчика: получил json, распарсил, отрисовал на UI ячейки, PROFIT.
Как элегантно колдовать с ячейками без изобретения велосипеда мы рассказали в одном из эпизодов «Охэхэнных историй», а потом сделали из него статью.
В сегодняшней программе ячеек в 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
}
Пойдем к ячейке. У нашей ячейки своя модель, и она называется ContainerItem
.
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)
}
}
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.
Эта статья написана по мотивам одного из эпизодов нашего видеоподкаста “Охэхэнные истории”. Его можно посмотреть здесь.
Для правой части всё немного проще. Мы создаем отдельную UIView
и помещаем туда UIImageView
со статичной картинкой шеврона. И из-за этого у нас модель остается пустой.