Нативный backend-driven UI в iOS приложении на базе Editor.js

Как быстро интегрировать и нативно отрисовать контент статейного формата у себя в мобильном приложении, почти никак не нагрузив проект и всего с помощью нескольких строк кода? В этом материале я опишу наш способ решения такой задачи.

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

Про инструмент

Инструмент, о котором пойдет речь - созданный нашей командой фреймворк Editor.js Kit. Он превращает JSON данные формата Editor.js в нативную мобильную верстку 👇

Фреймворк выложен в open-source и доступен на github для iOS (Swift) и Android (Kotlin). В рамках этой статьи с практической стороны раскрывается только iOS часть.

Предыстория

Пару лет назад наша команда столкнулась с задачей отрисовки в мобильном приложении новостных статей, пользовательских соглашений, экранов с описанием товара и других отформатированных массивов текста, чередующихся картинками, форматированными ссылками и списками. В общем, экранов статейного формата, как на TJ / VC. Контент должен был раздаваться бэкендом, а уже конкретный клиент (web / iOS / Android) форматирует и рендерит его в соответствии с представлениями о хорошем UX платформы и чувством прекрасного.

Задача, с одной стороны, тривиальная. Но, во-первых, экраны с таким контентом встречаются повсеместно почти во всех приложениях, и было бы хорошо придумать переиспользуемое универсальное решение. А во-вторых, вариант взять UITextView / WKWebView и скормить ему html верстку нам не подходит, поскольку данные должны быть легковесными, а верстка - нативной.

Было принято решение: пилим фреймворк.

Формат данных: Editor.js

При выборе формата для создания, хранения и передачи контента учитывали три фактора:

  • Команда редакторов должна создавать статьи с форматированием в каком-то юзер-френдли веб-редакторе;
  • Бэкенд API должен уметь принимать написанную статью с сохранением форматирования, хранить ее и раздавать ее всем клиентам на любые платформы в едином формате;
  • Мобильный клиент должен принимать контент статьи, парсить его и рендерить нативно.

Думали не долго: наткнулись на Editor.js - тот самый редактор, про который на VC уже была статья. По словам авторов из команды CodeX, именно этот инструмент используется для написания статей на vc.ru, TJ и DTF.

Основная концепция — блочная структура и чистые данные в виде JSON на выходе. В отличие от большинства редакторов, где пользователь работает с текстом внутри одной редактируемой обертки, в Editor.js каждый структурный элемент статьи — блок — это отдельный редактируемый элемент. Блоки могут быть какие угодно: абзацы, заголовки, цитаты, списки, изображения, твиты, опросы и так далее.

Поддерживаемые блоки

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

Парсинг контента

JSON данные могут отдаваться как с backend API, так и лежать локально в бандле или памяти смартфона - подготовка к его использованию будет одинакова и максимально проста в любом случае. Если кратко:

/// JSON данные `data: Data` превращаются в список блоков, готовых к отрисовке let myBlocks = try EJKit.shared.decode(data: data)

Подключение на экран

Для отрисовки экранов со скроллом мы почти всегда используем UICollectionView вместо UITableView в силу большей гибкости лейаута. Поэтому поддержку коллекции сделали в первую очередь. Любителей таблиц успокою - их поддержка легко добавляется адаптером по образцу коллекции.

Есть два основных сценария интеграции верстки себе на экран. Первый - когда ваш экран состоит только из Editor.js блоков. Это максимально просто делается через адаптер:

import EditorJSKit /// Не забываем конформить `EJCollectionDataSource` class ViewController: UIViewController, EJCollectionDataSource { /// Инстанс кита let kit = EJKit.shared lazy var collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionViewFlowLayout()) lazy var adapter = EJCollectionViewAdapter(collectionView: collectionView) /// Список блоков, который мы парсим var blocks: EJBlocksList? /// реализация протокола EJCollectionDataSource var data: EJBlocksList? { blocks } /** */ override func viewDidLoad() { super.viewDidLoad() adapter.dataSource = self let data: Data // ваши json данные в формате Editor.js blocks = try EJKit.shared.decode(data: data) collectionView.reloadData() } }

