SwiftUI Best Practices: Dynamische Binding-Generierung

Bindings spielen in SwiftUI eine enorm wichtige Rolle. Mit ihrer Hilfe geben wir eine Referenz eines Status an eine View weiter. Von dieser View aus kann die zugehörige Information ausgelesen und verändert werden. Zudem sind Bindings enorm flexibel und lassen sich mithilfe eines entsprechenden Initializers (init(get:set:)) ganz den eigenen Bedürfnissen entsprechend erzeugen.

Das Problem mit Optionals

Viele Views in SwiftUI setzen auf Bindings. Nehmen wir als Beispiel TextEditor. Die View benötigt ein Binding vom Typ String. Was aber, wenn der zugrundeliegende Status, den man als Binding nutzen möchte, ein Optional ist? Dazu ein Beispiel:

struct ContentView: View {
    @State private var text: String? = nil
    
    var body: some View {
        TextEditor(text: $text)
    }
}

Dieser Code lässt sich nicht kompilieren, da TextEditor als Binding zwingend einen konkreten Wert vom Typ String benötigt, nicht wie im gezeigten Fall ein Optional. Eine potentielle Lösung zeigt das folgende Konstrukt:

struct ContentView: View {
    @State private var text: String? = nil
    
    var body: some View {
        let textBinding = Binding<String>(get: {
            if self.text != nil {
                return self.text!
            }
            return ""
        }, set: {
            self.text = $0
        })
        return TextEditor(text: textBinding)
    }
}

Hier erzeuge ich ein passendes Binding mithilfe des Initializers init(get:set:). Das basiert immer auf einem validen Wert vom Typ String, indem als Fallback schlicht ein leerer String als Wert zurückgegeben wird, sollte text nil entsprechen.

Das Problem: Derartige Binding-Konstrukte können sich in umfangreichen SwiftUI-Projekten schnell wiederholen. Das trifft insbesondere zu, wenn man für die Datenbasis ein Framework wie Core Data nutzt, das von Haus aus viele Eigenschaften als Optionals umsetzt.

Die Lösung: Eine Binding-Extension

Um solch umfangreiche (und optisch auch nicht sehr ansprechende) Konstrukte zu vermeiden, könnt ihr eine Extension für den Typ Binding erstellen. Denn das gezeigte Konstrukt baut immer auf zwei Faktoren auf:

  • einem bestehenden Binding auf Basis eines Optionals
  • einem Fallback-Wert

Im gezeigten Beispiel ist das bestehende Binding $text, der Fallback-Wert der leere String. Um daraus ein passendes Binding mit konkretem Wert zu generieren, könnte man den Typ Binding um folgende Methode ergänzen:

extension Binding {
    static func convertOptionalString(_ optionalString: Binding<String?>, fallback: String = "") -> Binding<String> {
        return Binding<String>(get: {
            optionalString.wrappedValue ?? fallback
        }, set: {
            optionalString.wrappedValue = $0
        })
    }
}

Diese neue Methode nimmt als Parameter ein Binding mit optionalem String sowie einen Fallback entgegen (der allerdings bereits einen Standardwert in Form eines leeren Strings erhält). Die Methode generiert daraufhin ein neues Binding, das immer einen validen String zurück liefert; entweder den Wert des optionalen Bindings oder den des Fallbacks.

Die Anwendung dieser neuen Methode im vorherigen Beispiel demonstriert das nachfolgende Listing:

struct ContentView: View {
    @State private var text: String? = nil
    
    var body: some View {
        TextEditor(text: Binding<String>.convertOptionalString($text))
    }
}

Das war’s! Auf diese Art und Weise können nun mit einem einzigen Befehl Bindings auf Basis eines optionalen Strings in Bindings mit einem konkreten String umgewandelt werden.

Es lebe die Dynamik!

Doch es geht noch besser! Warum diese Funktion auf Strings beschränken? Mithilfe einer Generic Function erweitert man die Funktionalität auf alle beliebigen Typen. Die entsprechende Implementierung zeigt das folgende Listing:

extension Binding {
    static func convertOptionalValue<T>(_ optionalValue: Binding<T?>, fallback: T) -> Binding<T> {
        return Binding<T>(get: {
            optionalValue.wrappedValue ?? fallback
        }, set: {
            optionalValue.wrappedValue = $0
        })
    }
    
    static func convertOptionalString(_ optionalString: Binding<String?>, fallback: String = "") -> Binding<String> {
        convertOptionalValue(optionalString, fallback: fallback)
    }
}

Die erste Methode convertOptionalValue(_:fallback:) nimmt ein beliebiges Binding mit einem optionalen Wert entgegen. Der Fallback muss dem Typ des optionalen Werts entsprechen. So ist sichergestellt, dass jedes Binding auf Basis eines Optionals in ein Binding mit konkretem Wert umgewandelt werden kann.

Die zuvor erstellte Methode convertOptionalString(_:fallback:) nutzt diese neue Generic Function, was den Code der Binding-Extension noch übersichtlicher macht. Denn convertOptionalString(_:fallback:) wird nicht überflüssig. Da sie speziell für Strings einen Standard-Fallback definiert, kann sie überall dort genutzt werden, wo dieser Fallback angemessen ist. So ist es auch denkbar, ähnliche Methoden wie convertOptionalString(_:fallback:) auch für andere häufig benötigte Typen zu erstellen, beispielsweise für Date:

extension Binding {
    // ...

    static func convertOptionalDate(_ optionalDate: Binding<Date?>, fallback: Date = Date()) -> Binding<Date> {
        convertOptionalValue(optionalDate, fallback: fallback)
    }
}

Fazit

Arbeitet man in einem Projekt viel mit Daten auf Basis von Optionals, kann die Arbeit mit Binding mühselig werden. Wenn es nur darum geht, als Alternative für einen nicht vorhandenen Wert eines Bindings einen frei definierbaren Standardwert zu nutzen, kann man sich mit der gezeigten Binding-Extension enorm viel Code-Schreiberei sparen. Da die Basismethode der Extension generisch ist, lässt sie sich mit jedem beliebigen Typ verwenden.

Euer Thomas


Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert