La mise en œuvre, la maintenance, la documentation et l'évolution d'un ensemble partagé de composants d'interface utilisateur constituent un défi dans une application à grande échelle. Les développeurs Web ont créé une solution puissante à ce problème : Storybook.js ( https://storybook.js.org ). Mais qu’en est-il du développement natif iOS ? Pouvons-nous vivre une expérience quelque peu similaire ? (Alerte spoiler : oui ! )
Mais d’abord, explorons le concept original de Storybook.js.
Storybook.js est un outil open source utilisé par les développeurs Web pour créer et tester des composants d'interface utilisateur pour les applications Web de manière isolée. Considérez-le comme un terrain de jeu où les développeurs peuvent créer et présenter des parties individuelles d'un site Web (comme des boutons, des formulaires et des barres de navigation) séparément du reste du site Web.
Storybook.js sert de documentation vivante des composants et fournit des visuels pour voir à quoi ressemblent et se comportent les composants dans différents états et scénarios, ainsi que des exemples de code.
L'élégance de Storybook.js réside dans le fait que les composants de l'interface utilisateur sont utilisés dans le même environnement ; que ce soit sur un site Web de production ou sur un site Storybook, nous les exécutons toujours dans un navigateur Web. Cela garantit que les composants semblent cohérents dans notre documentation/terrain de jeu et en production, les empêchant ainsi d'être désynchronisés.
Les pages Web constituent également un excellent support de documentation, offrant une excellente expérience de lecture, un partage facile, des temps de chargement instantanés et une disponibilité universelle sur n'importe quel appareil et système d'exploitation.
Cependant, pour les applications iOS , l'histoire est différente.
Sur iOS, le paysage des outils et méthodes liés au développement de composants d’interface utilisateur est assez fragmenté. Jetons-y un coup d'œil du point de vue de Storybook.js.
Interface Builder fournit une interface glisser-déposer, ce qui facilite la conception d'interfaces utilisateur sans écrire de code. Habituellement, il est utilisé pour disposer les composants d’interface utilisateur existants pour des écrans entiers plutôt que pour le développement de composants d’interface utilisateur individuels. Cela ne fonctionne pas bien pour présenter les différents états des composants de l'interface utilisateur et pour gérer les interfaces utilisateur complexes en général, nécessitant souvent l'implémentation de nombreux aspects dans le code. Dans l’ensemble, beaucoup le considèrent comme une impasse, y compris Apple.
SwiftUI Previews est un outil créé par Apple pour développer et tester des vues SwiftUI dans Xcode.
Les aperçus SwiftUI peuvent également être utilisés pour les UIViews. Voir https://sarunw.com/posts/xcode-previews-with-uiview
Avantages:
Les inconvénients:
Quand cela fonctionne, les aperçus SwiftUI sont probablement l'expérience la plus proche de Storybook.js, à l'exception de l'aspect "documentation".
La prochaine chose que nous avons est de générer une documentation basée sur le code source, les commentaires et les annotations. Les exemples les plus notables sont :
DocC - https://developer.apple.com/documentation/docc
Jazzy - https://github.com/realm/jazzy
Ce type d'outils peut être utilisé pour créer des références API de composants sous la forme d'une page Web.
Les tests instantanés sont un excellent moyen de développement isolé, testant divers états visuels d'un composant et garantissant que les choses ne changent pas de manière inattendue.
Les deux bibliothèques les plus populaires qui implémentent les tests d'instantanés sur iOS sont :
Un autre choix évident pour l’expérience Storybook.js serait de créer une application de catalogue native personnalisée.
Avantages:
Les inconvénients:
Quelques exemples:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
Une idée intéressante, quoique extravagante, est d'intégrer une application de catalogue native dans le site Web Storybook.js à l'aide du service Appetize.io, qui permet de diffuser le contenu d'un appareil iOS sur une page Web - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133
C'est un fait que le développement mobile natif ne dispose pas de l'ingrédient secret nécessaire à une expérience de type Storyboard.js : un environnement unique pour exécuter la documentation, les terrains de jeu et la production, le tout au sein de la plate-forme la plus accessible : un site Web.
Mais que se passerait-il si nous combinions une application de catalogue, des tests instantanés et une génération de documentation HTML en un seul système ? Imaginez écrire un extrait de code une seule fois, puis être capable d'effectuer les opérations suivantes :
Bonnes nouvelles! J'ai construit une preuve de concept pour un tel système :
https://github.com/psharanda/NativeBook
Jetons un coup d'œil et assemblons-le !
NativeBook se concentre actuellement sur les composants basés sur UIView. Les composants SwiftUI peuvent également être intégrés de manière similaire, bien qu'ils ne soient pas abordés dans cet article.
L'histoire est la pierre angulaire de notre système. Essentiellement, il s'agit d'un extrait de code nommé qui présente un état « intéressant » d'un composant de l'interface utilisateur. Il peut être représenté comme une simple structure :
struct Story { let name: String let makeView: () -> UIView? }
L'objet suivant est ComponentStories
, qui contient une liste d'histoires pour un composant :
protocol ComponentStories { var componentName: String { get } var stories: [Story] { get } }
Alors, comment allons-nous déclarer un extrait de code ? Pour une analyse plus facile et plus pratique, la fonction peut avoir une convention de dénomination spéciale qui commence par story_
. En général, chaque extrait de code d'une histoire :
Voici un exemple d'extrait :
@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 }
Nous pouvons améliorer l'expérience d'écriture d'histoires et réduire la quantité de passe-partout en utilisant la nature dynamique d'Objective-C. L'idée est d'avoir une classe de base capable de récupérer tous les sélecteurs de classe commençant par store_
et de construire une liste d'histoires à partir d'eux.
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 } } }() }
Par exemple, une classe qui héberge les histoires pour UILabel peut être structurée comme suit :
class UILabelStories: DynamicComponentStories { @objc static func story_BasicLabel() -> UIView { … } @objc static func story_FixedWidthLabel() -> UIView { … } }
Une application de catalogue, dans sa forme la plus simple, se compose généralement de deux écrans.
Écran principal : cet écran affiche une liste de composants et leurs histoires associées.
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) } }
Écran Détails : Sur cet écran, nous affichons un composant au centre sans restreindre sa largeur ou sa hauteur, car c'est au snippet de définir ces attributs.
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), ]) } }
L'ensemble du flux de catalogue peut être initialisé de la manière suivante :
let vc = StorybookViewController(componentsStories: [ UILabelStories(), UIButtonStories(), UITextFieldStories(), ]) let nc = UINavigationController(rootViewController: vc) window.rootViewController = nc
Nous pouvons reprendre l'idée PreviewContainer et sa mise en œuvre sur 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) } }
Et écrivez nos aperçus aussi simplement que ceci
struct UIButton_Previews: PreviewProvider { static var previews: some View { ForEach(UIButtonStories().stories, id: \.name) { story in PreviewContainer { story.makeView()! } .previewDisplayName(story.name) } } }
Dans notre exemple, nous utiliserons la bibliothèque iOSSnapshotTestCase .
Avec iOS 17, certains ajouts précieux à l'API UIView
ont été introduits pour remplacer les traits via UIView.traitOverrides . Cette propriété est extrêmement utile pour la prise en charge de l’accessibilité des tests instantanés. Pour nos tests d'instantanés, nous allons tester la même vue dans diverses conditions en appliquant RTL, le mode sombre et certaines catégories de types dynamiques.
Il est important de noter que pour tester traitOverrides
, nous devons utiliser la méthode drawViewHierarchy(in:afterScreenUpdates:)
et nous assurer que les vues que nous testons sont ajoutées à la clé UIWindow
de l'application.
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() } }
En effet, une fois ces préparatifs en place, créer un scénario de test devient assez simple :
final class UIButtonTests: NativeBookSnapshotTestCase { func test() { // recordMode = true runTests(for: UIButtonStories()) } }
Pour cette tâche, nous allons utiliser une pile différente : modèles Node.js, TypeScript et EJS. Il est beaucoup plus facile de travailler avec des pages HTML à l’aide d’outils qui leur sont natifs.
La première chose dont nous avons besoin est une sorte de fichier de configuration dans lequel nous pouvons lier nos extraits de code et nos tests d'instantanés. Un simple fichier JSON fonctionne bien à cet effet.
{ "components": [ { "name": "UILabel", "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift", "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests" }, … ] }
Après avoir créé une simple application Node.js, définissons un modèle.
interface Story { name: string; codeSnippet: string; } interface Component { name: string; stories: Story[]; snapshotsFolderPath: string; }
Le défi intéressant est de savoir comment extraire des extraits de code d'un fichier Swift. Ceci peut être réalisé tout simplement en utilisant des 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; }
La génération HTML peut être effectuée à l'aide d'EJS, un puissant moteur de modèles qui nous permet d'utiliser JavaScript dans un modèle :
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, } ) ); }
Maintenant, en combinant tout dans la fonction principale :
(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 démo est disponible sur https://psharanda.github.io/NativeBook
Afin de générer de la documentation dans le dépôt NativeBook, vous devez exécuter les commandes suivantes :
cd native_book_gen npm install npm run native-book-gen
Ou exécutez simplement :
sh generate_native_book.sh
La documentation apparaîtra dans le dossier docs
J'espère que vous avez apprécié le voyage. NativeBook , dans son état actuel, est une preuve de concept, mais avec quelques ajouts et une bonne intégration dans le flux de travail d'un développeur, il peut commencer à apporter beaucoup de valeur.
On peut également imaginer l'amélioration suivante :