paint-brush
NativeBook: Toolkit for Building IOS UI Components Inspired by Storybook.jsby@psharanda
2,770 reads
2,770 reads

NativeBook: Toolkit for Building IOS UI Components Inspired by Storybook.js

by Pavel SharandaDecember 19th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

Storybook.js is a tool used by web developers to create and test UI components in isolation, providing a playground for building and showcasing UI parts separately. However, for native iOS development, there's no direct equivalent. The article introduces "NativeBook," a proposed system combining a catalog app, SwidtUI previews, snapshot tests, and HTML documentation generation to create a Storybook.js-like experience for iOS. While still a proof of concept, NativeBook aims to streamline the iOS UI component development and documentation process.
featured image - NativeBook: Toolkit for Building IOS UI Components Inspired by Storybook.js
Pavel Sharanda HackerNoon profile picture

Introduction

Implementing, maintaining, documenting, and evolving a shared set of UI components is a challenge in a large-scale application. Web developers have created a powerful solution for this problem - Storybook.js (https://storybook.js.org). But what about native iOS development? Can we achieve a somewhat similar experience? (Spoiler Alert: Yes!)


But first, let's explore the original Storybook.js concept.

Storybook.js

Storybook.js is an open-source tool used by web developers to create and test UI components for web applications in isolation. Think of it as a playground where developers can build and showcase individual parts of a website (like buttons, forms, and navigation bars) separately from the rest of the website.


Storybook.js serves as a living documentation of components and provides visuals to see how components look and behave in different states and scenarios alongside code samples.


Example of Storybook.js page for Button component


The elegance of Storybook.js lies in the fact that UI components are used in the same environment; whether on a production website or a Storybook one, we are still running them in a web browser. This ensures that components look consistent in our documentation/playground and in production, preventing them from being out of sync.


Web pages are also an excellent medium for documentation, providing a great reading experience, easy sharing, instant loading times, and universal availability on any device and operating system.


However, for iOS apps, the story is different.

Meanwhile on iOS

On iOS, the landscape of tools and methods related to UI component development is quite fragmented. Let’s take a look at them from Storybook.js perspective.

Interface Builder

Interface Builder provides a drag-and-drop interface, making it easy to design UIs without writing code. Usually, it is used for laying out existing UI components for entire screens rather than for the development of individual UI components. It doesn’t work well in showcasing different UI component states and handling complex UIs in general, often requiring many aspects to be implemented in code. Overall, it is considered a dead end by many, including Apple.

SwiftUI Previews

SwiftUI Previews is a tool created by Apple for developing and testing SwiftUI views within Xcode.

SwiftUI Previews can also be used for UIViews. See https://sarunw.com/posts/xcode-previews-with-uiview

Pros:

  • Great for isolated development
  • Support Hot Reload
  • Use an actual iOS simulator under the hood
  • Display live components next to a code snippet
  • Convenient for testing Dynamic type and Dark mode

Cons:

  • Require Xcode to be installed with the development environment properly configured.
  • Build times can be lengthy
  • Work unreliably and crash quite often
  • Resource-heavy, running slowly even on high-end machines


When it works, SwiftUI previews are probably the closest experience to Storybook.js, except for the "documentation" aspect.

HTML-based documentation generation

The next thing we have is generating documentation based on source code, comments, and annotations. The most notable examples are:

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

Jazzy - https://github.com/realm/jazzy


This type of tooling can be used to create component API references in the form of a web page.


Much of Apple's developer documentation built using DocC.

Snapshot testing

Snapshot tests are a great way for isolated development, testing various visual states of a component, ensuring that things do not change unexpectedly.


The two most popular libraries that implement snapshot testing on iOS are:

Custom catalog app

Another obvious choice for the Storybook.js experience would be to build a custom native catalog app.


Pros:

  • Uses the same environment as production: iOS simulator or device
  • Displays real components on the screen
  • Enables testing of accessibility, internationalization, and dark mode.

Cons:

  • It is an app; it is not an instant experience, and it is an extra hassle to build and run it.
  • The documentation experience is completely lacking.


Some examples:

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


One cool, albeit extravagant, idea is to embed a native catalog app into the Storybook.js website using Appetize.io service, which allows streaming the contents of an iOS device to a web page - https://medium.com/@vasikarla.raj/storybook-for-native-d772654c7133

One more idea

It’s a fact that native mobile development lacks the secret ingredient needed for a Storyboard.js-like experience: a single environment for running documentation, playgrounds, and production, all within the most accessible platform - a website.


But what if we combine a catalog app, snapshot tests, and HTML-based documentation generation into a single system? Imagine writing a code snippet just once and then being able to do the following:

  • Display a view produced from a snippet in a native catalog app
  • Embed a view into SwiftUI previews
  • Run a snapshot test for a snippet
  • Display this code snippet and the resulting snapshot in a generated HTML-based documentation.


Good news! I've built a proof of concept for such a system:

https://github.com/psharanda/NativeBook


Let's take a look and put it together!


NativeBook currently focuses on UIView-based components. SwiftUI components can also be integrated in a similar manner, although they are not covered in this article.

NativeBook Implementation

Stories

Story is a cornerstone of our system. Essentially, it is a named code snippet that showcases some 'interesting' state of a UI component. It can be represented as a simple struct:

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


The next object is ComponentStories, which contains a list of stories for a component:

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


So, how are we going to declare a snippet? For easier parsing and convenience, the function can have a special naming convention that starts with story_. In general, each code snippet for a story:

  • Creates a view
  • Configures it (which may include setting width/height constraints)
  • Returns a view


Here's an example of a snippet:

@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
}


