Implementar, manter, documentar e desenvolver um conjunto compartilhado de componentes de UI é um desafio em um aplicativo de grande escala. Os desenvolvedores da Web criaram uma solução poderosa para esse problema - Storybook.js ( https://storybook.js.org ). Mas e quanto ao desenvolvimento nativo do iOS ? Podemos conseguir uma experiência um tanto semelhante? (Alerta de spoiler: sim! )
Mas primeiro, vamos explorar o conceito original do Storybook.js.
Storybook.js é uma ferramenta de código aberto usada por desenvolvedores da web para criar e testar componentes de UI para aplicativos da web isoladamente. Pense nisso como um playground onde os desenvolvedores podem construir e exibir partes individuais de um site (como botões, formulários e barras de navegação) separadamente do resto do site.
Storybook.js serve como uma documentação viva de componentes e fornece recursos visuais para ver como os componentes se parecem e se comportam em diferentes estados e cenários junto com exemplos de código.
A elegância do Storybook.js reside no fato de que os componentes da UI são usados no mesmo ambiente; seja em um site de produção ou em um Storybook, ainda os executamos em um navegador da web. Isso garante que os componentes pareçam consistentes em nossa documentação/playground e em produção, evitando que fiquem fora de sincronia.
As páginas da Web também são um excelente meio para documentação, proporcionando uma ótima experiência de leitura, fácil compartilhamento, tempos de carregamento instantâneos e disponibilidade universal em qualquer dispositivo e sistema operacional.
No entanto, para aplicativos iOS , a história é diferente.
No iOS, o cenário de ferramentas e métodos relacionados ao desenvolvimento de componentes de UI é bastante fragmentado. Vamos dar uma olhada neles da perspectiva do Storybook.js.
O Interface Builder fornece uma interface de arrastar e soltar, facilitando o design de UIs sem escrever código. Normalmente, ele é usado para definir componentes de UI existentes para telas inteiras, em vez de para o desenvolvimento de componentes de UI individuais. Ele não funciona bem para mostrar diferentes estados de componentes de UI e lidar com UIs complexas em geral, muitas vezes exigindo que muitos aspectos sejam implementados no código. No geral, é considerado um beco sem saída por muitos, incluindo a Apple.
SwiftUI Previews é uma ferramenta criada pela Apple para desenvolver e testar visualizações SwiftUI no Xcode.
As visualizações do SwiftUI também podem ser usadas para UIViews. Consulte https://sarunw.com/posts/xcode-previews-with-uiview
Prós:
Contras:
Quando funciona, as visualizações do SwiftUI são provavelmente a experiência mais próxima do Storybook.js, exceto pelo aspecto de "documentação".
A próxima coisa que temos é gerar documentação com base no código-fonte, comentários e anotações. Os exemplos mais notáveis são:
DocC - https://developer.apple.com/documentation/docc
Jazzístico - https://github.com/realm/jazzy
Este tipo de ferramenta pode ser usado para criar referências de API de componentes na forma de uma página da web.
Os testes de instantâneo são uma ótima maneira de desenvolvimento isolado, testando vários estados visuais de um componente, garantindo que as coisas não mudem inesperadamente.
As duas bibliotecas mais populares que implementam testes de snapshot no iOS são:
Outra escolha óbvia para a experiência do Storybook.js seria construir um aplicativo de catálogo nativo personalizado.
Prós:
Contras:
Alguns exemplos:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
Uma ideia legal, embora extravagante, é incorporar um aplicativo de catálogo nativo ao site Storybook.js usando o serviço Appetize.io, que permite transmitir o conteúdo de um dispositivo iOS para uma página da web - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133
É fato que o desenvolvimento móvel nativo carece do ingrediente secreto necessário para uma experiência semelhante ao Storyboard.js: um ambiente único para executar documentação, playgrounds e produção, tudo dentro da plataforma mais acessível - um site.
Mas e se combinarmos um aplicativo de catálogo, testes de snapshot e geração de documentação baseada em HTML em um único sistema? Imagine escrever um trecho de código apenas uma vez e depois poder fazer o seguinte:
Boas notícias! Eu construí uma prova de conceito para esse sistema:
https://github.com/psharanda/NativeBook
Vamos dar uma olhada e montar tudo!
NativeBook atualmente se concentra em componentes baseados em UIView. Os componentes SwiftUI também podem ser integrados de maneira semelhante, embora não sejam abordados neste artigo.
A história é a base do nosso sistema. Essencialmente, é um trecho de código nomeado que mostra algum estado “interessante” de um componente de UI. Pode ser representado como uma estrutura simples:
struct Story { let name: String let makeView: () -> UIView? }
O próximo objeto é ComponentStories
, que contém uma lista de histórias de um componente:
protocol ComponentStories { var componentName: String { get } var stories: [Story] { get } }
Então, como vamos declarar um snippet? Para facilitar a análise e a conveniência, a função pode ter uma convenção de nomenclatura especial que começa com story_
. Em geral, cada trecho de código de uma história:
Aqui está um exemplo de trecho:
@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 melhorar a experiência de escrita de histórias e reduzir a quantidade de clichês fazendo uso da natureza dinâmica do Objective-C. A ideia é ter uma classe base que seja capaz de recuperar todos os seletores de classe que começam com store_
e construir uma lista de histórias a partir deles.
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 exemplo, uma classe que hospeda as histórias do UILabel pode ser estruturada da seguinte forma:
class UILabelStories: DynamicComponentStories { @objc static func story_BasicLabel() -> UIView { … } @objc static func story_FixedWidthLabel() -> UIView { … } }
Um aplicativo de catálogo, em sua forma mais simples, normalmente consiste em duas telas.
Tela Principal: Esta tela exibe uma lista de componentes e suas histórias associadas.
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) } }
Tela de detalhes: Nesta tela exibimos um componente no centro sem restringir sua largura ou altura, pois cabe ao snippet definir esses 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 o fluxo do catálogo pode ser inicializado da seguinte maneira:
let vc = StorybookViewController(componentsStories: [ UILabelStories(), UIButtonStories(), UITextFieldStories(), ]) let nc = UINavigationController(rootViewController: vc) window.rootViewController = nc
Podemos pegar a ideia do PreviewContainer e sua implementação em 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) } }
E escrever nossas prévias tão simples quanto isto
struct UIButton_Previews: PreviewProvider { static var previews: some View { ForEach(UIButtonStories().stories, id: \.name) { story in PreviewContainer { story.makeView()! } .previewDisplayName(story.name) } } }
Em nosso exemplo, usaremos a biblioteca iOSSnapshotTestCase .
Com o iOS 17, algumas adições valiosas à API UIView
foram introduzidas para substituir características por meio de UIView.traitOverrides . Esta propriedade é extremamente útil para suporte de acessibilidade de teste de snapshot. Para nossos testes de instantâneo, testaremos a mesma visualização em várias condições, aplicando RTL, modo escuro e algumas categorias de tipo dinâmico.
É importante observar que, para testar traitOverrides
, precisamos usar o método drawViewHierarchy(in:afterScreenUpdates:)
e garantir que as visualizações que estamos testando sejam adicionadas à chave UIWindow
do aplicativo.
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() } }
Na verdade, com esses preparativos feitos, criar um caso de teste torna-se bastante simples:
final class UIButtonTests: NativeBookSnapshotTestCase { func test() { // recordMode = true runTests(for: UIButtonStories()) } }
Para esta tarefa, usaremos uma pilha diferente: modelos Node.js, TypeScript e EJS. É muito mais fácil trabalhar com páginas baseadas em HTML usando ferramentas nativas delas.
A primeira coisa que precisamos é de algum tipo de arquivo de configuração onde possamos vincular nossos trechos de código e testes de instantâneo. Um arquivo JSON simples funciona bem para essa finalidade.
{ "components": [ { "name": "UILabel", "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift", "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests" }, … ] }
Depois de criar um aplicativo Node.js simples, vamos definir um modelo.
interface Story { name: string; codeSnippet: string; } interface Component { name: string; stories: Story[]; snapshotsFolderPath: string; }
O desafio interessante é como extrair trechos de código de um arquivo Swift. Isso pode ser conseguido simplesmente usando 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; }
A geração de HTML pode ser feita usando EJS, um poderoso mecanismo de template que nos permite usar JavaScript dentro de um 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, } ) ); }
Agora, combinando tudo na função 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); } })();
A demonstração está disponível em https://psharanda.github.io/NativeBook
Para gerar documentação no repositório NativeBook, você precisa executar os seguintes comandos:
cd native_book_gen npm install npm run native-book-gen
Ou simplesmente execute:
sh generate_native_book.sh
A documentação aparecerá na pasta docs
Esperamos que você tenha gostado da viagem. NativeBook , em seu estado atual, é uma prova de conceito, mas com algumas adições e integração adequada ao fluxo de trabalho do desenvolvedor, pode começar a agregar muito valor.
Também podemos imaginar o seguinte aprimoramento: