zurück zum Artikel

Refaktorierung in SwiftUI mit Property Wrappern und ViewModifiern​

Wolf Dieter Dallinger

(Bild: Shutterstock)

Für SwiftUI gibt es einfache Refactoring-Methoden, um Code zu modularisieren und damit übersichtlicher und weniger fehleranfällig zu gestalten.​

Views in Apples deklarativem GUI-Framework SwiftUI verwenden mit ViewBuilder eine domänenspezifische Sprache (DSL) für kompakten und leicht lesbaren Programmcode – vor allem im Vergleich zu dem älteren und imperativen GUI-Framework UIKit. Dennoch können Views aus vielen Codezeilen bestehen und unübersichtlich werden. Apples integrierte Entwicklungsumgebung Xcode bietet grundlegende Unterstützung für Refaktorierung [1] in SwiftUI. Teile einer View in eine weitere View auszulagern, ist tägliches Handwerk.

Hilfreich sind zudem zwei weitere Ansätze. Der erste verwendet Property Wrapper für dynamische Eigenschaften. Schlüssel für AppStorage, Typ und Default-Wert lassen sich in einen eigenen Property Wrapper integrieren. Die Implementierung ist gekapselt und damit weniger fehleranfällig. Außerdem lässt sich der Property Wrapper einfacher in ein Framework auslagern.

Der zweite Ansatz sind ViewModifier mit einer zugehörigen View-Methode. Damit lassen sich unter anderem if-Abfragen analog zu bekannten ViewModifiern wie disabled() und hidden() gestalten und die dynamische Eigenschaft aus der View in den ViewModifier verlagern.

Verschiedene Apps, die einen Property Wrapper aus einem Framework einbinden, mögen unterschiedliche Default-Werte erfordern. Der folgende Text zeigt, wie man mit einem ViewModifier einen Standardwert vorgeben kann.

Alle Beispiele im Artikel müssen SwiftUI importieren, sofern nicht anders angegeben. Zur besseren Lesbarkeit fehlen die Import-Anweisungen in den Codeausschnitten.

In SwiftUI speichern dynamische Eigenschaften den Zustand der Benutzerschnittstelle. Wenn sich die gespeicherten Werte ändern, erfolgt eine Neuberechnung der Views. Animationen für Änderungen und Transitionen für die Ein- und Ausblendung einer View gestalten den Zustandswechsel grafisch ansprechend.

Für eine dynamische Eigenschaft ist ein Property Wrapper erforderlich, der zum Protokoll DynamicProperty konform ist. State, ObservedObject, AppStorage und Environment sind Beispiele für solche Property Wrapper aus SwiftUI.

Im folgenden Beispiel kann die App in einem gesperrten Modus laufen. Eine Mitarbeiterin bekommt vollen Zugriff, aber Besucher an einem Messestand können im gesperrten Modus nur eingeschränkt mit der App interagieren. Das ist zusammen mit dem Einzel-App-Modus oder dem geführten Zugriff eines Gerätes ein reales Messeszenario.

AppStorage ist der Property Wrapper für die dynamische Eigenschaft appIsLocked. Er speichert ihren Wert unter dem Schlüssel "appIsLocked" App-weit und dauerhaft in den UserDefaults. true ist der Default-Wert, der greift, wenn nil oder noch nichts unter dem Schlüssel in den UserDefaults gespeichert ist. Die App ist beim ersten Start dadurch gesperrt, also Secure-by-Design.

Wenn appIsLocked den Wert true hat, zeigt die App einen Hinweis auf die gesperrte App.

struct MyView: View {
  @AppStorage("appIsLocked") 
     private var appIsLocked = true
    
  var body: some View {
    if appIsLocked {
      Text("Die App ist gesperrt.")
    }
  }
}

Das einfache Beispiel erfordert keine Refaktorierung. In einem App-Projekt mögen aber Gründe dafür vorliegen.

Der Zugriff per AppStorage auf einen bestimmten Wert in den UserDefaults findet häufig an mehreren Stellen in einer App statt. Wenn die App in den gesperrten Modus wechselt, muss sie viele Buttons, Menüs und Views sperren. Der Schlüssel und der Default-Wert sind an allen betroffenen Stellen erforderlich. Damit ergeben sich viele Fehlerquellen.

Wer einen eigenen Property Wrapper verwendet, kann beides auslagern und damit kapseln. Ein eigener Property Wrapper verbessert zudem die Lesbarkeit. Das gilt umso mehr, wenn die App mehrere dynamische Eigenschaften in einer View verwendet.

Der erste Ansatz zur Refaktorierung ist daher ein eigener Property Wrapper namens AppIsLocked:

struct MyView: View {
  @AppIsLocked private var appIsLocked
    
  var body: some View {
    if appIsLocked {
      Text("Die App ist gesperrt.")
    }
  }
}

Der zweite Ansatz betrifft sowohl die dynamische Eigenschaft als auch die if-Abfrage. Wird – wie im Beispiel – in einer View die dynamische Eigenschaft nur für die if-Abfrage verwendet, lassen sich beide in einen eigenen ViewModifier auslagern und mit einer Methode aus einer View-Extension anwenden. Hier heißt die Methode ifAppIsLocked(). Das erhöht die Lesbarkeit des Programmcodes zusätzlich:

struct MyView: View {
  var body: some View {
    Text("Die App ist gesperrt.")
      .ifAppIsLocked()
  }
}

Das Refaktorieren hat den kompletten Overhead entfernt, und es bleibt klar lesbarer Code übrig: Zeige diesen Text an, wenn die App gesperrt ist. Erneut lohnt sich die Refaktorierung, wenn ein Projekt mehrere solche if-Abfragen enthält.

Benötigt man eine Funktionsweise in mehreren Projekten, bietet es sich an, ein Framework zu erstellen und in ein Package zu integrieren. Eigene Property Wrapper und ViewModifier mit View-Extensions erleichtern das Auslagern in ein Framework.

Je nach Projekt sind unterschiedliche Default-Werte für einen Property Wrapper aus einem Framework erforderlich. Eine App soll beim ersten Start vielleicht im sicheren Modus laufen, der für eine andere womöglich nur eine ergänzende Option ist. Der Text zeigt im Folgenden als dritten Fall, wie man mit einem ViewModifier und einer View-Extension einen Default-Wert für einen eigenen Property Wrapper festlegt, der AppStorage verwendet. Den Default-Wert registriert der Code dazu in den UserDefaults.

Der Aufruf der entsprechenden View-Methode ist nur einmal pro Start der App erforderlich. Er muss ausreichend hoch in der View-Hierarchie erfolgen, beispielsweise in der App-Struktur:

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
        .appIsUnlockedByDefault()
    }
  }
}

Da Property Wrapper ein grundsätzliches Konzept von Swift sind, sind die folgenden Codeabschnitte unabhängig von SwiftUI.

Zunächst soll ein Beispielprojekt einen Wert vom Typ Int speichern, der maximal 10 beträgt. Werte über 10 speichert das Programm als 10.

Die Umsetzung verwendet eine berechnete und eine gespeicherte Eigenschaft. Die Property wird über die berechnete Eigenschaft value aufgerufen, die die Werte überprüft, gegebenenfalls korrigiert und in der gespeicherten Eigenschaft storedValue speichert:

var storedValue: Int = 0

var value: Int {
  get { storedValue }
  set { storedValue = min(10, newValue) }
}

Wer das Vorgehen an mehreren Stellen verwenden möchte, kann sie in einen Property Wrapper auslagern. Dabei handelt es sich um eine Instanz einer Struktur, Klasse oder Aufzählung. Deren berechnete Eigenschaft wrappedValue enthält den Wert und entspricht in obigem Beispiel dem value:

struct IntMax10 {
  private var storedValue: Int = 0

  var wrappedValue: Int {
    get { storedValue }
    set { storedValue = min(10, newValue) }
  }
}

Das obige Beispiel sieht mit dem Property Wrapper IntMax10 folgendermaßen aus:

var propertyWrapper = IntMax10()

var value: Int {
  get { propertyWrapper.wrappedValue }
  set { propertyWrapper.wrappedValue = newValue }
}

