La navigation avec SwiftUI en iOS 16

Tous les développeurs utilisant SwiftUI peuvent le dire, la navigation est un gros point noir dans la construction d’une application complexe de production, qui peut contenir par exemple des deeplinks ou des redirections diverses. La complexité de cette navigation vient du fait que la redirection est directement rattachée à une vue, et peut difficilement s’en extraire.

Comment gérer la navigation ?

Depuis iOS 16.0, SwiftUI expose une nouvelle façon de gérer la navigation d’une application : la NavigationStack.

Elle permet de prendre en entrée un Binding vers une collection d’éléments Hashable et réagira à tous les changements de cette collection. Cette collection peut être une énumération de Pages de votre côté, avec éventuellement des paramètres nécessitant l’affichage d’un écran.

  enum Pages: Hashable, Codable {
      case home
      case menu
      case settings
    case profile(user: User)
  }

On peut donc imaginer une classe spécifique, qu’on appellera ici NavigationStorage qui permettra de gérer tout ce qui concerne la navigation, de stocker votre tableau de pages et de le partager à toute l’application en tant qu’EnvironmentObject.

  class NavigationStorage<Page: Hashable>: ObservableObject {
    @Published var path: [Page] = []
  }

A chaque ajout, ou suppression d’une page dans ce tableau, la NavigationStack réagira, et effectuera un push (ou pop) de votre écran.

  struct MainView: View {
    @StateObject private var navigation = NavigationStorage<Pages>()

    var body: some View {
      NavigationStack(path: $navigation.path) { 
        MyCustomView()
          .environmentObject(navigation)
                .navigationDestination(for: Pages.self) { route in
                    switch route {
                    case .home: HomeView()
            case .menu: MenuView()
                    case .settings: SettingsView()
                    }
                }
          }
    }
  }

Et comment on gère les deeplinks ?

Et bien, sur le même principe, il faut juste rajouter quelques méthodes 🙂

On va commencer par rajouter une méthode à notre NavigationStorage, qui va nous permettre d’interpréter les URLs entrantes, de transformer une URL en une valeur de Pages, et d’ajouter cette page à notre graphe de navigation.

  func handle(_ url: URL) {
    // Ici on transforme notre URL en une valeur de Pages
    let page = url.toNavigationPage
      path.append(page)
  }

💡 Note: On pourrait créer une classe URLHandler qui s’occuperait de faire toute la gestion des URLs entrantes, porté par le NavigationStorage, mais on va essayer de faire simple dans le cadre de cet article.

Ensuite, il nous faut écouter les URLs entrantes côté MainView grâce à onOpenURL

  NavigationStack(path: $navigation.path) { 
     // Rien ne change ici
  }
  .onOpenURL {
     navigation.handle($0)
  }

Et si je veux sauvegarder ma navigation?

Prenons l’exemple d’un tunnel de navigation comprenant plusieurs étapes d’un formulaire. Si l’utilisateur quitte le tunnel d’une quelconque manière, il peut être intéressant de lui restaurer ce tunnel dans le même état qu’au moment où il l’a quitté. C’est là qu’intervient le SceneStorage.

Le SceneStorage est présent depuis iOS 14 et suit donc l’introduction des Scenes dans l’environnement iOS.

Dans notre cas, il peut s’avérer très puissant puisqu’il permet de reconstituer le graphe de navigation (et le modifier ensuite si besoin) si l’app est kill ou passe d’un background au foreground.

Introduisons le à notre exemple développé plus haut, en rajoutant à notre MainView :

  @Environment(\.scenePhase) private var scenePhase
  @SceneStorage("navigation") private var path: Data?

La première variable est la scenePhase, elle va nous permettre de détecter les changements d’état de notre application, comme le passage de background à foreground, et inversement.

La deuxième, c’est le sceneStorage dont nous avons parlé plus haut, qui va nous permettre de sauvegarder le graphe de navigation en passant en background ou quand l’app est killée, et de le restaurer au lancement de l’app, ou au passage en foreground.

  NavigationStack(path: $navigation.path) { 
    // Rien ne change ici
  }
  .onChange(of: scenePhase) { phase in
     switch phase {
     case .background, .inactive:
         path = navigation.encoded()
     case .active:
         guard let path else { return }
         navigation.restore(from: path)
     @unknown default: break
     }
  }

Comme on peut le voir, au passage en background/inactive, on stocke le path, et on le restore au passage en active. Mais c’est quoi ces méthodes encoded() et restore(:) ?

Eh bien ce sont juste des méthodes qui vont encoder et décoder le graphe de navigation. Codable ça vous dit quelque chose ? 😛

Si vous avez bien regardé l’enum au début de l’article, elle conforme à Hashable pour pouvoir être utilisée dans une NavigationStack, mais aussi à Codable, pour lui permettre d’être encodée/décodée.

Voilà à quoi ressemble ces deux méthodes :

  extension NavigationStorage where Page: Codable {
      func encoded() -> Data? {
          try? encoder.encode(path)
      }

      func restore(from data: Data) {
          do {
              path = try decoder.decode([Page].self, from: data)
          } catch {
              path = []
          }
      }
  }

Pour résumer, le @SceneStorage attend de la Data pour pouvoir stocker un graphe de navigation, ce qui nous force à faire conformer notre enum de Pages à Codable, et de prévoir deux méthodes permettant d’encoder et de décoder ce graphe, pour pouvoir le sauvegarder puis restituer à une NavigationStack 🙂

Conclusion

La Navigation SwiftUI en iOS 16 facilite grandement la mise en place d’une navigation stable et générique, qui va permettre à des applications complexes de couvrir la plupart des cas d’usages qui n’étaient pas supportés jusque là. Enjoy 😀

 

Nathan PORTE – Développeur iOS