SwiftUI in der Praxis, Teil 1

heise Developer stellt in einem Zweiteiler den Einsatz von SwiftUI anhand der Entwicklung einer kleinen Beispiel-App für iOS vor.

In Pocket speichern vorlesen Druckansicht 13 Kommentare lesen
SwiftUI in der Praxis, Teil 1

(Bild: Paweł Kuźniar (unter der GFDL))

Lesezeit: 14 Min.
Von
  • Thomas Sillmann
Inhaltsverzeichnis

Mit SwiftUI verändern sich einige Konzepte, die typisch für die UI-Gestaltung unter AppKit, UIKit und WatchKit sind. Das hängt primär mit dem Umstand zusammen, dass SwiftUI auf eine deklarative statt eine imperative Syntax setzt. Statt also Views durch den Aufruf von Methoden zu erstellen und ihr Verhalten zu steuern, kommt für sie in SwiftUI ein Status zum Einsatz. Er ist maßgeblich für das Aussehen und die Funktionsweise einer View verantwortlich. Ändert er sich, sorgt das auch für eine automatische Erneuerung der View.

Um die Arbeit mit SwiftUI und das grundlegende Konzept detailliert vorzustellen und zu erläutern, zeigt dieser Artikel die vollständige Umsetzung einer kleinen Beispiel-App für iOS auf Basis von SwiftUI. Sie stellt eine simple Notizverwaltung dar, in der sich Notizen samt Titel und Inhalt erstellen und bearbeiten lassen. Darüber hinaus gibt es eine Option, Notizen nachträglich als Favoriten zu kennzeichnen.

Um die Umsetzung des im Folgenden beschriebenen Beispiels nachzuvollziehen, braucht es nichts mehr als die aktuelle Version von Xcode. Als Erstes erstellt man ein neues iOS-Projekt auf Basis der "Single View App"-Vorlage und gibt ihr einen beliebigen Namen. Wichtig: Neben der Programmiersprache Swift muss im Bereich "User Interface" der Punkt "SwiftUI" ausgewählt sein (s. Abb. 1).

In Xcode lässt sich direkt ein neues Projekt auf Basis von SwiftUI erstellen (Abb. 1).

Ehe es im Detail um die Arbeit mit SwiftUI geht, muss man die notwendige Basis zur Arbeit mit der Notizen-App erstellen. Sie setzt sich aus zwei Klassen zusammen: Note und NotesManager. Ersteres dient zur Abbildung einer einzelnen Notiz, wofür die Klasse insgesamt drei Eigenschaften für Titel, Inhalt und eine mögliche Markierung als Favorit mitbringt. Die Eigenschaften werden direkt über einen passenden Initializer gesetzt, der für alle drei Properties einen Standardwert vorsieht. NotesManager fungiert als App-übergreifendes Singleton zum zentralen Zugriff auf alle Notizen. Dazu bringt sie ein Array mit, in dem man alle Notizen speichert.

Diese erste Basisversionen der beiden Klassen findet sich in Listing 1. Zu einem späteren Zeitpunkt nimmt der Autor an ihnen noch ein paar notwendige Anpassungen vor.

// Listing 1: Deklaration der Klassen Note und NotesManager
class Note {
    var title: String
    var content: String
    var isFavorite: Bool
    
    init(title: String = "", content: String = "", isFavorite: Bool = false) {
        self.title = title
        self.content = content
        self.isFavorite = isFavorite
    }
}

class NotesManager {
    static let shared = NotesManager()
    var notes = [Note]()
}

Für den Moment liegen genügend Informationen vor, um mit der Umsetzung des User Interface für die Notizen-App zu beginnen. Als Basis hierfür nutzen Entwickler die ContentView.swift-Datei, die Xcode automatisch zusammen mit dem Projekt erzeugt hat. Sie enthält Dummy-Code für eine erste View und einen Preview-Provider, der jene View über die Preview in Xcode einblendet.

