Integration von UIKit in SwiftUI

Wie UIHostingController verwendet werden kann, um SwiftUI Views in UIKit zu integrieren habt ihr bereits gesehen. Wie funktioniert es jedoch andersrum — wie können Views oder View-Controller aus der UIKit-Welt in SwiftUI integriert werden? Wieso ist das überhaupt nötig? Im vorherigen Teil der Serie haben wir uns mit einem modal angezeigten View-Controller beschäftigt, der sich selber wieder ausblendet und keine Verbindungen zu anderen View-Controllern hat. Es kann nun aber auch sein, dass ein View-Controller migriert werden soll, der in der Mitte oder am Anfang einer Navigationsabfolge steht. Je nach verwendeter Architektur möchte man in diesem Fall aus SwiftUI heraus auf einen UIKit-View-Controller verweisen, um dorthin navigieren zu können. Ein anderer Grund kann sein, dass man einen View-Controller oder eine View aus der UIKit-Welt in eine SwiftUI-View einfügen möchte. Da SwiftUI ein noch sehr junges Framework ist, sind noch nicht alle Bedienelemente verfügbar, die es in UIKit gibt. So gibt es etwa noch keine UIActivityViewController-Alternative die z. B. zum Teilen von Inhalten aus einer App verwendet werden kann.

Die Lösung für das Problem sind die von SwiftUI bereitgestellten Protokolle UIViewRepresentable und UIViewControllerRepresentable mittels derer UIKit-Views bzw. View-Controller SwiftUI-kompatibel gemacht werden können. Bei der Migration der „Image Recognition Playground”-App soll als Nächstes der CategoryViewController migriert werden. Dieser View-Controller zeigt alle Bilder an, die einer bestimmten Kategorie eines Datensatzes zugeordnet sind ( z. B. alle Bilder von Hotdogs). Wie im Storyboard unten zu sehen (vorletzter View-Controller unten), gehen vom CategoryViewController zwei Navigationswege zu anderen View-Controllern ab. Einer von diesen ist der CameraViewController. Dieser dient dazu, um Fotos von Objekten aufzunehmen, um den bestehenden Datensatz zu erweitern. Dieser View-Controller soll erst zu einem späteren Zeitpunkt migriert werden und dementsprechend vorerst weiterhin in seiner UIKit-Version benutzt werden.

Storyboard der „Image Recognition Playground”-App vor der Migration
Storyboard der „Image Recognition Playground”-App vor der Migration

Um den CameraViewController in SwiftUI verwenden zu können, benutzen wir also das UIViewControllerRepresentable -Protokoll. Zuerst legen wir eine neue View CameraView an, welche die Anforderungen des Protokolls erfüllt: Dafür müssen zwingend die Methoden makeUIViewController(context:) und updateUIViewController(_:context:) implementiert werden. Erstere wird genutzt, um den View-Controller zu initialisieren und zweitere, um ihn zu updaten, wenn sich ein Zustand in SwiftUI ändert.

Immer wenn ein neues Foto geschossen wurde, ruft der CameraViewController die onPictureTaken(image:)-Methode des CameraViewControllerDelegate-Protokolls auf. Damit die Fotos an eine SwiftUI-View übergeben werden können, müssen wir zusätzlich einen Coordinator implementieren, der die Anforderung des CameraViewControllerDelegate-Protokolls erfüllt und ihn in der makeCoordinator()-Methode des UIViewControllerRepresentable-Protokolls initialisieren. Die CameraView versehen wir dann mit einer Property handleTakenPicture vom Typ (UIImage) -> Void, also einer Funktion die ein UIImage entgegennimmt. Jedes Mal, wenn der Coordinator mittels onPictureTaken(image:) Methode über ein neu geschossenes Foto informiert wird, ruft er die handleTakenPicture-Methode auf und leitet das Bild somit weiter.

import SwiftUI

typealias TakenPictureHandler = (UIImage) -> Void

struct CameraView: UIViewControllerRepresentable {
    var handleTakenPicture: TakenPictureHandler
    
    func makeUIViewController(context: Context) -> CameraViewController {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        guard let cameraVC = storyboard.instantiateViewController(
          identifier: "CameraVC") as? CameraViewController else {
            fatalError("\(#function) Can't load CameraViewController")
        }
        cameraVC.delegate = context.coordinator
        return cameraVC
    }
    
    func updateUIViewController(_ uiViewController: CameraViewController,
                                context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator(handleTakenPicture: handleTakenPicture)
    }
    
