Le Routing avec SwiftUI

Joey Barbier
7 min readMar 31, 2024

Optimisation et polyvalence du routing sur la stack Swift et SwiftUI.

Introduction

Dans le développement d’applications, le routage joue un rôle clé en dirigeant les utilisateurs à travers les différentes vues.

Cependant, les besoins modernes exigent une navigation non seulement fluide mais aussi intelligente, capable de s’adapter aux règles métier.

Nous allons commencer par une introduction rapide de la navigation native de SwiftUI, afin de rappeler ses avantages et ses contraintes avant d’introduire une approche personnalisée qui enrichit le routage de SwiftUI en y intégrant une souplesse et des possibilités accrues pour les développeurs.

(Optionnelle) Rappel: La navigation Native

iOS 16.0, a marqué une évolution significative dans le routing pour SwiftUI. Des outils tels que NavigationLink(value:) et NavigationDestination(for:).

Exemple:

NavigationStack {
List {
NavigationLink("Mint", value: Color.mint)
NavigationLink("Pink", value: Color.pink)
NavigationLink("Teal", value: Color.teal)
}
.navigationDestination(for: Color.self) { color in
ColorDetail(color: color)
}
.navigationTitle("Colors")
}

Avantages :

  • Simplicité : La syntaxe pour la navigation est épurée, rendant le code plus lisible et facile à comprendre.
  • Intégration : La navigation est conçue pour fonctionner de manière transparente avec les autres composants de SwiftUI, assurant une cohérence dans le développement des applications.
  • Surcharge : L’override est simple à mettre en place. Dans l’exemple ci-dessous FavorisDetail() sera affichée car c’est la première occurrence de .navigationDestination(Color.self) qui est rencontrée lors de la remontée des vues.
NavigationStack {
VStack {// 2eme vue rencontrée
List {// 1ere vue rencontrée
NavigationLink("Mint", value: Color.mint)
}
.navigationDestination(for: Color.self) { color in
FavorisDetail(color: color)
}
}
.navigationDestination(for: Color.self) { color in
ColorDetail(color: color)
}
.navigationTitle("Colors")
}

Limitations :

Cependant, cette simplicité vient avec son lot de contraintes. La navigation dans SwiftUI, bien qu’efficace pour des flux directs, montre ses limites lorsqu’il s’agit de scénarios plus complexes nécessitant une logique conditionnelle.

Par exemple, dans un cas où l’accès à une vue dépend de l’état de connexion de l’utilisateur, la solution native ne fournit pas de moyen direct pour intégrer ces vérifications avant de procéder à la navigation.

  • Manque de flexibilité: Les développeurs peuvent se heurter à des difficultés lorsqu’ils tentent d’implémenter des conditions ou des logiques spécifiques déclenchant la navigation.
  • Complexité accrue pour les scénarios avancés: Pour des parcours utilisateurs qui ne sont pas linéaires ou qui dépendent de multiples conditions, la mise en place peut devenir rapidement complexe et moins intuitive ce qui aura pour effet d’alourdir le code.

Face à ces problématiques, il devient évident qu’une approche plus personnalisée pourrait offrir la flexibilité nécessaire pour répondre aux exigences de navigation sophistiquées des applications modernes.

Repenser la Navigation avec Flexibilité !

L’approche flexible

La solution se base sur l’extension des capacités natives de SwiftUI ce qui permet une gestion plus fine de la navigation.

Elle repose sur deux principes fondamentaux :

  1. Séparation de la couche Navigation: L’abstraction de la navigation permet de ne pas alourdir le code de vue SwiftUI avec la logique de navigation.
  2. Personnaliser les Flux de Navigation: Créer des chemins de navigation non linéaires, conditionnés par des états d’application dynamiques, sans alourdir le code ni compromettre la lisibilité.

Mise en œuvre

Contrairement à ce que l’on pourrait penser, l’intégration de cette approche flexible, ne nécessite pas un remaniement complexe ni une courbe d’apprentissage abrupte.

Au cœur de cette solution se trouve l’idée d’étendre les classes natives de SwiftUI de manière intuitive.

1) — Rendre le Model “Navigable”

Dans un premier temps, nous rendons notre modèle de données “Navigable”. Pour cela, une extension des EnvironmentValues s'avère nécessaire.

Prenons l'exemple du modèle Viewer à rendre navigable :

extension EnvironmentValues {
var onTapViewer: ((Viewer)->Void)? {
get {
self [EnvironmentKey_onTapViewer.self]
}
set {
self [EnvironmentKey_onTapViewer.self] = newValue
}
}

struct EnvironmentKey_onTapViewer: EnvironmentKey {
static var defaultValue: ((Viewer) -> Void)?
}
}

🕵 — Astuce : Vous pouvez simplifier l’utilisation des EnvironmentValues avec une macro pour une meilleure lisibilité et réusabilité du code.

// Source: https://github.com/Wouter01/SwiftUI-Macros
extension EnvironmentValues {
@EnvironmentValue
var onTapViewer: ((Viewer)->Void)?
}

2) — Rendons la vue cliquable

Tout comme avec NavigationLink, nous devons ajouter du code au niveau de la vue pour notifier les vues parentes qu'un objet a été cliqué.

Voici comment procéder :

extension Viewer {
struct Row: View {
// Récupération de l'action à appeler lors d'un clic.
@Environment(\.onTapViewer) var onTap: ((Viewer) -> Void)?

var viewer: Viewer
var body: some View {
Text("- \(viewer.name) ")
.onTapGesture { // À l'occurrence d'un "Tap",
// on éxécute l'action définie dans "onTapViewer"
onTap?(viewer)
}
}
}
}

