Introduction à SwiftUI

Dans cet article, nous présenterons de manière générale SwiftUI, le dernier framework d’interface utilisateur introduit par Apple lors de la WWDC de 2019 [1].

Un peu d’histoire

Depuis la sortie de l’iPhone et d’iOS en 2007, les outils de création d’applications sur cette plateforme ont beaucoup évolué [2]. Côté langage, nous sommes passés de l’Objective-C (initialement sans garbage collector) à Swift. À l’heure où nous écrivons ces lignes, Swift en est à la version 5.5 [3]. Au niveau du système d’exploitation, nous en sommes à la version 15 [4].
De nombreuses fonctionnalités sont apparues en 12 ans. La complexité augmentant, les outils ont ainsi été modifiés pour accompagner ces différents usages. Entre autres, les frameworks ont graduellement ajouté des couches d’abstraction tout en assurant un contrôle fin sur les fonctionnalités.
UIKit, le framework d’interface historique et membre de l’iOS SDK, a ajouté de nombreux contrôles et possibilités au fil des versions d’iOS. Il continue d’évoluer encore à l’heure actuelle.

Pourquoi SwiftUI ?

Au lancement des premiers iPhones, les interfaces sont rudimentaires : peu de contrôles (boutons, gestures), listes uniques etc. Or, cette simplicité s’est perdue peu à peu avec l’enrichissement des interfaces utilisateur. Le but de SwiftUI est de simplifier cette création et d’ainsi diminuer les temps de développement :
Il est “cross device”. C'est-à-dire que l’on peut maintenant développer pour iOS, iPadOS, WatchOS, et MacOS avec un seul et même framework (il fallait avant en utiliser plusieurs : AppKit pour une application MacOS, UIKit pour une application iOS, etc.) [5].
Les composants graphiques sont plus compréhensibles (Stack, List) et beaucoup plus simples. Il n’y a plus besoin d’implémenter d’obscures Delegate [6].
Il utilise une syntaxe déclarative (moins verbeuse).

Ce dernier point, non des moindres, amorce un changement de paradigme que nous allons détailler ci-après.

Déclaratif vs. impératif

Alors que UIKit est un framework plutôt impératif, SwiftUI est un framework déclaratif. On ne décrit plus l’implémentation des différents éléments de la vue mais on décrit sa structure. En somme, cela revient à se poser la question “Comment ?” pour l’impératif et “Quoi ?” pour le déclaratif. Voici un exemple simple :

Avec UIKit (impératif) :

let stack = UIStackView(frame: CGRect(0,0,100,100))
stack.axis = .vertical

let label = UILabel()
label.text = “Hello!”
label.backgroundColor = .green

stack.arrangedSubviews.append(label)

Ici, label est la variable qui représente le texte. On définit son contenu via text et sa couleur via backgroundColor. On décrit chaque étape de modification.

Avec SwiftUI (déclaratif) :

VStack {
    Text(“Hello!”)
    	.background(Color.green)
}
.frame(width: 100, height: 100)

Dans ce cas, tout est chaîné et on ne récupère que le résultat final. La position se fait en fonction de son parent sans implémentation de contraintes comme avec l’auto-layout de UIKit.

La programmation déclarative est non seulement plus concise, mais de fait plus lisible pour les développeurs. Finalement on ne se concentre pas sur la manière dont les changements sont appliqués mais plutôt sur le résultat final.

Principe de fonctionnement de SwiftUI

SwiftUI compare l’état des vues pour relancer ou non un rendu, une transition ou une animation [7]. SwiftUI va utiliser l’identité des vues pour faire cette comparaison. Une identité peut être :

  • structurelle (sa position et son type dans son parent).
  • explicite (protocole Identifiable).

Capture-d-e-cran-2022-01-28-a--18.19.19
Figure 1 : Schéma de fonctionnement de SwiftUI

Comme le montre le schéma ci-dessus, SwiftUI va créer l’arbre des vues à chaque changement du state (issu de l’interaction utilisateur) et le comparer à la version précédente pour vérifier s’il doit rendre à nouveau certaines vues.
Une vue est une valeur (struct) et non une référence (class). La création d’une vue est peu coûteuse tout comme celle de l’arbre.
Attention : Il est impératif que les constructeurs des vues ne fassent pas d’opérations lourdes et complexes.