Die App stellt alle in ihr enthaltenen Notizen in einer Listenansicht dar. Im ersten Schritt ergänzt man darum die ContentView.swift-Datei um eine gänzlich neue View namens NoteCell, die direkt unterhalb von ContentView implementiert wird. Über diese View lässt sich das Aussehen der Zellen innerhalb der Liste festlegen. Für jede anzuzeigende Notiz erstellen Entwickler im nächsten Schritt eine passende Instanz der View.

Die View ist nicht sonderlich komplex. Sie soll den Titel der zugehörigen Notiz sowie eine Sterngrafik anzeigen, sofern die Notiz als Favorit gekennzeichnet ist. Entsprechend ist die Basis der NoteCell-View ein HStack, der ein Text-Element für den Titel und ein Image für die Sterngrafik enthält. Letzteres wird nur angezeigt, wenn es sich bei der Notiz um einen Favoriten handelt. Um die View korrekt konfigurieren zu können, benötigt sie außerdem eine note-Property, über die sich ihr die darzustellende Notiz mit den zugehörigen Informationen übergeben lässt. Listing 2 zeigt die vollständige Implementierung der NoteCell-Struktur:

// Listing 2: Erstellen der Zelle für die Notizenliste
struct NoteCell: View {
    var note: Note
    
    var body: some View {
        HStack {
            Text(note.title)
            if note.isFavorite {
                Spacer()
                Image(systemName: "star.fill")
            }
        }
    }
}

Mit der Zelle als Basis setzen Entwickler nun die eigentliche Listenansicht um. Dazu überarbeiten sie die ContentView-Struktur innerhalb der ContentView.swift-Datei. Sie erhält zuerst eine Property namens notesManager, die das Singleton der NotesManager-Klasse zugewiesen bekommt. Sie fungiert als Quelle für die View, aus der sie die benötigten Informationen (sprich die anzuzeigenden Notizen) auslesen kann.

Um die Listenansicht umzusetzen, kommt eine SwiftUI-View namens List zum Einsatz. Sie erwartet eine Range und ein Design für die in ihr enthaltenen Zellen. Als Range nutzt man das notes-Array der eben erstellten notesManager-Property. Das sorgt dafür, dass für jede Notiz innerhalb des Arrays eine passende Zelle innerhalb der Liste erzeugt wird.

An dieser Stelle ist aber zusätzlich eine erste Anpassung am Model, genauer gesagt der Note-Klasse, vorzunehmen. List kann nämlich nur Instanzen eines Typs in Form eines Arrays durchlaufen, wenn der Typ konform zum Identifiable-Protokoll ist. Es besitzt als einzige Anforderung die Implementierung einer id-Property, die hashable ist. Im Beispiel erzeugen Entwickler dazu für jede neue Notiz schlicht eine Instanz auf Basis von UUID und weisen sie der neuen id-Property zu. Die überarbeitete Note-Klasse sieht man in Listing 3:

// Listing 3: Ergänzung der Note-Klasse um das Identifiable-Protokoll
class Note: Identifiable {
    var id = UUID()
    var title: String
    var content: String
    var isFavorite: Bool
    
    init(title: String = "", content: String = "", isFavorite: Bool = false) {
        self.title = title
        self.content = content
        self.isFavorite = isFavorite
    }
}

Nun ist List in der Lage, ein Array von Note-Instanzen zu durchlaufen. Listing 4 zeigt die entsprechende Implementierung von List innerhalb der body-Property der ContentView-Structure (der hier standardmäßig enthaltene "Hello world"-Text wurde entfernt). Für jede gefundene Notiz wird eine passende Instanz von NoteCell erzeugt.

// Listing 4: Erstellen einer Listenansicht für Notizen
struct ContentView: View {
    var notesManager = NotesManager.shared
    
    var body: some View {
        List(notesManager.notes) { note in
            NoteCell(note: note)
        }
    }
}

Über ContentView lassen sich nun zwar Notizen darstellen, doch noch fehlt eine Funktion, um überhaupt neue Notizen erzeugen zu können. Hierfür fügen Entwickler dem Xcode-Projekt eine neue SwiftUI-View über die entsprechende Vorlage hinzu (s. Abb. 2) und geben ihr den Namen NoteView. Sie soll gleichermaßen zum Erstellen neuer wie auch zur späteren Bearbeitung bestehender Notizen dienen. Deshalb erhält die View direkt eine Property namens note, über die ihr die zu bearbeitende Note-Instanz übergeben wird.