3 )— Définir l’action à effectuer avec l’Interactor

Au sein de notre interface utilisateur, l’utilisation de Viewer.Row nous permet de présenter les informations d'un Viewer.

Nous spécifions également l'action que notre Interactor (ou autre en fonction de votre architecture) doit exécuter en cas de clic sur cet élément.

Voici comment cela se met en place dans une vue démonstrative :

struct MyDemoView: View {
// Supposons que `viewer` soit déjà initialisé...

var body: some View {
VStack {
// Ma Row pour afficher mon modèle "Viewer"
Viewer.Row(viewer: viewer)
.environment(\.onTapViewer) { viewer in
// Appel à l'interactor pour gérer la navigation
interactor.routeToViewerDetails(viewer)
}
}
}
}

// coté intéractor:
class Interactor {
func routeToViewerDetails(_ viewer: Viewer) {
// Intégration d'une règle métier, par exemple vérifier
// si le viewer n'est pas un bot avant de permettre la navigation
guard !(viewer.isBot) else { return }
coordinator.goTo(.viewerDetails(viewer: viewer))
}
}

Dans cet exemple, avant d’exécuter la navigation vers les détails d’un Viewer, l'Interactor pourrait vérifier si le Viewer en question ne correspond pas à un bot. Cette vérification illustre comment des règles métier spécifiques peuvent être intégrées de manière transparente dans le flux de navigation.

4) — La surcharge des Actions de Navigation

Un des avantages majeurs de notre approche réside dans la capacité de surcharger les actions de navigation définies par .environment(\.onTapViewer), permettant ainsi aux vues enfant de spécifier ou de modifier l'action de navigation en fonction de leur contexte spécifique. Cela offre une flexibilité inégalée pour la gestion de la navigation dans des applications complexes.

Cas d’Usage: Personnalisation de la Navigation dans un Flux d’Utilisateur

Considérons une application où l’écran d’accueil présente différentes sections de contenu, telles que “Viewer”. Pour chaque section, il existe une navigation par défaut vers une vue de détail. Cependant, pour certains contenus spécifiques, comme les favoris, nous souhaitons offrir une expérience de navigation différente.

struct HomeView: View {
var body: some View {
VStack {
SectionsView()
.environment(\.onTapViewer) { viewer in
// Navigation par défaut
interactor.routeToDefaultDetail(viewer)
}
}
}
}

struct SectionsView: View {
var body: some View {
VStack {
// Section Favoris
Viewer.Row(viewer: viewerFav)
.environment(\.onTapViewer) { viewer in
// Surcharge de la navigation par défaut pour les favoris
interactor.routeToFavorisDetail(viewer)
}

// Pour ce Viewer, la navigation par défaut
// de HomeView est utilisée, car non surchargée
Viewer.Row(viewer: viewer)
}
}
}

Dans cet exemple, HomeView définit une action de navigation par défaut pour tous les models Viewer.

Toutefois, dans SectionsView, nous spécifions une navigation différente pour un viewerFav (favoris), en surchargeant l'action par défaut grâce à .environment(\.onTapViewer).

Cette approche illustre comment il est possible de personnaliser simplement la navigation au sein de la même hiérarchie de vues.

5 )— Orchestrer la Navigation : L’Approche du Coordinateur

Pour orchestrer la navigation dans nos applications SwiftUI, l’utilisation d’un Coordinateur est proposée. Il est crucial de comprendre que cette méthode constitue une option parmi d’autres pour structurer la navigation. Les développeurs sont libres d’adapter cette approche ou d’explorer d’autres solutions en fonction de leurs besoins et préférences spécifiques.

Le Coordinateur est au cœur de notre système de navigation, maintenant à jour une liste des destinations possibles et assurant la gestion cohérente et centralisée des transitions entre les vues.

@Observable class Coordinator {
var path = [NavigationItem]()

func goTo(_ item: NavigationItem) {
// Assurer l'exécution du changement de vue sur le MainThread
Task { @MainActor in
path.append(item)
}
}
}
extension Coordinator {
enum NavigationItem: Hashable, Sendable {
case viewerDetails(viewer: Viewer)
case viewerDetailsFav(viewer: Viewer)

@ViewBuilder
var getView: some View {
switch self {
case .viewerDetails(let viewer):
App.ViewerDetails(viewer: viewer)
case .viewerDetailsFav(let viewer):
App.ViewerDetailsFav(viewer: viewer)
}
}
}
}

Il est essentiel d’associer le Coordinator aux vues appropriées pour activer cette gestion de navigation :


@Bindable private var coordinator = Coordinator()

var body: some Scene {
WindowGroup {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: Coordinator.NavigationItem.self) { item in
item.getView
}
}
.environment(coordinator)
}
}

🎉 — Enjoy

Merci d’avoir lu cet article ! J’espère qu’il vous a inspiré pour vos projets SwiftUI. Pour des discussions autour du développement iOS et Web (Symfony, Next.js, React, Node, Vapor), retrouvez-moi :

Pour ceux qui souhaitent approfondir, le code de l’article est disponible sur mon GitHub : Medium-iOS-Routing. Jetez un œil et partagez vos retours !

Peut-être à bientôt en live sur Twitch !

--

--

Joey Barbier

Lead Tech iOS. Consultant et développeur pour applications iOS en remote depuis Lille. https://www.joeybarbier.com