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 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.
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.
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 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 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:
Cons:
When it works, SwiftUI previews are probably the closest experience to Storybook.js, except for the "documentation" aspect.
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.
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:
Another obvious choice for the Storybook.js experience would be to build a custom native catalog app.
Pros:
Cons:
Some examples:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
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
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:
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.
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:
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 { … }
}
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
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)
}
}
}
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())
}
}
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);
}
})();
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
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!