Neue Views lassen sich in Xcode über die Vorlage SwiftUI View einem Projekt hinzufügen (Abb. 2).

Generell wird die Implementierung dieser Property aber nun ein wenig komplexer. Schließlich soll NoteView nicht nur Informationen zu einer Notiz anzeigen, sondern sie auch ändern. Um das zu ermöglichen, ist die note-Property mit dem Property Wrapper @ObservedObject zu deklarieren. Das wiederum ist aber nur möglich, wenn die zugehörige Klasse (hier also Note) konform zum ObservableObject-Protokoll ist.

Das Protokoll ermöglicht es, SwiftUI-Views über Änderungen in bestimmten Properties zu informieren. Kommt es sodann zu einer solchen Änderung, werden die zugehörigen Views automatisch aktualisiert. Um solche Updates zu erhalten, sind die entsprechenden Properties mit dem @Published-Property-Wrapper zu deklarieren. Im Zuge der Aktualisierung der Note-Klasse nutzt man das direkt aus und versieht die drei Properties title, content und isFavorite damit. Wenn sich nun diese Eigenschaften einer Notiz ändern, werden alle zugehörigen Views automatisch entsprechend aktualisiert. Die nun erneut überarbeitete Variante der Note-Klasse zeigt Listing 5:

// Listing 5: Umsetzung der Note-Klasse als ObservableObject
class Note: Identifiable, ObservableObject {
    var id = UUID()
    @Published var title: String
    @Published var content: String
    @Published var isFavorite: Bool
    
    init(title: String = "", content: String = "", isFavorite: Bool = false) {
        self.title = title
        self.content = content
        self.isFavorite = isFavorite
    }
}

Nun wieder zurück zur NoteView. Ihre Basis ist ein VStack, der ein Textfeld zum Ändern des Titels und eine Text-View zur Bearbeitung des Inhalts enthält. Das Textfeld lässt sich über eine Instanz der Structure TextField umsetzen. Sie erwartet bei der Initialisierung zwei Parameter: einen Platzhalter-Text (im Beispiel "Title") und ein Binding zu jenem String, der angezeigt und möglicherweise geändert werden soll. Hierfür dient die title-Property der der View übergebenen Notiz. Auf die greift man über eine passende note-Property zu, die mit @ObservedObject deklariert ist. Sie erfordert es jedoch, eine passende Note-Instanz bei Initialisierung der NoteView zu übergeben. Das führt dazu, dass der Preview-Provider zwingend anzupassen ist, andernfalls kommt es zu einem Compiler-Fehler. Hier reicht es aber aus, eine einfache Dummy-Instanz für Preview-Zwecke zu verwenden. Den bisherigen Code für die NoteView-Structure zeigt Listing 6:

// Listing 6: Erste Version der NoteView-Structure (inklusive angepasstem Preview-Provider)
struct NoteView: View {
    @ObservedObject var note: Note
    
    var body: some View {
        VStack {
            TextField("Title", text: $note.title)
            // TODO: Implement text-view.
        }
    }
}

struct NoteView_Previews: PreviewProvider {
    static var previews: some View {
        NoteView(note: Note(title: "Lorem ipsum", content: "Lorem ipsum ..."))
    }
}

Eine wichtige Eigenschaft fehlt nun aber noch: die Text-View zur Anzeige und Bearbeitung des Inhalts einer Notiz (in Listing 6 an dem TODO-Kommentar zu erkennen). Das Problem ist hier: Eine View wie UITextView aus dem UIKit-Framework gibt es in SwiftUI (noch) nicht. Um also an der Stelle voranzukommen, müssen Entwickler eine UITextView-Instanz in die SwiftUI View einbinden. Deshalb ist für UITextView ein sogenanntes Representable in SwiftUI anzufertigen. Dazu erstellt man zunächst innerhalb des Projekts eine neue SwiftUI View und nennt sie TextView. Die body-Property inklusive Inhalt können Entwickler vollständig aus der neu erzeugten Datei entfernen; die benötigen sie in diesem Fall nicht. Stattdessen legen sie zunächst fest, dass TextView zum UIViewRepresentable-Protokoll konform ist – statt zum View-Protokoll.