Der Code ist noch etwas sperrig, aber der Compiler kann diesen aus einer einfacheren Schreibweise erzeugen. Der Property Wrapper benötigt dafür das Attribut @propertyWrapper:

@propertyWrapper struct IntMax10 {
  private var storedValue: Int = 0

  var wrappedValue: Int {
    get { storedValue }
    set { storedValue = min(10, newValue) }
  }
}

Der Property Wrapper lässt sich anschließend als Attribut für die Eigenschaft verwenden, aber bisher nicht für eine globale Variable:

@IntMax10 var value

Der Compiler macht daraus

var _value = IntMax10()

var value: Int {
  get { _value.wrappedValue }
  set { _value.wrappedValue = newValue }
}

Ein Property Wrapper kann noch über eine zweite Eigenschaft verfügen, für die der Compiler Code analog dem wrappedValue erzeugt: den projectedValue. Viele Property Wrapper für SwiftUI liefern darüber ein Binding zum wrappedValue.

Im Beispiel gibt der projectedValue der Einfachheit halber denselben Wert wie wrappedValue zurück:

@propertyWrapper struct IntMax10 {
  private var storedValue: Int = 0

  var wrappedValue: Int {
    get { storedValue }
    set { storedValue = min(10, newValue) }
  }
  
  var projectedValue: Int {
    wrappedValue
  }
}

Aus

@IntMax10 var value

macht der Compiler dann:

var _value = IntMax10()

var value: Int {
  get { _value.wrappedValue }
  set { _value.wrappedValue = newValue }
}

var $value: Int {
  _value.projectedValue
}

value ist die eigentliche Eigenschaft und der wrappedValue des Property Wrapper, $value der projectedValue des Property Wrapper und _value der eigentliche Property Wrapper.

In SwiftUI gilt das Ändern einer dynamischen Eigenschaft als Zustandsänderung der Benutzerschnittstelle, die eine Neuberechnung der betroffenen Views auslöst. Eine Eigenschaft gilt als dynamisch, wenn ein Property Wrapper für dynamische Eigenschaften sie verwaltet.

Ein Property Wrapper, der

ist ein solcher Property Wrapper für dynamische Eigenschaften.

Der Property Wrapper AppIsLocked aus obigem SwiftUI-Beispiel sieht damit folgendermaßen aus:

@propertyWrapper struct AppIsLocked: DynamicProperty {
  @AppStorage("appIsLocked") 
    private var appIsLocked = true
    
  var wrappedValue: Bool {
    get { appIsLocked }
    nonmutating set { appIsLocked = newValue }
  }
}

wrappedValue ist eine berechnete Eigenschaft. Das Speichern des Wertes erfolgt in der dynamischen Eigenschaft appIsLocked des Property Wrapper AppIsLocked. Letzteres verwendet den Property Wrapper AppStorage.

AppStorage speichert den Wert extern in den UserDefaults. Der Setter von wrappedValue des AppStorage ist daher als nonmutating ausgezeichnet. Das gilt in der Folge auch für AppIsLocked, das die Auszeichung übernimmt.

Apples Property Wrapper für SwiftUI liefern über die Eigenschaft projectedValue in der Regel ein Binding zur Eigenschaft wrappedValue. Da im Property Wrapper AppIsLocked die beiden Eigenschaften wrappedValue und appIsLocked denselben Wert repräsentieren, gibt der Code der Einfachheit halber den Wert der Eigenschaft projectedValue des Property Wrapper AppStorage zurück, also $appIsLocked.

Der vollständige Property Wrapper AppIsLocked sieht folgendermaßen aus:

@propertyWrapper struct AppIsLocked: DynamicProperty {
  @AppStorage("appIsLocked") 
    private var appIsLocked = true
    
  var wrappedValue: Bool {
    get { appIsLocked }
    nonmutating set { appIsLocked = newValue }
  }

  var projectedValue: Binding<Bool> { $appIsLocked }
}

Solche Property Wrapper sind einfach aufgebaut und laden dazu ein, sie routinemäßig zu erstellen.

Das Beispiel mit dem Property Wrapper sieht nun folgendermaßen aus:

struct MyView: View {
  @AppIsLocked private var appIsLocked
    
  var body: some View {
    if appIsLocked {
      Text("Die App ist gesperrt.")
    }
  }
}

Übergibt man das Binding, das die Eigenschaft $appIsLocked liefert, an ein Toggle, können User den Wert ändern:

Toggle("App ist gesperrt?", isOn: $appIsLocked)

Ein reales Projekt würde einen Button verwenden, der die Authentifizierung über FaceID, TouchID und Passworteingabe ermöglicht. Alternativ könnte die Anwendung das Deaktivieren des gesperrten Modus nur über die App-Einstellungen erlauben, die im Einzel-App-Modus und bei geführtem Zugriff nicht zugänglich sind.

Im Property Wrapper AppIsLocked steht das Environment von MyView zur Verfügung. Es ließe sich nutzen, um mit einem eigenen EnvironmentValue, einem ViewModifier und einer View-Extension einen App-weiten Default-Wert einzuschleusen. Das Beispiel verwendet aber eine einfachere Methode, die weiter unten aufgeführt ist.

Ein ViewModifier ist eine Struktur, die eine View erzeugt. Der Unterschied zur reinen View liegt in der Semantik: Der ViewModifier erstellt keine komplett neue View, sondern modifiziert eine vorhandene.

Ziel ist ein ViewModifier, der eine andere View genau dann anzeigt, wenn die App gesperrt ist. Ein passender Name ist das oben vorgeschlagene IfAppIsLocked.

Der ViewModifier benötigt eine dynamische Eigenschaft, die den gewünschten Zustand darstellt. Dafür dient der zuvor erstellte Property Wrapper AppIsLocked:

struct IfAppIsLocked: ViewModifier {
  @AppIsLocked private var appIsLocked

  func body(content: Content) -> some View {
    if appIsLocked {
      content
    }
  }
}

Eine App wendet den ViewModifier mit der View-Methode modifier() auf eine View an. MyView im Beispiel verwendet die dynamische Eigenschaft appIsLocked nur für die if-Abfrage. Da diese in den ViewModifier gewandert ist, benötigt MyView appIsLocked nicht mehr. Das Beispiel fällt mit dem ViewModifier deutlich kürzer aus:

struct MyView: View {
  var body: some View {
    Text("Die App ist gesperrt.")
         .modifier(IfAppIsLocked())
  }
}

Üblicherweise lagert man den Aufruf von modifier() in eine View-Extension aus und verbessert die Lesbarkeit noch weiter:

extension View {
  func ifAppIsLocked() -> some View {
    modifier(IfAppIsLocked())
  }
}

Mit der neuen View-Methode ifAppIsLocked() sieht der Code folgendermaßen aus:

struct MyView: View {
  var body: some View {
    Text("Die App ist gesperrt.")
         .ifAppIsLocked()
  }
}

Das Ziel ist erreicht: Der komplette Overhead ist wegrefaktoriert und es bleibt klar lesbarer Code übrig: Zeige den passenden Text an, wenn die App gesperrt ist.

In ein Framework ausgelagerte Property Wrapper und ViewModifier sind für mehrere Projekte wiederverwendbar. Ein Projekt kann erfordern, dass die App nach Installation gesperrt ist. Ein anderes ermöglicht eine Sperre als Option und startet die App nach der Installation entsperrt.

Um je nach Projekt unterschiedliche Default-Werte zuzulassen und trotzdem denselben Property Wrapper über ein Framework zur Verfügung zu stellen, bietet sich an, den Default in jedem Projekt per ViewModifier vorzugeben. Ohne die Vorgabe sollte die Anwendung automatisch einen sicheren Default-Wert nehmen, damit der Property Wrapper das Paradigma Secure-by-Design erfüllt.

Umgebungsvariablen stellen eine gute Methode dar, einen Wert über mehrere Views hinweg an eine View oder einen Property Wrapper für dynamische Eigenschaften weiterzureichen.

Default-Werte für UserDefaults und damit den Property Wrapper AppStorage lassen sich direkt in den UserDefaults registrieren. Dieses Vorgehen ist einfacher und daher zu bevorzugen.