Intégration dans XCode

À partir de XCode 11, il est possible de visualiser ces changements avec SwiftUI en temps réel [8]. Grâce aux “live previews”, la moindre modification se voit instantanément. Pour cela, il suffit de charger la “preview” (celle-ci se met en pause dès que XCode détecte un changement dans le code).
Il est également possible de naviguer entre les différentes vues. Par exemple, si le bouton d’une vue amène sur une autre, nous pouvons directement faire ce parcours. On peut ainsi voir les transitions et animations sans même attendre le lancement du simulateur, contrairement à UIKit [9].

UIKit vs. SwiftUI par l’exemple

Afin d’illustrer clairement les différences entre UIKit et SwiftUI, prenons l’exemple d’une liste de nos séries favorites. Chaque élément visible comporte un libellé (le nom de la série) et un bouton Move to top qui permet de placer la série choisie en haut de la liste. L’implémentation a été faite en UIKit puis en SwiftUI. Nous avons essayé d’avoir un rendu identique entre les deux implémentations.

Voyons à présent le code avec UIKit :

Tout d’abord, nous créons l’interface via l’Interface Builder, puis nous ajoutons les éléments tout en spécifiant leurs contraintes. Nous retrouvons donc une CollectionView, avec une cellule SerieCell.

Capture-d-e-cran-2022-01-28-a--18.18.57
Figure 2 : Storyboard

Pour remplir la CollectionView avec des cellules, il faut d’abord créer la vue de celles-ci. Ici, rien de très compliqué puisque nous attachons simplement le titre et le bouton à l’Interface Builder (annotation @IBOutlet), puis lui attribuons les valeurs.

class SerieCell: UICollectionViewCell {
    @IBOutlet var title: UILabel!
    @IBOutlet var button: UIButton!
    var callBack: ((String) -> Void)?

    func setup(with serie: String, callback: @escaping (String) -> Void) {
        title.text = serie
        self.callBack = callback
        button.setTitle("Move to top", for: .normal)
 	button.addTarget(self, action: #selector(onClick), for: .touchUpInside)
    }
    
    @objc private func onClick() {
        guard let serie = title.text else {
            return
        }
        callBack?(serie)
    }
}

Enfin, il faut créer le ViewController. Celui-ci nous permet de peupler la CollectionView et de définir la méthode setToTop qui placera une cellule donnée en première position. Comme son nom l’indique, c’est lui qui va gérer la vue, et en l’occurrence faire les liens entre l’UI et la donnée.

final class SeriesViewController: UIViewController {

    var data: [String] = [
        "The Wire",
        "Breaking Bad",
        "Dexter",
        "Queen of the South",
        "The OA",
        "Stranger Things",
        "Narcos",
        "True Detective",
        "Game of Thrones"
    ]
    static let identifier = "SeriesViewController"
    static let reuseIdentifier = "SerieCell"

    @IBOutlet var collectionView: UICollectionView!

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.dataSource = self
        collectionView.delegate = self
    }

    func setToTop(serie: String) {
        data.removeAll { value in
            value == serie
        }
        data.insert(serie, at: 0)
        collectionView.reloadData() // <= needed to update the view
    }
}

extension SeriesViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
  	data.count
    }

    func collectionView(_ collectionView: UICollectionView,cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.reuseIdentifier, for: indexPath) as? SerieCell else {
            return UICollectionViewCell()
        }
        cell.setup(with: data[indexPath.row], callback: setToTop(serie:))
        return cell
    }
}

extension SeriesViewController: UICollectionViewDelegate {}

extension SeriesViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        CGSize(width: collectionView.frame.size.width, height: 50)
    }
}

Il nous aura fallu pas moins de 3 fichiers différents, soit 190 lignes de code pour obtenir ce résultat.

Côté SwiftUI, tout se trouve dans un seul fichier. Lorsque le code se complexifie, il est nécessaire de déléguer la logique au ViewModel, de créer des vues intermédiaires etc.

struct SwiftUIView: View {

    @State var data: [String] = [
	"The Wire",
	"Breaking Bad",
	"Dexter",
	"Queen of the South",
	"The OA",
	"Stranger Things",
	"Narcos",
	"True Detective",
	"Game of Thrones"
    ]

