NativeBook: набор инструментов для создания компонентов пользовательского интерфейса iOS, вдохновленный Storybook.js

NativeBook: набор инструментов для создания компонентов пользовательского интерфейса iOS, вдохновленный Storybook.js

20 декабря 2023 г.

Введение

Внедрение, поддержка, документирование и развитие общего набора компонентов пользовательского интерфейса — сложная задача в крупномасштабном приложении. Веб-разработчики создали мощное решение этой проблемы — Storybook.js (https://storybook.js.org). А как насчет разработки для iOS? Можем ли мы добиться чего-то подобного? (Внимание, спойлер: Да!)

Но сначала давайте рассмотрим исходную концепцию Storybook.js.

Storybook.js

Storybook.js — это инструмент с открытым исходным кодом, используемый веб-разработчиками для изолированного создания и тестирования компонентов пользовательского интерфейса веб-приложений. Думайте об этом как о игровой площадке, где разработчики могут создавать и демонстрировать отдельные части веб-сайта (например, кнопки, формы и панели навигации) отдельно от остальной части веб-сайта.

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

Example of Storybook.js page for Button component

Элегантность Storybook.js заключается в том, что компоненты пользовательского интерфейса используются в одной и той же среде; будь то рабочий веб-сайт или веб-сайт Storybook, мы по-прежнему запускаем их в веб-браузере. Это гарантирует, что компоненты будут выглядеть согласованно в нашей документации/на игровой площадке и в рабочей среде, предотвращая их рассинхронизацию.

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

Однако для приложений iOS история это другое.

Тем временем на iOS

В iOS набор инструментов и методов, связанных с разработкой компонентов пользовательского интерфейса, весьма фрагментирован. Давайте посмотрим на них с точки зрения Storybook.js.

Конструктор интерфейсов

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

Предварительный просмотр SwiftUI

SwiftUI Previews — это инструмент, созданный Apple для разработки и тестирования представлений SwiftUI в Xcode.

:::совет Предварительные просмотры SwiftUI также можно использовать для UIView. См. https://sarunw.com/posts/xcode-previews-with-uiview

:::

Плюсы:

  • Отлично подходит для изолированной разработки.
  • Поддержка горячей перезагрузки
  • Используйте настоящий симулятор iOS.
  • Отображение активных компонентов рядом с фрагментом кода.
  • Удобно для тестирования динамического типа и темного режима.

Минусы:

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

Когда это работает, предварительный просмотр SwiftUI, вероятно, наиболее близок к Storybook.js, за исключением аспекта «документации».

Создание документации на основе HTML

Следующее, что нам предстоит сделать, — это создание документации на основе исходного кода, комментариев и аннотаций. Наиболее яркими примерами являются:

DocC – https://developer.apple.com/documentation/docc

Джаззи – https://github.com/realm/jazzy

Этот тип инструментов можно использовать для создания ссылок на API компонентов в форме веб-страницы.

Much of Apple's developer documentation built using DocC.

Тестирование моментальных снимков

Снимочные тесты — отличный способ изолированной разработки, тестирования различных визуальных состояний компонента и обеспечения отсутствия неожиданных изменений.

Две наиболее популярные библиотеки, реализующие тестирование снимков на iOS:

Приложение пользовательского каталога

Другим очевидным выбором для Storybook.js является создание собственного собственного приложения-каталога.

Плюсы:

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

Минусы:

  • Это приложение; это не мгновенный опыт, и его создание и запуск требуют дополнительных хлопот.
  • Опыт документирования полностью отсутствует.

Несколько примеров:

https://github.com/aj-bartocci/Storybook-SwiftUI

https://github.com/hpennington/SwiftBook

https://github.com/eure/Storybook-ios

Catalog experience from https://github.com/eure/Storybook-ios

Одна крутая, хотя и экстравагантная идея — встроить собственное приложение-каталог в веб-сайт Storybook.js с помощью сервиса Appetize.io, который позволяет транслировать содержимое устройства iOS на веб-страницу — https://medium.com/@vasikarla.raj/storybook-for-native-d772654c7133

Еще одна идея

Фактом является то, что в нативной мобильной разработке отсутствует секретный ингредиент, необходимый для работы, подобной Storyboard.js: единая среда для запуска документации, игровых площадок и производства, и все это на самой доступной платформе — веб-сайте.

Но что, если мы объединим приложение-каталог, тесты снимков и генерацию документации на основе HTML в единую систему? Представьте себе, что вы написали фрагмент кода всего один раз, а затем можете сделать следующее:

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

Хорошие новости! Я разработал концепцию такой системы:

https://github.com/psharanda/NativeBook

Давайте посмотрим и соберем!

:::информация NativeBook в настоящее время фокусируется на компонентах на основе UIView. Компоненты SwiftUI также можно интегрировать аналогичным образом, хотя они не рассматриваются в этой статье.

:::

Реализация NativeBook

Истории

История — краеугольный камень нашей системы. По сути, это именованный фрагмент кода, который демонстрирует «интересное» состояние компонента пользовательского интерфейса. Его можно представить в виде простой структуры:

struct Story {
    let name: String
    let makeView: () -> UIView?
}

Следующий объект — ComponentStories, который содержит список историй для компонента:

protocol ComponentStories {
   var componentName: String { get }
   var stories: [Story] { get }
}

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

  • Создает представление
  • Настраивает его (включая настройку ограничений ширины/высоты)
  • Возвращает представление

Вот пример фрагмента:

@objc static func story_RoundedCornerButton() -> UIView {
   let button = UIButton()
   var config = UIButton.Configuration.filled()
   config.title = "Rounded"
   config.cornerStyle = .medium
   button.configuration = config
   return button
}

Мы можем улучшить процесс написания историй и сократить количество шаблонов, используя динамическую природу Objective-C. Идея состоит в том, чтобы иметь базовый класс, способный извлекать все селекторы классов, начинающиеся с store_, и создавать из них список историй.

class DynamicComponentStories: NSObject, ComponentStories {
    var componentName: String {
        return NSStringFromClass(type(of: self))
            .split(separator: ".").last!
            .replacingOccurrences(of: "Stories", with: "")
    }

    private(set) lazy var stories: [Story] = {
        var methodCount: UInt32 = 0
        guard let methodList = class_copyMethodList(object_getClass(type(of: self)), &methodCount) else { return [] }
        var uniqueSelectors = Set<String>()
        var selectors = [Selector]()
        for i in 0 ..< Int(methodCount) {
            let selector = method_getName(methodList[i])
            let name = NSStringFromSelector(selector)
            if name.starts(with: "story_") {
                if !uniqueSelectors.contains(name) {
                    uniqueSelectors.insert(name)
                    selectors.append(selector)
                }
            }
        }
        free(methodList)

        let cls = type(of: self)
        return selectors.map { selector in
            Story(name: NSStringFromSelector(selector).replacingOccurrences(of: "story_", with: "")) {
                cls.perform(selector).takeUnretainedValue() as? UIView
            }
        }
    }()
}

Например, класс, в котором размещаются истории для UILabel, может быть структурирован следующим образом:

class UILabelStories: DynamicComponentStories {
    @objc static func story_BasicLabel() -> UIView {   }
    @objc static func story_FixedWidthLabel() -> UIView {  }
}

Приложение-каталог

Приложение-каталог в своей простейшей форме обычно состоит из двух экранов.

Главный экран: на этом экране отображается список компонентов и связанные с ними истории.

class StorybookViewController: UITableViewController {
    let componentsStories: [ComponentStories]
    init(componentsStories: [ComponentStories]) {
        self.componentsStories = componentsStories
        super.init(style: .plain)
        title = "UIKit: NativeBook"
    }

    override func numberOfSections(in _: UITableView) -> Int {
        return componentsStories.count
    }

    override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
        return componentsStories[section].stories.count
    }

    override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
        return componentsStories[section].componentName
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CellId") ?? UITableViewCell()
        cell.textLabel?.text = componentsStories[indexPath.section].stories[indexPath.row].name
        return cell
    }
    override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
        let storyViewController = StoryViewController(story: componentsStories[indexPath.section].stories[indexPath.row])
        navigationController?.pushViewController(storyViewController, animated: true)
    }
}

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

