Implementar, mantener, documentar y desarrollar un conjunto compartido de componentes de interfaz de usuario es un desafío en una aplicación a gran escala. Los desarrolladores web han creado una solución poderosa para este problema: Storybook.js ( https://storybook.js.org ). Pero ¿qué pasa con el desarrollo nativo de iOS ? ¿Podemos lograr una experiencia algo similar? (Alerta de spoiler: ¡Sí! )
Pero primero, exploremos el concepto original de Storybook.js.
Storybook.js es una herramienta de código abierto utilizada por desarrolladores web para crear y probar componentes de interfaz de usuario para aplicaciones web de forma aislada. Piense en ello como un patio de juegos donde los desarrolladores pueden crear y mostrar partes individuales de un sitio web (como botones, formularios y barras de navegación) por separado del resto del sitio web.
Storybook.js sirve como documentación viva de los componentes y proporciona imágenes para ver cómo se ven y se comportan los componentes en diferentes estados y escenarios junto con ejemplos de código.
La elegancia de Storybook.js radica en el hecho de que los componentes de la interfaz de usuario se utilizan en el mismo entorno; ya sea en un sitio web de producción o en uno de Storybook, todavía los ejecutamos en un navegador web. Esto garantiza que los componentes se vean consistentes en nuestra documentación/área de juegos y en producción, evitando que no estén sincronizados.
Las páginas web también son un excelente medio para la documentación, ya que brindan una excelente experiencia de lectura, fácil intercambio, tiempos de carga instantáneos y disponibilidad universal en cualquier dispositivo y sistema operativo.
Sin embargo, para las aplicaciones de iOS , la historia es diferente.
En iOS, el panorama de herramientas y métodos relacionados con el desarrollo de componentes de UI está bastante fragmentado. Veámoslos desde la perspectiva de Storybook.js.
Interface Builder proporciona una interfaz de arrastrar y soltar, lo que facilita el diseño de interfaces de usuario sin escribir código. Por lo general, se utiliza para diseñar componentes de UI existentes para pantallas completas en lugar de desarrollar componentes de UI individuales. No funciona bien para mostrar diferentes estados de los componentes de la interfaz de usuario ni para manejar interfaces de usuario complejas en general, ya que a menudo requiere que se implementen muchos aspectos en el código. En general, muchos, incluido Apple, lo consideran un callejón sin salida.
SwiftUI Previews es una herramienta creada por Apple para desarrollar y probar vistas de SwiftUI dentro de Xcode.
Las vistas previas de SwiftUI también se pueden utilizar para UIViews. Ver https://sarunw.com/posts/xcode-previews-with-uiview
Ventajas:
Contras:
Cuando funciona, las vistas previas de SwiftUI son probablemente la experiencia más cercana a Storybook.js, excepto por el aspecto de "documentación".
Lo siguiente que tenemos es generar documentación basada en código fuente, comentarios y anotaciones. Los ejemplos más notables son:
DocC: https://developer.apple.com/documentation/docc
Jazzy: https://github.com/realm/jazzy
Este tipo de herramientas se puede utilizar para crear referencias de API de componentes en forma de página web.
Las pruebas instantáneas son una excelente manera de realizar desarrollo aislado, probar varios estados visuales de un componente y garantizar que las cosas no cambien inesperadamente.
Las dos bibliotecas más populares que implementan pruebas de instantáneas en iOS son:
Otra opción obvia para la experiencia Storybook.js sería crear una aplicación de catálogo nativa personalizada.
Ventajas:
Contras:
Algunos ejemplos:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
Una idea interesante, aunque extravagante, es integrar una aplicación de catálogo nativa en el sitio web Storybook.js utilizando el servicio Appetize.io, que permite transmitir el contenido de un dispositivo iOS a una página web: https://medium.com/@vasikarla .raj/storybook-para-nativo-d772654c7133
Es un hecho que el desarrollo móvil nativo carece del ingrediente secreto necesario para una experiencia similar a Storyboard.js: un entorno único para ejecutar documentación, áreas de juego y producción, todo dentro de la plataforma más accesible: un sitio web.
Pero ¿qué pasa si combinamos una aplicación de catálogo, pruebas instantáneas y generación de documentación basada en HTML en un solo sistema? Imagínese escribir un fragmento de código solo una vez y luego poder hacer lo siguiente:
¡Buenas noticias! He creado una prueba de concepto para dicho sistema:
https://github.com/psharanda/NativeBook
¡Echemos un vistazo y armémoslo!
NativeBook actualmente se centra en componentes basados en UIView. Los componentes de SwiftUI también se pueden integrar de manera similar, aunque no se tratan en este artículo.
La historia es la piedra angular de nuestro sistema. Básicamente, es un fragmento de código con nombre que muestra algún estado "interesante" de un componente de la interfaz de usuario. Se puede representar como una estructura simple:
struct Story { let name: String let makeView: () -> UIView? }
El siguiente objeto es ComponentStories
, que contiene una lista de historias para un componente:
protocol ComponentStories { var componentName: String { get } var stories: [Story] { get } }
Entonces, ¿cómo vamos a declarar un fragmento? Para facilitar el análisis y facilitar el análisis, la función puede tener una convención de nomenclatura especial que comience con story_
. En general, cada fragmento de código de una historia:
A continuación se muestra un ejemplo de un fragmento:
@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 }
Podemos mejorar la experiencia de escritura de historias y reducir la cantidad de texto repetitivo haciendo uso de la naturaleza dinámica de Objective-C. La idea es tener una clase base que sea capaz de recuperar todos los selectores de clases que comienzan con store_
y construir una lista de historias a partir de ellos.
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 } } }() }
Por ejemplo, una clase que aloja las historias de UILabel se puede estructurar de la siguiente manera:
class UILabelStories: DynamicComponentStories { @objc static func story_BasicLabel() -> UIView { … } @objc static func story_FixedWidthLabel() -> UIView { … } }
Una aplicación de catálogo, en su forma más simple, normalmente consta de dos pantallas.
Pantalla principal: esta pantalla muestra una lista de componentes y sus historias asociadas.
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) } }
Pantalla de detalles: En esta pantalla mostramos un componente en el centro sin restringir su ancho o alto, ya que depende del fragmento definir estos atributos.
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), ]) } }
Todo el flujo del catálogo se puede inicializar de la siguiente manera:
let vc = StorybookViewController(componentsStories: [ UILabelStories(), UIButtonStories(), UITextFieldStories(), ]) let nc = UINavigationController(rootViewController: vc) window.rootViewController = nc
Podemos tomar la idea de PreviewContainer y su implementación de 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) } }
Y escribe nuestras vistas previas tan simples como esta.
struct UIButton_Previews: PreviewProvider { static var previews: some View { ForEach(UIButtonStories().stories, id: \.name) { story in PreviewContainer { story.makeView()! } .previewDisplayName(story.name) } } }
En nuestro ejemplo, usaremos la biblioteca iOSSnapshotTestCase .
Con iOS 17, se introdujeron algunas adiciones valiosas a la API UIView
para anular rasgos a través de UIView.traitOverrides . Esta propiedad es extremadamente útil para la compatibilidad con pruebas de accesibilidad de instantáneas. Para nuestras pruebas de instantáneas, probaremos la misma vista en diversas condiciones aplicando RTL, modo oscuro y algunas de las categorías de tipo dinámico.
Es importante tener en cuenta que para probar traitOverrides
, debemos usar el método drawViewHierarchy(in:afterScreenUpdates:)
y asegurarnos de que las vistas que estamos probando se agreguen a la UIWindow
clave de la aplicación.
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() } }
De hecho, con estos preparativos, crear un caso de prueba resulta bastante sencillo:
final class UIButtonTests: NativeBookSnapshotTestCase { func test() { // recordMode = true runTests(for: UIButtonStories()) } }
Para esta tarea, utilizaremos una pila diferente: plantillas de Node.js, TypeScript y EJS. Es mucho más fácil trabajar con páginas basadas en HTML utilizando herramientas nativas para ellas.
Lo primero que necesitamos es algún tipo de archivo de configuración donde podamos vincular nuestros fragmentos de código y pruebas instantáneas. Un archivo JSON simple funciona bien para este propósito.
{ "components": [ { "name": "UILabel", "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift", "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests" }, … ] }
Después de crear una aplicación Node.js simple, definamos un modelo.
interface Story { name: string; codeSnippet: string; } interface Component { name: string; stories: Story[]; snapshotsFolderPath: string; }
El desafío interesante es cómo extraer fragmentos de código de un archivo Swift. Esto se puede lograr de manera muy simple usando expresiones regulares.
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; }
La generación de HTML se puede realizar utilizando EJS, un potente motor de plantillas que nos permite utilizar JavaScript dentro de una plantilla:
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, } ) ); }
Ahora, combinando todo en la función principal:
(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); } })();
La demostración está disponible en https://psharanda.github.io/NativeBook
Para generar documentación en el repositorio de NativeBook, debe ejecutar los siguientes comandos:
cd native_book_gen npm install npm run native-book-gen
O simplemente ejecute:
sh generate_native_book.sh
La documentación aparecerá en la carpeta docs
.
Esperemos que hayas disfrutado el viaje. NativeBook , en su estado actual, es una prueba de concepto, pero con algunas adiciones y una integración adecuada en el flujo de trabajo de un desarrollador, puede comenzar a aportar mucho valor.
También podemos imaginar la siguiente mejora: