Integration von SwiftUI in UIKit

Die Voraussetzungen sind also erfüllt — doch wie kann nun mit der Migration begonnen werden? Glücklicherweise bietet SwiftUI Möglichkeiten einzelne Einheiten (etwa einen Screen oder auch nur ein Bedienelement) die in SwiftUI geschrieben sind in UIKit-Code einzubinden. Dadurch ist es möglich den alten UIKit-Code Schritt für Schritt auf SwiftUI zu migrieren oder aber vorerst nur neue Interface-Elemente in SwiftUI zu implementieren.

Um ein erstes Gefühl für SwiftUI zu bekommen, suchen wir uns einen bestehenden simplen View-Controller zur Migration aus: den TextViewController. Dessen Aufgabe ist es Informationen wie Impressum, Datenschutzbestimmungen und Lizenzvereinbarung bestehend aus einer Überschrift und einem mehrzeiligen Text anzuzeigen.

TextViewController im Storyboard (UIKit)
TextViewController im Storyboard (UIKit)

Als Erstes wird eine neue Datei TextView.swift angelegt, in der die View entworfen werden kann. In einem VStack, der die enthaltenen Elemente vertikal aneinander ausgerichtet, wird ein HStack bestehend aus dem cronn-Logo und einem Label (Text) platziert. Durch den Parameter alignment: .leading des VStack werden die enthaltenen Elemente je nach eingestellter Leserichtung ausgerichtet. Der Text wird durch sogenannte ViewModifier in seiner Erscheinung angepasst. Der lineLimit(1) ViewModifier sorgt dafür, dass der Text einzeilig, also ohne Zeilenumbrüche, dargestellt wird. Mittels font(.title) wird der Text im Standard iOS-Titel-Stil angezeigt. Der eigentliche Informationstext wird in einer ScrollView unterhalb der Kopfzeile angezeigt. Als letztes Element wird ein Button eingefügt, der bei Benutzung eine der View übergebene dismissAction()-Methode aufruft. Dieser besteht lediglich aus dem Text „OK”. Damit die Beschriftung zentriert positioniert wird, werden vor und nach diesem noch jeweils ein Spacer eingefügt. Schließlich erzeugt der padding() ViewModifier am VStack etwas Abstand zu den enthaltenen Elementen.

import SwiftUI

struct TextView: View {
    @ObservedObject var textStore: TextStore
    var dismissAction: (() -> Void)?

    var body: some View {
        VStack(alignment: .leading) {
            HStack() {
                Image("cronnGreyscale")
                Text(textStore.displayText.title)
                    .lineLimit(1)
                    .font(.title)
            }
            
            ScrollView {
                Text(textStore.displayText.body)
            }
            
            Button(action: {
                (self.dismissAction ?? {})()
            }) {
                Spacer()
                Text("OK")
                Spacer()
            }
        }.padding()
    }
}
TextView in SwiftUI

Entsprechend des bei SwiftUI genutzten Design-Patterns Model-View-ViewModel (MVVM) legen wir auch ein ViewModel TextStore an, welches auf das bereits bestehende Model DisplayText zugreift. In diesem einfachen Fall hat das ViewModel nur die Funktion Änderungen am Model zu überwachen und die View mittels @Published-Property-Wrapper über solche zu informieren. Würde sich zwischenzeitlich also der im Model gespeicherte Text ändern, würde die View automatisch neu gezeichnet. Dies wird im aktuellen Beispiel nicht passieren, ist aber für andere Anwendungsfälle sehr praktisch.

class TextStore: ObservableObject {
    @Published var displayText: DisplayText

    init(displayText: DisplayText) {
        self.displayText = displayText
    }
}

struct DisplayText: Codable {
    var title: String
    var body: String
}
TextStore (ViewModel) und DisplayText (Model)

Doch welche Möglichkeiten gibt es zur Orientierung beim Entwurf einer View? In der UIKit-Welt gibt es Storyboards oder .xib-Dateien in denen Views grafisch entworfen und zusammengeklickt werden können. Damit kann bereits vor dem Testen auf dem Simulator oder Gerät ein Eindruck von der Benutzeroberfläche gewonnen werden. SwiftUI sieht dafür Previews vor, die in der gleichen Datei wie die View implementiert werden. Diese ermöglichen es neben dem Code eine grafische Vorschau einzublenden. Dafür erstellen wir in der previews-Property eines PreviewProvider eine entsprechende View mit Beispieldaten. Ändert sich etwas im Code an der View, wird die Vorschau automatisch aktualisiert. Seit Xcode 14 können die Previews mittels previewDevice(_:) ViewModifier auch auf verschiedenen Gerätetypen angezeigt werden.

struct TextView_Previews: PreviewProvider {
    static var previews: some View {
        TextView(textStore:
            TextStore(displayText:
                DisplayText(
                    title: "Lorem ipsum dolor sit er elit lamet, consectetaur",
                    body: """
                            Lorem ipsum dolor sit amet, consetetur sadipscing 
                            elitr, sed diam nonumy eirmod tempor invidunt ut 
                            labore et dolore magna aliquyam erat, sed diam 
                            ...
                            Lorem ipsum dolor sit amet,
                          """
                )
            )
        )
        .previewDevice(PreviewDevice(rawValue: "iPhone 8"))
        .previewDisplayName("iPhone 8")
    }
}
TextView Preview in SwiftUI
TextView Preview (SwiftUI)
TextView Preview (SwiftUI)

Wie kann nun die TextView in das bestehende Projekt integriert werden, um den alten TextViewController zu ersetzen? Dafür gibt es den UIHostingController, welcher es erlaubt eine beliebige SwiftUI-View in einen UIKit-View-Controller einzubetten. Dem Initializer des View-Controllers übergeben wir dabei das ViewModel vom Typ TextStore, dass wiederum benötigt wird um die TextView zu initialisieren. Mittels super.init(rootView: textView) initialisieren wir dann den View-Controller mit der SwiftUI-View als rootView. Schließlich kommt noch die dismissAction() zum Tragen. Sollte der OK-Button getapt werden, lassen wir sich den View-Controller selber ausblenden.

import SwiftUI

class TextViewController: UIHostingController<TextView> {
    init(textStore: TextStore) {
        let textView = TextView(textStore: textStore)
        super.init(rootView: textView)

        rootView.dismissAction = {
            self.dismiss(animated: true, completion: nil)
        }
    }
    
    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
TextView eingebettet in TextViewController (UIHostingController)

Je nachdem wie der ursprüngliche View-Controller implementiert und genutzt wurde müssen nun noch kleinere Anpassungen vorgenommen werden. Im Falle der „Image Recognition Playground”-App wurde der TextViewController bisher programmatisch und nicht etwa via Storyboard-Segues präsentiert. Er wurde jedoch bisher über das Storyboard initialisiert, was nun auch programmatisch geschieht. Dafür wird die folgende Änderung vorgenommen.

let storyboard = UIStoryboard(name: "Main", bundle: nil)
let textVC = storyboard.instantiateViewController(
                            withIdentifier: "TextVC")
                            as! TextViewController
textVC.displayText = displayText
self.present(textVC, animated: true, completion: nil)
Vorher: Initialisierung und Präsentation des UIKit-TextViewController
let textStore = TextStore(displayText: displayText)
let textVC = TextViewController(textStore: textStore)
self.present(textVC, animated: true, completion: nil)
Nachher: Initialisierung und Präsentation des SwiftUI-TextViewController

Als letztes kann nun der TextViewController aus dem Storyboard entfernt werden. Im nächsten Post der Serie wird es darum gehen wie UIKit-Code in SwiftUI integriert und der Einstiegspunkt der App auf eine SwiftUI-View gesetzt werden kann.