class StoryViewController: UIViewController {
    let story: Story
    init(story: Story) {
        self.story = story
        super.init(nibName: nil, bundle: nil)
        title = story.name
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .secondarySystemBackground

        guard let storyView = story.makeView() else {
            return
        }
        view.addSubview(storyView)

        storyView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            storyView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            storyView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}

Весь поток каталога можно инициализировать следующим образом:

let vc = StorybookViewController(componentsStories: [
   UILabelStories(),
   UIButtonStories(),
   UITextFieldStories(),
])
let nc = UINavigationController(rootViewController: vc)
window.rootViewController = nc

NativeBook Catalog

Предварительный просмотр SwiftUI

Мы можем взять идею PreviewContainer и ее реализацию из https://sarunw.com/posts/xcode- превью-с-uiview/

struct PreviewContainer<T: UIView>: UIViewRepresentable {
   let view: T

   init(_ viewBuilder: @escaping () -> T) {
       view = viewBuilder()
   }
   func makeUIView(context: Context) -> T {
       return view
   }

    func updateUIView(_ view: T, context: Context) {
        view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        view.setContentHuggingPriority(.defaultHigh, for: .vertical)
    }
}

И напишите наши превью так просто

struct UIButton_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(UIButtonStories().stories, id: .name) { story in
            PreviewContainer {
                story.makeView()!
            }
            .previewDisplayName(story.name)
        }
    }
}