UIViewRepresentable erlaubt es, beliebige Klassen auf Basis von UIView in SwiftUI einzubetten. Analog dazu stehen weitere Protokolle zur Verfügung:

  • UIViewControllerRepresentable für den Einsatz von UIViewController-Instanzen in SwiftUI.
  • NSViewControllerRepresentable für die Verwendung von NSViewController-Instanzen in SwiftUI.
  • NSViewRepresentable für den Einsatz von NSView-Instanzen in SwiftUI.
  • WKInterfaceObjectRepresentable für den Einsatz von WKInterfaceObject-Instanzen in SwiftUI.

Der Grundaufbau aller genannten Protokolle ist ähnlich. Sie besitzen jeweils eine eigene Make- und Update-Methode, die beide zu implementieren sind. Die Make-Methode erzeugt die eigesetzten Views oder View-Controllers aus dem AppKit-, UIKit- oder WatchKit-Framework. Die Update-Methode wird immer aufgerufen, wenn sich Daten der SwiftUI-View verändern.

Bevor Entwickler mit der Implementierung des UIViewRepresentable-Protokolls beginnen, müssen sie der neuen TextView-Structure zunächst noch eine Binding-Property namens text hinzufügen. Die verweist auf den Text, der innerhalb der Text-View angezeigt werden soll (also beispielsweise der Inhalt einer Notiz) und erlaubt es dank Binding, den Text auch zu verändern.

Nun geht es an die eigentliche Integration der UITextView in SwiftUI. Dazu implementieren Entwickler zunächst die makeUIView(context:)-Methode, deren Rückgabetyp sie explizit auf UITextView setzen. So geben sie explizit an, dass TextView auf UITextView basiert. Innerhalb der Make-Methode braucht man nichts weiter zu tun, als eine passende UITextView-Instanz zu erzeugen und zurückzugeben.

Im Anschluss folgt noch die Implementierung der Update-Methode namens updateUIView(_:context:). Die Methode ruft das System immer dann auf, wenn sich Daten der SwiftUI-View (sprich von TextView) ändern. TextView besitzt nur eine Eigenschaft namens text. Da deren Inhalt in der UITextView angezeigt werden soll, legen Entwickler in der Update-Methode fest, dass bei jeder Änderung von text ihr Wert der UITextView zugewiesen wird. Auf diese View lässt sich hierbei über den ersten Parameter der Update-Methode uiView zugreifen.

Beim Einsatz eines Representable-Protokolls wie UIViewRepresentable kommt aber noch eine weitere Besonderheit zum Tragen: der sogenannte Coordinator. Er ist eine unabhängige Klasse, die sich um die Kommunikation zwischen View beziehungsweise View-Controller aus AppKit, UIKit und WatchKit und der SwiftUI-View kümmert. Das ist immer dann wichtig, wenn eine View oder ein View-Controller Aktionen auslösen, die Entwickler in der zugehörigen SwiftUI-View auswerten müssen.

Ein solcher Fall liegt tatsächlich bei der Integration der UITextView vor. Zwar können Entwickler eine solche nun in SwiftUI mit der TextView-Structure initialisieren und ihr einen Text übergeben, jedoch fehlt noch die Logik, um Änderungen am Text aus der UITextView heraus zu übernehmen. Dafür würde sich im einfachsten Fall die Implementierung der textViewDidChange(_:)-Methode aus dem UITextViewDelegate-Protokoll eignen. Entsprechend braucht es einen Coordinator, der genau diese Anforderungen erfüllt und so Änderungen am Text an die text-Property der TextView-Structure übergibt.

Einen solchen Coordinator erstellt man typischerweise als Nested Type der SwiftUI View. Damit sie mit jener SwiftUI View kommunizieren kann, übergibt man ihr bei der Initialisierung eine passende Referenz. Die restliche Implementierung des Coordinators kümmert sich "nur" noch darum, eine reibungslose Kommunikation zwischen SwiftUI und AppKit, UIKit oder WatchKit sicherzustellen.