    var body: some View {
        VStack(spacing: .zero) {
            Text("SwiftUI Series")
                .font(.title)
                .padding(.bottom, 60)
            ScrollView {
                LazyVStack(spacing: .zero) {
                    ForEach(data, id: \.self) { text in
                        VStack(spacing: .zero) {
                            Spacer()
                            HStack(spacing: .zero) {
                                Text(text)
                                Spacer()
                                Button(action: {
                                    moveToTop(text)
                                }, label: {
                                    Text("Move to top")
                                        .font(.system(size: 15))
                                })
                            }
                            Spacer()
                            Divider()
                        }
                        .frame(height: 50)
                    }
                }
            }
            Spacer()
        }
        .padding(16)
    }


    private func moveToTop(_ serie: String) {
        data.removeAll { value in
            value == serie
        }
        data.insert(serie, at: 0)
    }
}

struct SwiftUIView_Previews: PreviewProvider {
    static var previews: some View {
        SwiftUIView()
    }
}

Capture-d-e-cran-2022-01-28-a--18.18.39
Figure 3 : SwifUIView preview

Dans le code ci-dessus, la donnée est un tableau de chaînes de caractères qui porte l’annotation @State. Cette annotation fait partie de ce que l’on désigne par “property wrapper”. Nous en donnerons ci-après quelques exemples.
La liste des séries est symbolisée par la LazyVStack. Le “lazy” signifie que seul le contenu visible va être construit. La boucle ForEach va construire chacune des cellules depuis la donnée passée. Une cellule est composée d’une HStack et d’un Divider. Les HStack permettent l’empilement de manière horizontale, les VStack verticalement. Par ailleurs, entre la HStack et le Divider, du padding est appliqué via .padding(.vertical, 16). Il s’agit d’un parmi les nombreux “modifiers” existants.

Property wrappers

Les property wrappers spécifiques à SwiftUI sont des annotations que l’on place devant les propriétés. Ils permettent de gérer l’état de la vue et de le conserver ou non en mémoire [10].

Voici quelques exemple de property wrapper :

  • State : Pour les propriétés simples qui ne quittent pas le scope de la vue.
  • StateObject : Pour conserver un objet de type référence observable instancié par la vue.
  • ObservedObject : Pour observer un objet de type référence observable que la vue ne possède pas.
  • Binding : Pour modifier un objet de type valeur que la vue ne possède pas.

Modifiers

Avec SwiftUI, l’utilisation des “modifiers” est très pratique car elle permet de customiser de manière simple les éléments d’une vue [11]. Toutefois, il faut bien garder en tête que chaque “modifier” appliqué modifiera la vue en créant une autre vue. C’est pourquoi l’ordre importe.
Pour illustrer ces propos, voici un petit exemple :

struct ModifiersExampleView: View {
  var body: some View {
    VStack {
      HStack {
        Text("This is an example")
          .font(.title)
      }
      .background(Color(.green))    // green background color first
      .padding()         	    // add padding
      .border(Color.black)
      HStack {
        Text("This is an example")
          .font(.title)
      }
      .padding()         	    // padding first
      .background(Color(.green))    // add green background color
      .border(Color.black)
    }
  }
}

Capture-d-e-cran-2022-01-28-a--18.19.37
Figure 4 : la preview associée à l’exemple des Modifiers

Il y a deux HStack contenant un texte. Les mêmes modifiers y sont appliqués mais dans un ordre différent :
Le premier définit la couleur de l’arrière-plan en jaune, puis y ajoute du padding. Une bordure noire a été ajoutée pour plus de visibilité.
Le deuxième quant à lui, applique d’abord le padding puis la colorisation de l’arrière-plan en vert.
Dans le premier cas, la couleur ne s’applique pas à toute la surface de la HStack puisque le padding n’est pris en compte qu’après la colorisation. On aurait facilement pu penser que le résultat serait identique au deuxième. Il faut donc être vigilant quant à l’ordre choisi.

Avec les deux implémentations présentées ci-dessus, nous pouvons d’ores et déjà dessiner quelques conclusions :

  • Volume de code :
    UIKit: 17 (CollectionViewCell) + 65 (ViewController) + 108 (Storyboard) = 190 lignes de code
    SwiftUI: 68 lignes (avec preview)
  • Lisibilité : L’écriture déclarative est beaucoup plus compréhensive et concise. Cette diminution du volume de code permet une meilleure lecture en diminuant le “bruit” engendré par les multiples déclarations du code impératif.