    class Coordinator: NSObject, CameraViewControllerDelegate,
                       UINavigationControllerDelegate {
        var handleTakenPicture: TakenPictureHandler
        
        init(handleTakenPicture: @escaping TakenPictureHandler) {
            self.handleTakenPicture = handleTakenPicture
        }
        
        func onPictureTaken(image: UIImage) {
            handleTakenPicture(image)
        }
    }
}
CameraView (SwiftUI) die auf dem CameraViewController (UIKit) basiert

Bei der Migration des CategoryViewController zu einer SwiftUI-Version, können wir nun einen Button in Form eines NavigationLink einbauen, der zur CameraView navigiert, die wiederum den CameraViewController darstellt. Bei der Initialisierung der CameraView können wir die handleTakenPicture-Methode übergeben. In unserem Fall gibt diese das Bild an ein ViewModel weiter.

NavigationLink(destination: CameraView() { image in
    DispatchQueue.main.async {
        self.manager.add(image)
    }
}) {
    Image(systemName: "camera")
    // ...
}
Navigation zur CameraView mittels NavigationLink
ObjectCategoryView (SwiftUI) CameraView (SwiftUI) bestehend aus CameraViewController (UIKit)
Links: ObjectCategoryView (SwiftUI)
Rechts: CameraView (SwiftUI) bestehend aus CameraViewController (UIKit)

Migration des Einstiegspunkts und Navigation-Controllers

Ab einem gewissen Punkt der Migration wird man in die Situation kommen, dass der initiale View-Controller migriert werden soll. Dafür muss der Einstiegspunkt der App angepasst werden. Gleiches gilt für den Fall, dass der benutzte NavigationController (UIKit) zu einer NavigationView (SwiftUI) migriert werden soll.

Der Einstiegspunkt wird durch das App-Lifecycle-Management bestimmt. In den letzten zwei iOS/iPadOS-Versionen wurden daran Änderungen vorgenommen. Unter iOS/iPadOS 12 wurde der Lifecycle über das AppDelegate überwacht. Mit Version 13 ( in der SwiftUI eingeführt wurde) wurde zusätzlich zum AppDelegate das SceneDelegate eingeführt, um es zu erlauben, mehrere Fenster einer App öffnen zu können. SceneDelegate ist jedoch weiterhin eine Lösung aus der UIKit-Welt. Mit iOS 14 wurde ein neues SwiftUI-basiertes App-Lifecycle-Management eingeführt, wodurch SceneDelegate und AppDelegate nicht mehr zwingend verwendet werden müssen. Der Nachteil daran ist, dass es zum einen noch nicht alle Funktionen der SceneDelegate und AppDelegate Lösung bietet und dass das Deployment-Target auf iOS/iPadOS 14 erhöht werden muss.

Da zum Start dieser Serie von Blogposts iOS/iPadOS 14 erst vor wenigen Tagen veröffentlicht wurde, nutzen wir die mit Version 13 kompatiblen Lösung. Im Folgenden wird jedoch gezeigt wie der Einstiegspunkt eines Projekts, dass vor Version 13 erstellt wurde — also noch nicht mit SceneDelegate-Lifecycle-Management ausgestattet ist – migriert werden kann. Als Erstes wird der initiale View-Controller mittels UIViewControllerRepresentable SwiftUI-kompatibel gemacht, wie im vorherigen Teil der Serie beschrieben. Im Falle der „Image Recognition Playground”-App, ist der DataSetLibraryViewController der initiale View-Controller. Sollte der entsprechende View-Controller bereits zu einer SwiftUI-View migriert worden sein, kann direkt mit dem nächsten Schritt weiter gemacht werden.

import SwiftUI

struct DataSetLibraryView: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> DataSetLibraryViewController {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        guard let dataSetLibraryVC = storyboard.instantiateViewController(
          identifier: "DataSetLibraryVC") as? DataSetLibraryViewController else {
            fatalError("\(#function) Can't load DataSetLibraryViewController")
        }
        return dataSetLibraryVC
    }
    
    func updateUIViewController(_ uiViewController: DataSetLibraryViewController,
                                context: Context) {}
}
DataSetLibraryView (SwiftUI) die auf dem DataSetLibraryViewController (UIKit) basiert

Als Nächstes erstellen wir die MainView in der die initiale View in einer NavigationView eingebettet wird.

import SwiftUI

struct MainView: View {
    var body: some View {
        NavigationView {
            DataSetLibraryView()
        }
    }
}
MainView mit enthaltener NavigationView und DataSetLibraryView