Einen in den UserDefaults angegebenen Standardwert für einen Schlüssel gibt die App bis zum nächsten Start immer dann zurück, wenn zu einem Schlüssel kein Wert oder nil gespeichert ist.

Die Registrierung sollte stattfinden, bevor die App den Wert das erste Mal liest. Die Ausführung der ViewModifier zu onAppear und task erfolgt zu spät, aber der Initialisierer eines ViewModifier wird vor der ersten Berechnung des ihm folgenden restlichen View-Tree ausgeführt.

Der ViewModifier AppIsUnlockedByDefault und die zugehörige View-Methode appIsUnlockedByDefault() sehen damit folgendermaßen aus:

struct AppIsUnlockedByDefault: ViewModifier {
  init() {
    UserDefaults.standard.register(defaults: [
      "appIsLocked": false,
    ])
  }
    
  func body(content: Content) -> some View {
    content
  }
}

extension View {
  func appIsUnlockedByDefault() -> some View {
    modifier(AppIsUnlockedByDefault())
  }
}

Wichtig ist, den neuen Default-Wert vor dem ersten Verwenden des Property Wrapper AppIsLocked zu setzen. Ein geeigneter Ort ist an der ersten View.

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
        .appIsUnlockedByDefault()
    }
  }
}

Mit einfachen und kurzen Property Wrappern, ViewModifiern und zugehörigen Methoden in View-Extensions lässt sich der Programmcode einer View kompakter gestalten und die Lesbarkeit verbessern. Wer sie an mehreren Stellen einsetzt, verringert zudem die Fehleranfälligkeit.

Diese Form der Refaktorierung bietet sich in folgenden Situationen an:

Die Vorgabe eines beliebigen Default-Werts über einen ViewModifier mit zugehöriger Methode in einer View-Extension ist ebenfalls wenig aufwändig. Das beim Refaktorieren erstellte Framework lässt sich flexibel in vielen Projekten einsetzen.

Der Artikel hat sich ausschließlich mit dynamischen Eigenschaften befasst, die der Property Wrapper AppStorage managt. Andere Property Wrapper eignen sich ebenso. Solange sich ein Schlüssel, ein KeyPath, ein Default-Wert, ein Typ oder etwas anderes in einen eigenen Property Wrapper integrieren lässt, eignet er sich prinzipiell für diese Methode der Refaktorierung.

Eigene Property Wrapper für dynamische Eigenschaften eröffnen zahlreiche Möglichkeiten. Beispielsweise lässt sich ein Text einer dynamischen Eigenschaft automatisch in einer Datei speichern. Bei deren Änderung soll die dynamische Eigenschaft die Änderung übernehmen und die betroffenen Views sollen neu berechnet werden. Dies mag aber Thema eines anderen Beitrages sein.

In einem Framework müssen von außen erreichbare Elemente als public deklariert und explizite Initialisierer hinzugefügt werden. Implizite Initialisierer sind nicht public. Im Interesse des übersichtlichen Programmcodes berücksichtigen die Codeausschnitte diese Vorgabe nicht.

Die Beispiele dieses Artikels finden sich in dem Sourcecode zu dem Framework LockApp [2] auf GitHub. Es enthält einen passenden LockAppButton zum Sperren und Entsperren einer App per FaceID, TouchID und Passwort und zusätzliche ViewModifier. Das Framework steht unter der MIT-Lizenz zur Verfügung.

Wolf Dieter Dallinger
ist Freiberufler, schreibt seit vielen Jahren Schulungshandbücher für IT-Unternehmen und entwickelt Apps für iPhone und iPad bevorzugt mit Swift. Seine Lieblingsframeworks sind der Datenbankwrapper Core Data und das GUIFramework SwiftUI.

(rme [3])


URL dieses Artikels:
https://www.heise.de/-9185527

Links in diesem Artikel:
[1] https://developer.apple.com/documentation/xcode/finding-and-refactoring-code
[2] https://github.com/WolfDieterDallinger/LockApp
[3] mailto:rme@ix.de