Второй сценарий - в случае, если вам надо встроить блоки в уже существующий экран с коллекцией, в любое его место. Для этого удобно использовать EJCollectionRenderer, который напрямую реализует методы dataSource и delegate коллекции и позволяет добавлять блоки в коллекцию в любом удобном порядке. Пример такой интеграции - в Readme на github.

Кастомизация стилей

В большинстве случаев в каждом проекте своя дизайн-система, то есть свои размеры шрифтов, отступы и цвета.

Рассмотрим базовую кастомизацию на примере заголовков:

{ "type": "header", "data": { "text": "Header 1", "level": 1 } }

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

struct CustomHeaderStyle: EJHeaderBlockStyle { let alignment: NSTextAlignment = .center /** */ func font(forHeaderLevel level: Int) -> UIFont { switch level { case 1: return UIFont.systemFont(ofSize: 30, weight: .bold) case 2: return UIFont.systemFont(ofSize: 24, weight: .bold) default: return UIFont.systemFont(ofSize: 20, weight: .semibold) } } /** */ func topInset(forHeaderLevel level: Int) -> CGFloat { return level == 1 ? 10 : 6 } /** */ func bottomInset(forHeaderLevel level: Int) -> CGFloat { return level == 1 ? 20 : 12 } }

Подключаем стиль:

EJKit.shared.set(style: CustomHeaderStyle(), for: EJNativeBlockType.header)

В результате получаем задуманное форматирование без каких-либо изменений в коде вью или в JSON данных:

Кастомные блоки

Встроить в полотно скролла можно абсолютно любой блок, и для этого не понадобится писать слишком много кода.

Каждый JSON блок представляет собой два верхнеуровневых поля: type (тип блока) и data (JSON контент, который зависит от типа блока). Для примера придумаем и сделаем выноску - callout:

{ "type" : "callout", "data" : { "emoji": "🙀", "text" : "This is a custom block which was easily integrated with a few lines of code." } }

Первым делом создадим кастомный тип-перечисление для наших собственных блоков, сразу добавив туда callout:

enum CustomBlockType: String, EJAbstractBlockType { case callout }

Затем пропишем модель для поля data, важно проставить протоколы:

public struct CalloutBlockContentItem: EJAbstractBlockContentItem { let text: String let emoji: String }

Следующим шагом пропишем стиль для нашей выноски, также реализуя EJBlockStyle:

struct CalloutBlockStyle: EJBlockStyle { // желтенький let backgroundColor = UIColor(red: 1, green: 0.78, blue: 0, alpha: 0.2) let cornerRadius: CGFloat = 6 let lineSpacing: CGFloat = 10 let font = UIFont.systemFont(ofSize: 16) let textColor = UIColor.black let emojiFont = UIFont.systemFont(ofSize: 24) }

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

final class CalloutBlockView: UIView, EJBlockView { private let emoji = UILabel() private let label = UILabel() /** Расставляем тексты в лейблы и применяем стиль */ func configure(withItem item: CalloutBlockContentItem, style: EJBlockStyle?) { emoji.text = item.emoji label.text = item.text guard let style = style as? CalloutBlockStyle else { return } backgroundColor = style.backgroundColor layer.cornerRadius = style.cornerRadius emoji.font = style.emojiFont label.font = style.font label.textColor = style.textColor } /** Здесь нужно посчитать размер для блока с учетом его стиля */ static func estimatedSize(for item: CalloutBlockContentItem, style: EJBlockStyle?, boundingWidth: CGFloat) -> CGSize { return CGSize(width: boundingWidth, height: 100) } }

Последний шаг: подключаем созданный блок к EJKit.

// Связываем элементы блока в единый каркас let calloutBlock = EJCustomBlock( type: CustomBlockType.callout, contentClass: BlockContent.Single<CalloutBlockContentItem>.self, viewClass: CalloutBlockView.self) // Регистрируем блок EJKit.shared.register(customBlock: calloutBlock) // Регистрируем стиль блока EJKit.shared.style.set(style: CalloutBlockStyle(), for: CustomBlockType.callout)

Готово! Смотрим на результат в виде светло-желтой выноски с шокированным котом в центре экрана:

Недостатки сегодня