SwiftUI Previews + NativeBook Stories

Снимочные тесты

В нашем примере мы будем использовать библиотеку iOSSnapshotTestCase.

В iOS 17 были введены некоторые ценные дополнения к API UIView для переопределения свойств с помощью UIView.traitOverrides< /а>. Это свойство чрезвычайно полезно для поддержки доступности при тестировании моментальных снимков. В наших тестах снимков мы собираемся протестировать одно и то же представление в различных условиях, применяя RTL, темный режим и некоторые категории динамического типа.

Важно отметить, что для тестирования traitOverrides нам нужно использовать метод drawViewHierarchy(in:afterScreenUpdates:) и убедиться, что тестируемые представления добавлены в ключ приложения UIWindow.

class NativeBookSnapshotTestCase: FBSnapshotTestCase {
    override func setUp() {
        super.setUp()
        usesDrawViewHierarchyInRect = true
    }

    func runTests(for componentStories: ComponentStories) {
        for story in componentStories.stories {
            if let view = story.makeView() {
                NativeBookVerifyView(view, storyName: story.name)
            }
        }
    }

    private func NativeBookVerifyView(_ view: UIView, storyName: String) {
        let window = UIApplication.shared.connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.keyWindow }.last!
        window.addSubview(view)
        view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            view.centerXAnchor.constraint(equalTo: window.centerXAnchor),
            view.centerYAnchor.constraint(equalTo: window.centerYAnchor),
        ])

        // testing a view for different dynamic type modes
        let contentSizeCategories: [UIContentSizeCategory: String] = [
            .extraSmall: "xs",
            .medium: "m",
            .large: "default",
            .extraLarge: "xl",
            .extraExtraExtraLarge: "xxxl",
        ]
        for (category, categoryName) in contentSizeCategories {
            view.traitOverrides.preferredContentSizeCategory = category
            window.layoutIfNeeded()
            FBSnapshotVerifyView(view, identifier: storyName + "__" + categoryName)
        }

        // testing dark mode support
        view.traitOverrides.preferredContentSizeCategory = .large
        view.traitOverrides.userInterfaceStyle = .dark
        window.layoutIfNeeded()
        FBSnapshotVerifyView(view, identifier: storyName + "__dark")

        // testing RTL support
        view.traitOverrides.layoutDirection = .rightToLeft
        view.semanticContentAttribute = .forceRightToLeft
        view.traitOverrides.userInterfaceStyle = .light
        window.layoutIfNeeded()
        FBSnapshotVerifyView(view, identifier: storyName + "__rtl")

        view.removeFromSuperview()
    }
}