Nun müssen wir nur noch dafür sorgen, dass nicht mehr der initiale View-Controller vom Storyboard bzw. programmatisch geladen wird, sondern die vorher implementierte MainView. Dafür erweitern wir die bestehende AppDelegate-Klasse um folgende zwei Methoden:

// MARK: UISceneSession Lifecycle

func application(_ application: UIApplication,
                 configurationForConnecting connectingSceneSession: UISceneSession,
                 options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    // Called when a new scene session is being created.
    // Use this method to select a configuration to create the new scene with.
    return UISceneConfiguration(name: "Default Configuration",
                                sessionRole: connectingSceneSession.role)
}

func application(_ application: UIApplication,
                 didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
    // Called when the user discards a scene session.
    // If any sessions were discarded while the application was not running,
    // this will be called shortly after application:didFinishLaunchingWithOptions.
    // Use this method to release any resources that were specific
    // to the discarded scenes, as they will not return.
}
UISceneSession-Lifecycle-Methoden im AppDelegate

Anschließend erstellen wir eine Datei SceneDelegate.swift mit folgendem Inhalt:

import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach
        // the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will
        // automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session
        // are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let mainView = MainView()

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: mainView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    func sceneDidDisconnect(_ scene: UIScene) {
        // Called as the scene is being released by the system.
        // This occurs shortly after the scene enters the background,
        // or when its session is discarded.
        // Release any resources associated with this scene
        // that can be re-created the next time the scene connects.
        // The scene may re-connect later, as its session was not
        // neccessarily discarded (see `application:didDiscardSceneSessions` instead).
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        // Called when the scene has moved from an inactive state to an active state.
        // Use this method to restart any tasks that were paused
        // (or not yet started) when the scene was inactive.
    }

    func sceneWillResignActive(_ scene: UIScene) {
        // Called when the scene will move from an active state to an inactive state.
        // This may occur due to temporary interruptions (ex. an incoming phone call).
    }

    func sceneWillEnterForeground(_ scene: UIScene) {
        // Called as the scene transitions from the background to the foreground.
        // Use this method to undo the changes made on entering the background.
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // Called as the scene transitions from the foreground to the background.
        // Use this method to save data, release shared resources,
        // and store enough scene-specific state information
        // to restore the scene back to its current state.
    }
}
UISceneDelegate-Lifecycle-Methoden in SceneDelegate

Wie im Code zu sehen, wird die MainView wiederum in einem UIHostingController verpackt, um sie UIKit-kompatibel zu machen. Dies ist nötig, da wie zu Anfang erwähnt, das AppDelegate und SceneDelegate Lifecycle-Management aus der UIKit-Welt stammt und einen View-Controller erwartet. Als letzter Schritt muss noch die Info.plist der App angepasst werden. Dafür öffnen wir die Datei als Quelltext in Xcode: Rechtsklick auf Info.plist im „Project Navigator” > „Open As” > „Source Code”. Nachdem folgender Code eingefügt wurde, sollte die App beim Start nun die MainView laden.


<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
    <key>UIWindowSceneSessionRoleApplication</key>
    <array>
        <dict>
            <key>UISceneConfigurationName</key>
            <string>Default Configuration</string>
            <key>UISceneDelegateClassName</key>
            <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
        </dict>
    </array>
</dict>
</dict>
Info.plist Ergänzung um UIApplicationSceneManifest
Öffnen der Info.plist als Quelltext
Öffnen der Info.plist als Quelltext

Sollte es zu unerwartetem Verhalten kommen, kann es helfen die App auf dem Testgerät oder Simulator zu löschen und neu zu installieren. Eine weitere Option ist das Löschen des Inhalts des DerivedData-Ordners. Der entsprechende Pfad kann in den Xcode-Einstellungen gefunden werden („Xcode” > „Preferences” > „Locations”).

Bestimmung des DerivedData Speicherorts
Bestimmung des DerivedData-Speicherorts

Weiterführende Informationen

In dieser Blogpostserie wurden die Werkzeuge und Einstellungen vorgestellt, die benötigt werden, um eine iOS/iPadOS-App von UIKit zu SwiftUI zu migrieren. Um den Einstieg in SwiftUI zu erleichtern, bietet Apple einige Ressourcen an. So gibt es z. B. schriftliche Schritt-für-Schritt-Anleitungen mit Code-Beispielen oder Videotalks der „Worldwide Developer Conference”. Zum Einstieg bietet sich der Talk »Introduction to SwiftUI« an. Weitere Videos beschäftigen sich z. B. mit den diesjährigen Neuerungen in SwiftUI sowie mit dem in diesem Blogpost erwähnten neuen SwiftUI-basierten App-Lifecycle-Management.