Wichtig: Um einen Coordinator in einem Representable wie TextView nutzen zu können, ist noch eine passende Make-Coordinator-Methode zu implementieren, die eine Instanz des gewünschten Coordinators erzeugt. Im Fall des UIViewRepresentable-Protokolls lautet diese Methode makeCoordinator(_:), als Rückgabetyp wird der zum Coordinator passende angegeben.

Damit der Coordinator im Fall der TextView nun seine Arbeit korrekt verrichten kann, müssen Entwickler innerhalb der makeUIView(context_:)-Methode sicherstellen, dass er als Delegate für die erzeugte UITextView-Instanz verwendet wird. Hierzu lässt sich der Coordinator über eine gleichnamige Property des context-Parameters auslesen. Die vollständige Implementierung der TextView-Structure findet man in Listing 7.

Da TextView eine Binding-Property bei der Initialisierung erwartet, ist im zugehörigen Preview-Provider ein passender Wert zu übergeben. Da dort aber typischerweise kein Binding zur Verfügung steht, kann man auch die Funktion constant(_:) nutzen, um einen statischen Wert für Preview-Zwecke zu übergeben.

// Listing 7: Integration einer UITextView in SwiftUI (inklusive Anpassung des Preview-Providers)
struct TextView: UIViewRepresentable {
    @Binding var text: String
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: UIViewRepresentableContext<TextView>) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<TextView>) {
        uiView.text = text
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextView
        
        init(_ textView: TextView) {
            parent = textView
        }
        
        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }
    }
}

struct TextView_Previews: PreviewProvider {
    static var previews: some View {
        TextView(text: .constant("Lorem ipsum ..."))
    }
}

Nun hat man eine UITextView als SwiftUI-View umgesetzt; inklusive passender Binding-Property, über die Entwickler den Inhalt der UITextView steuern und Änderungen am Text übernehmen können. Nun brauchen sie nur noch eine Instanz der TextView-Structure innerhalb der NoteView zu erstellen. Hierfür ersetzen sie den zuvor vorbereiteten TODO-Kommentar durch einen entsprechenden Befehl und übergeben als Binding den Inhalt der Notiz. Zusätzlich nutzen sie den border(_:width:)-Modifier, um einen Rahmen um die Text-View zu setzen und sie so vom Rest der Ansicht ein wenig abzuheben. Durch Aufruf des padding()-Modifiers auf dem VStack rücken Entwickler abschließend alle View-Elemente noch ein wenig ein, und auch den Titel einer Notiz zeigen sie noch einmal als Titel der Navigation-Bar an. Das kommt zum Tragen, wenn sie Notizen später wieder einblenden. Die vollständige Implementierung von NoteView findet sich in Listing 8 (siehe auch Abb. 3):

// Listing 8: Einsatz einer UITextView in SwiftUI 

struct NoteView: View {
@ObservedObject var note: Note
    
    var body: some View {
        VStack {
            TextField("Title", text: $note.title)
            TextView(text: $note.content)
                .border(Color.gray, width: 1)
        }
        .navigationBarTitle(note.title)
        .padding()
    }
}

UITextView aus dem UIKit-Framework lässt sich nun innerhalb von SwiftUI einsetzen (Abb. 3).

Das Fundament für die Notizen-App steht nun. Es stehen Ansichten zur Auflistung aller Notizen und eine Bearbeitungsmaske bereit. Für Letztere nutzt man eine UITextView, die mit einem Representable in SwiftUI integriert wurde.

Im Folgeartikel fügt der Autor die verschiedenen Teile zu einem großen Ganzen zusammen, um Notizen erstellen und anzeigen zu können. Abschließend werden noch ein paar kleinere Optimierungen an der Code-Basis vorgenommen.

Thomas Sillmann
ist Autor, Apple Developer und Trainer. Mit seiner Begeisterung für das Schreiben hat er bereits mehrere Fachbücher, Fachartikel sowie Online-Kurse veröffentlicht. Thomas lebt und arbeitet in Aschaffenburg. (ane)