  • Поддерживается только отображение, контента, возможности редактирования блоков прямо с клавиатуры на смартфоне нет.
  • Интерактив с контентом блоков отсутствует (кроме нажатия на гиперссылки и форматированные ссылки). Конечно, можно написать свой кастомный блок и прописать там всё, что душе угодно. Но никакой архитектуры на уровне фреймворка под поддержку интерактива не предусмотрено.
  • Стили для семи поддерживаемых из коробки типов блоков не всеобъемлющи. У текста помимо цвета, шрифта и отступов есть, как минимум, междустрочный интервал, кернинг и т.п. Но опять-таки, ничто не мешает переопределить тот или иной блок или стиль под дизайн-систему вашего проекта.

Выводы

Editor.js Kit хорошо подойдет тем, у кого в мобильном продукте хотя бы на нескольких экранах нужно реализовать backend-driven верстку. При довольно малых временных затратах можно интегрировать к себе в приложение эстетичный нативный юзер интерфейс в соответствии с вашим дизайн-кодом, не захламив при этом проект сторонними зависимостями.

Наравне с no-code и low-code продуктами, наше решение призвано сократить время, которое команда тратит на дизайн тривиальных задач, а программист на написание boilerplate кода, что особенно важно для стартапов и командам на этапах создания MVP продукта.

Всем интересующимся - добро пожаловать к нам на github: iOS и Android.

Над фреймворком работала команда Upstarts.work

Авторы и контрибьюторы: Вадим, Руслан, Иван, Исрапил, Александр

Авторы редактора Editor.js – CodeX Team

0
7 комментариев
Написать комментарий...
Аккаунт удален

Комментарий недоступен

Ответить
Развернуть ветку
Вадим Попов
Автор

1. На производительность может влиять только реализация верстки в отдельных блоках. В тех, что поддерживаются из коробки - нет ничего тяжелого, автолейаут обычный, пара лейблов и картинка - максимум. Если реализовать кастомный сложный блок (например, положить в него... игру), и поместить его в скролл, то не исключено, что скролл будет подлагивать - но это уже другая история :)
2. Не совсем, но такая цель тоже легко реализуема. На бэкенд можно прокидывать версию приложения и в зависимости от нее возвращать контент. Основная наша цель была - уменьшить количество будущих трудозатрат для реализации такой тривиальной задачи.
3. Первую MVP версию собрали на фуллтайме за 2 недели, потом еще 4 месяца понемногу улучшали и расширяли. После чего был перерыв, и вот сейчас вернулись к нему, значительно обновили кодовую базу для iOS, и для Android тоже на очереди.
4. Мы с помощью фреймворка решали задачи вроде реализации маркетплейсов с динамическими страницами товаров, описаниями, новостными статьями, обзорами. Тут точно оправдано, время было сэкономлено не только на фронте, но и на бэке - там большинство контента хранили как jsonb в базе, и не заморачивались со специальными таблицами или orm.

Ответить
Развернуть ветку
Алексис Второй

Спасибо за развитие и поддержку продукта!

Ответить
Развернуть ветку
Александр Герасимов

Спасибо. Взял на заметку.

Ответить
Развернуть ветку
Аккаунт удален

Комментарий недоступен

Ответить
Развернуть ветку
Вадим Попов
Автор

Поддерживается только отображение, контента, возможности редактирования блоков прямо с клавиатуры на смартфоне нет.

Бэкенд поставляет клиентам контент и инструкции, как его отрисовать. Он может регулировать и сам контент и стили его отображения под конкретного клиента, в зависимости от ваших задач.

Ну а правила, которые придуманы не нами - так это не баг, а фича. Зачем придумывать что-то новое, если есть отличный и активно используемый Editor.js с открытым исходным кодом. Мы на основе готового формата сделали инструмент, который значительно упрощает процесс верстки.

Ответить
Развернуть ветку
Аккаунт удален

Комментарий недоступен

Ответить
Развернуть ветку
4 комментария
Раскрывать всегда