We can improve the story-writing experience and reduce the amount of boilerplate by making use of the dynamic nature of Objective-C. The idea is to have a base class which is capable of retrieving all class selectors that start with store_ and construct a stories list from them.

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
            }
        }
    }()
}


For example, a class that hosts the stories for UILabel can be structured as follows:

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

Catalog app

A catalog app, in its simplest form, typically consists of two screens.


Main Screen: This screen displays a list of components and their associated stories.

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)
    }
}


Details screen: On this screen, we display a component in the center without restricting its width or height, as it's up to the snippet to define these attributes.

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),
        ])
    }
}


The entire catalog flow can be initialized in the following manner:

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


NativeBook Catalog


SwiftUI Previews

We can take the PreviewContainer idea and its implementation from https://sarunw.com/posts/xcode-previews-with-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)
    }
}


And write our previews as simple as this

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

Snapshot tests

In our example, we will be using the iOSSnapshotTestCase library.


With iOS 17, some valuable additions to the UIView API were introduced for overriding traits through UIView.traitOverrides. This property is extremely useful for snapshot testing accessibility support. For our snapshot tests, we are going to test the same view in various conditions by enforcing RTL, dark mode, and some of the dynamic type categories.


It's important to note that in order to test traitOverrides, we need to use the drawViewHierarchy(in:afterScreenUpdates:) method and ensure that the views we are testing are added to the app's key 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()
    }
}


Indeed, with these preparations in place, creating a test case becomes quite straightforward:

final class UIButtonTests: NativeBookSnapshotTestCase {
    func test() {
        // recordMode = true
        runTests(for: UIButtonStories())
    }
}


Reference snapshots produced by NativeBookVerifyView

Documentation generator

For this task, we are going to use a different stack: Node.js, TypeScript, and EJS templates. It is much easier to work with HTML-based pages using tools that are native to them.


The first thing we need is some sort of configuration file where we can link our code snippets and snapshot tests. A simple JSON file works well for this purpose.

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


After creating a simple Node.js app, let's define a model.

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

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


The interesting challenge is how to extract code snippets from a Swift file. This can be achieved quite simply by using regex.

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

  const codeSnippetRegex =
    /func\s+story_(?<name>[A-Za-z0-9_]+)\(\)\s*->\s*UIView\s*{(?<codeSnippet>[\s\S]*?)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 generation can be done using EJS, a powerful template engine that allows us to use JavaScript within a template:

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,
      }
    )
  );
}


Now, combining everything in the main function:

(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);
  }
})();

Documentation Site

The demo is available at https://psharanda.github.io/NativeBook


In order to generate documentation in NativeBook repo, you need to do the following commands:

cd native_book_gen
npm install
npm run native-book-gen


Or simply run:

sh generate_native_book.sh


The documentation will appear in docs folder


UIButton reference produced by NativeBook


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


What’s next?

Hopefully, you enjoyed the journey. NativeBook, in its current state, is a proof of concept, but with some additions and proper integration into a developer's workflow, it can start bringing a lot of value.


We can also imagine the following enhancements:

  • Adding support for Android: We can support both platforms and have consistent catalogs, snapshot tests, and documentation where you can easily toggle between platforms

  • Embedding Figma designs into documentation

  • Merging with documentation generated by tools like DocC or Jazzy to get a comprehensive API reference in addition to snapshots and code snippets


UPDATE: I used the approach from this article for my recent Auto Layout wrapper library, https://github.com/psharanda/FixFlex. It helped me generate examples for README.md, implement the catalog app, snapshot tests, and SwiftUI previews - all from a single set of code snippets. Check it out!