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.
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)
}
}
}
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")
// ...
}
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) {}
}
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()
}
}
}
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.
}
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.
}
}
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>
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”).
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.