Disponibilité (iOS 13, production ready iOS 14)

SwiftUI est disponible depuis 2019 avec iOS 13. Cependant, les composants disponibles à cette époque étaient très limités, rendant son utilisation complexe dans un projet d’application mobile.
À la sortie d’iOS 14, l’API a gagné de nombreux composants, la rendant bien plus adaptée à la production dans un contexte professionnel. Les quelques fonctionnalités manquantes sont aisément compensées par l’interopérabilité entre SwiftUI et UIKit que nous allons expliquer ci-après.

Interopérabilité

Malgré sa facilité de prise en main, certaines fonctionnalités présentes avec UIKit manquent en SwiftUI, comme la possibilité de modifier la couleur de la Tabbar. Cependant, nous pouvons compléter ces manques en intégrant des fonctionnalités de UIKit. Il faut pour cela utiliser des protocoles spécifiques comme UIViewRepresentable ou UIViewControllerRepresentable.
Nous pouvons voir l’utilisation de UIViewControllerRepresentable dans notre projet d’exemple.
De même, nous pouvons intégrer des vues SwiftUI dans un projet UIKit via la classe UIHostingController. C’est un wrapper prenant en paramètre une vue SwiftUI [12]. Il faudra cependant satisfaire au moins une des trois conditions qui suivent :

  • La version du SDK de déploiement soit assez élevée pour embarquer la fonctionnalité.
  • Encapsuler son utilisation dans un test tel que :
if #available(iOS 13, *) {
	// use my UIHostingController
}
  • Utiliser dans une méthode précédée de l’annotation : @available(iOS 13, *) [13].

Parallèle avec les autres langages/frameworks

SwiftUI, de par sa structure et son système déclaratif, a de nombreux points communs avec des frameworks cross-platform, voire natifs, comme React, Flutter et Jetpack Compose.

En effet SwiftUI utilise entre autres l’annotation @State pour maintenir l’état interne d’une vue entre deux évaluations de celle-ci. En React il s’agit de this.state, en Jetpack compose remember.

Le cycle de vie est de la vue est géré par les modifiers : onAppear / onDisappear etc.
En React Native on retrouve les fonctions : componentDidMount / componentWillUnmount etc.

Apple ne prend pas position sur l’architecture à utiliser pour maintenir les états des vues. Il n’y a pas de préconisation du type : store unique (redux) VS ViewModel VS Presenter.

Cette proximité avec React est une porte ouverte au développeur Web souhaitant aller vers du natif, la courbe d’apprentissage est plus douce que via UIKit.

Conclusion

Avec SwiftUI, Apple a créé un framework nous permettant maintenant de décrire des interfaces utilisateur complexes de manière élégante.
De notre expérience, les vues peuvent en pratique être réalisées deux fois plus vite qu’avec UIKit.
En empruntant le système déclaratif, Apple a réduit la complexité de prise en main de son Framework, ouvrant ainsi à un plus grand nombre de développeurs l'accès à son OS.

Un tutoriel officiel est d’ailleurs fourni : https://developer.apple.com/tutorials/swiftui.
C’est un très bon point de départ pour se familiariser avec le framework et ses différents concepts.

Sources

[1] https://developer.apple.com/videos/play/wwdc2019/231/

[2] https://www.apple.com/fr/newsroom/2007/01/09Apple-Reinvents-the-Phone-with-iPhone/

[3] https://swift.org/download/

[4] https://www.apple.com/ios/ios-15/

[5] https://developer.apple.com/videos/play/wwdc2019/240

[6] https://developer.apple.com/documentation/uikit/uiapplication/1622936-delegate?language=objc

[7] https://www.objc.io/books/thinking-in-swiftui/

[8] https://developer.apple.com/documentation/swiftui/previews-in-xcode/

[9] https://swiftwithmajid.com/2021/03/10/mastering-swiftui-previews/

[10] https://www.hackingwithswift.com/quick-start/swiftui/all-swiftui-property-wrappers-explained-and-compared

[11] https://developer.apple.com/documentation/swiftui/image/modifier(_:)

[13] https://developer.apple.com/documentation/swiftui/uihostingcontroller

[13] https://developer.apple.com/documentation/swiftui/uiviewrepresentable