Действительно, при наличии этих приготовлений создание тестового примера становится довольно простым:

Reference snapshots produced by NativeBookVerifyView

Генератор документации

Для этой задачи мы будем использовать другой стек: шаблоны Node.js, TypeScript и EJS. Гораздо проще работать со страницами на основе HTML, используя встроенные в них инструменты.

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

{
    "components": [
        {
            "name": "UILabel",
            "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift",
            "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests"
        },
    
   ]
}

Создав простое приложение Node.js, давайте определим модель.

interface Story {
  name: string;
  codeSnippet: string;
}

interface Component {
  name: string;
  stories: Story[];
  snapshotsFolderPath: string;
}

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

function storiesFromFile(filePath: string): Story[] {
  const sourceCode = fs.readFileSync(repoRoot + filePath, "utf-8");

  const codeSnippetRegex =
    /funcs+story_(?<name>[A-Za-z0-9_]+)()s*->s*UIViews*{(?<codeSnippet>[sS]*?)return/g;

  const result: Story[] = [];
  let match;
  while ((match = codeSnippetRegex.exec(sourceCode)) !== null && match.groups) {
    result.push({
      name: match.groups["name"],
      codeSnippet: formatMultilineString(match.groups["codeSnippet"]),
    });
  }
  return result;
}

Генерацию HTML можно выполнить с помощью EJS, мощного механизма шаблонов, который позволяет нам использовать JavaScript внутри шаблона:

function renderIndex(components: Component[]) {
  fs.writeFileSync(
    siteDir + "/index.html",
    ejs.render(
      fs.readFileSync(__dirname + "/templates/index.html.ejs", "utf-8"),
      {
        components: components,
      }
    )
  );
}

function renderComponent(component: Component) {
  fs.writeFileSync(
    siteDir + `/${component.name}.html`,
    ejs.render(
      fs.readFileSync(__dirname + "/templates/component.html.ejs", "utf-8"),
      {
        component: component,
      }
    )
  );
}

Теперь объединим все в основной функции:

(function () {
  // load config
  const nativeBookConfigContents = fs.readFileSync(
    repoRoot + "native_book_config.json",
    "utf-8"
  );
  const config = JSON.parse(nativeBookConfigContents);

  // gather information for a component
  const components: Component[] = [];
  for (const componentJson of config["components"]) {
  components.push({
    name: componentJson["name"],
    stories: storiesFromFile(componentJson["storiesFilePath"]),
    snapshotsFolderPath: componentJson["snapshotsFolderPath"],
  });
  }

  // prepare site folder
  if (fs.existsSync(siteDir)) {
    fs.rmSync(siteDir, { recursive: true });
  }
  fs.mkdirSync(siteDir, { recursive: true });

  // render html site
  renderIndex(components);
  for (const component of components) {
    renderComponent(component);
  }
})();

Сайт документации

Демо-версия доступна по адресу https://psharanda.github.io/NativeBook

Чтобы создать документацию в репозитории NativeBook, вам необходимо выполнить следующие команды:

cd native_book_gen
npm install
npm run native-book-gen

Или просто запустите:

sh generate_native_book.sh

Документация появится в папке docs

.

UIButton reference produced by NativeBook

We can check snapshots for different accessibility mode, dark mode and RTL!

Что дальше?

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

Мы также можем представить себе следующие улучшения:

  • Добавление поддержки Android: мы можем поддерживать обе платформы и иметь согласованные каталоги, тестовые снимки и документацию, позволяющую легко переключаться между платформами.
  • Встраивание проектов Figma в документацию
  • Объединение с документацией, созданной такими инструментами, как DocC или Jazzy, для получения подробного справочника по API в дополнение к снимкам и фрагментам кода.
  • Добавление надлежащей поддержки представлений SwiftUI


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE