Zum Inhalt springen

Multi Selector in SwiftUI

Eine fehlende SwiftUI-Komponente fuer Prototyping-Zwecke ergaenzen.

Multi Selector in SwiftUI

Waehrend ich meine erste ernsthafte App mit SwiftUI entwickelte, war ich immer wieder beeindruckt, wie schnell die UI-Entwicklung mit SwiftUI geworden war – vor allem, wenn die mitgelieferten Views den eigenen Anwendungsfall bereits abdecken. Und obwohl wir fuer jede Art von individuellem UI natuerlich weiterhin eigene Views schreiben muessen, indem wir bestehende kombinieren und mit Modifiers anpassen, wuerde ich erwarten, dass SwiftUI zumindest die gaengigsten Views unterstuetzt, die Entwickler brauchen koennten, um Daten darzustellen und Eingaben von Nutzern entgegenzunehmen.

Waere das der Fall, koennte SwiftUI sogar fuer Prototyping genutzt werden, bei dem eine “funktionierende, aber nicht huebsche” Version einer App-Idee schnell gebaut und Nutzern gezeigt werden koennte, um zu pruefen, ob die App-Idee Erfolgschancen hat. Ausserdem koennte man so auch schnell Feedback sammeln, welche Teile ein deutlich verstaendlicheres UI brauchen (die noch nicht gut verstandenen Teile) und welche groesstenteils bei den Standard-Komponenten mit einigen visuellen Anpassungen bleiben koennten.

Mit anderen Worten: SwiftUI hat meiner Meinung nach das Potenzial, MVP-getriebene Produktentwicklung fuer deutlich mehr Entwickler interessant zu machen – was definitiv eine gute Sache ist, da es viel Zeit spart, die sonst in Dinge investiert wuerde, die sich letztlich als Fehlschlag herausstellen. Das passt zur Lean-Startup-Methodik, die ich fuer einen grossartigen Ansatz halte, um jede Art von neuem Produkt anzugehen.

Der aktuelle Stand von SwiftUI

Damit das moeglich waere, wuerde ich erwarten, dass SwiftUI bereits alle gaengigen Eingabetypen abdeckt, die in Formularen benoetigt werden koennten – etwa fuer die Nutzerregistrierung oder andere Arten von Daten – denn viele Arten von Apps sind letztlich nichts anderes als ein Formular, das Eingabedaten entgegennimmt, sie auf irgendeine Weise transformiert und Daten auf eine besondere Art oder zu einem bestimmten Zeitpunkt zurueckgibt. Leider ist SwiftUI da noch nicht ganz angekommen.

Apples Ansatz bei SwiftUI scheint zu sein, jedes Jahr zu ueberlegen, welche Komponenten am meisten fehlen, und einige davon hinzuzufuegen. Zum Beispiel wurden auf der WWDC 2020 ProgressView, Gauge, Image-Unterstuetzung innerhalb von Text und viele andere Details bestehender Views verbessert, sowohl in Bezug auf Performance als auch Flexibilitaet. Auf der WWDC 2021 wurden mehrere async/await-bezogene APIs hinzugefuegt, wie AsyncImage oder die View-Modifier .refreshable und .task, neben anderen Verbesserungen und Ergaenzungen.

Der Vorteil dieses Ansatzes ist: Sobald etwas zum Framework hinzugefuegt wird, kann man erwarten, dass es lange Zeit existiert und auf die gleiche Weise funktioniert – grosse Codeaenderungen sind also nicht bei jedem Release noetig (wie es bei Swift als Sprache vor Swift 4 der Fall war). Der Nachteil ist, dass noch viele Komponenten fehlen. Und genau da kann die Community einspringen, um temporaere Loesungen bereitzustellen, die spaeter leicht durch offizielle Komponenten von Apple ersetzt werden koennen.

Eine Multi-Selection-View-Komponente implementieren

In diesem Beitrag moechte ich mich auf eine solche Komponente konzentrieren und meine erste Loesung dafuer vorstellen: Einen Multi-Selector, um mehrere Optionen aus einer gegebenen Menge auszuwaehlen. Derzeit bietet Apple zwar einen Picker, aber der unterstuetzt keine Mehrfachauswahl und verlaesst sogar automatisch den Listen-Screen, sobald eine einzelne Auswahl getroffen wurde. Also packen wir es direkt an!

Welche Art von Datenstruktur koennte einen Multi-Selector erfordern? Schauen wir uns dieses Beispiel an:

struct Goal: Hashable, Identifiable {
    var name: String
    var id: String { name }
}

struct Task {
    var name: String
    var servingGoals: Set<Goal>
}

Also haben wir in unserer App im Grunde eine Sammlung von Zielen und eine Sammlung von Aufgaben. Und wir moechten die Beziehung modellieren, welche Ziele jede Aufgabe bedient. Beim Erstellen oder Bearbeiten einer Task wollen wir auswaehlen, welche Ziele die Aufgabe bedient. Hier ist der SwiftUI-Code fuer eine TaskEditView:

import SwiftUI

struct TaskEditView: View {
    @State
    var task = Task(name: "", servingGoals: [])

    var body: some View {
        Form {
            Section(header: Text("Name")) {
                TextField("e.g. Find a good Japanese textbook", text: $task.name)
            }

            Section(header: Text("Relationships")) {
                Text("TODO: add multi selector here")
            }
        }.navigationTitle("Edit Task")
    }
}

struct TaskEditView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            TaskEditView()
        }
    }
}

Der obige Code rendert zu dieser Vorschau:

Implementing a multi 3

Um zu zeigen, wie es funktionieren wuerde, wenn wir nur ein Ziel haetten, koennten wir unseren TODO-Text-Eintrag einfach durch einen Picker ersetzen:

// mock data:
let allGoals: [Goal] = [
    Goal(name: "Learn Japanese"),
    Goal(name: "Learn SwiftUI"),
    Goal(name: "Learn Serverless with Swift")
]

Picker("Serving Goal", selection: $task.servingGoal) {
    ForEach(allGoals) {
        Text($0.name).tag($0 as Goal)
    }
}

So sieht die TaskEditView jetzt aus:

Implementing a multi 6

Und beim Klicken auf den Picker erscheint diese Detailansicht:

Implementing a multi 5

Ziemlich unkompliziert. Beachte, dass Goal Identifiable sein muss, damit das funktioniert – deshalb habe ich var id: String { name } von Anfang an hinzugefuegt. Fuer unseren Multi-Selector soll das UI eigentlich ziemlich gleich aussehen, aber statt einer moechten wir mehrere Eintraege auswaehlen koennen.

Zuerst muessen wir den Eintrag in der TaskEditView neu erstellen. Ich habe MultiSelector als Ersatz-Typnamen fuer Picker gewaehlt. Hier ist die Implementierung:

import SwiftUI

struct MultiSelector<LabelView: View, Selectable: Identifiable & Hashable>: View {
    let label: LabelView
    let options: [Selectable]
    let optionToString: (Selectable) -> String
    var selected: Binding<Set<Selectable>>

    private var formattedSelectedListString: String {
        ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) })
    }

    var body: some View {
        NavigationLink(destination: multiSelectionView()) {
            HStack {
                label
                Spacer()
                Text(formattedSelectedListString)
                    .foregroundColor(.gray)
                    .multilineTextAlignment(.trailing)
            }
        }
    }

    private func multiSelectionView() -> some View {
        Text("TODO: add multi selection detail view here")
    }
}

Beachte, dass ich mich entschieden habe, jeden Eintrag mit einem String darzustellen – daher wird die optionToString-Closure benoetigt, die die String-Darstellung des Options-Typs liefert.

Der Aufruf von ListFormatter.localizedString stellt sicher, dass wir eine Liste ausgewaehlter Optionen im korrekten Lokalisierungsformat zusammenfuegen (z. B. wird ["A", "B", "C"] im Englischen zu “A, B and C”).

Das ist der Preview-Code, den ich fuer die View verwendet habe:

struct MultiSelector_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            Form {
                MultiSelector<Text, IdentifiableString>(
                    label: Text("Multiselect"),
                    options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                    optionToString: { $0.string },
                    selected: $selected
                )
            }.navigationTitle("Title")
        }
    }
}

Beachte, dass ich statt Goal einen internen Typ verwendet habe, um die Vorschau unabhaengig von meinem konkreten Projekt zu machen. So sieht die Vorschau aus:

Implementing a multi

Setzen wir das in unsere TaskEditView ein und schauen, wie es in diesem Kontext aussieht, indem wir den TODO-Text-Aufruf ersetzen durch:

MultiSelector(
    label: Text("Serving Goals"),
    options: allGoals,
    optionToString: { $0.name },
    selected: $task.servingGoals
)

Die Vorschau aendert sich jetzt zu diesem Ergebnis, das genau so aussieht wie erwartet:

Implementing a multi 4

Aber wenn man darauf klickt, sieht man das hier – das ist noch nicht richtig:

Implementing a multi 2

Implementieren wir also die Detailansicht. Ich habe den Typnamen MultiSelectionView fuer die Detailansicht gewaehlt, und hier ist der Code:

import SwiftUI

struct MultiSelectionView<Selectable: Identifiable & Hashable>: View {
    let options: [Selectable]
    let optionToString: (Selectable) -> String

    @Binding
    var selected: Set<Selectable>

    var body: some View {
        List {
            ForEach(options) { selectable in
                Button(action: { toggleSelection(selectable: selectable) }) {
                    HStack {
                        Text(optionToString(selectable)).foregroundColor(.black)

                        Spacer()

                        if selected.contains { $0.id == selectable.id } {
                            Image(systemName: "checkmark").foregroundColor(.accentColor)
                        }
                    }
                }.tag(selectable.id)
            }
        }.listStyle(GroupedListStyle())
    }

    private func toggleSelection(selectable: Selectable) {
        if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) {
            selected.remove(at: existingIndex)
        } else {
            selected.insert(selectable)
        }
    }
}

Abgesehen von label hat diese View im Grunde die gleichen Properties. Aber diesmal werden sie tatsaechlich genutzt – zum Beispiel, wenn geprueft wird, ob das Haekchen angezeigt werden soll, indem contains auf der selected-Sammlung aufgerufen wird.

Wenn einer der Eintraege angeklickt wird, wird toggleSelection auf den Eintrag angewendet, um ihn aus der selected-Property zu entfernen oder einzufuegen. Fuer das Haekchen verwende ich das SF Symbol “checkmark”, das genauso aussieht wie das Haekchen-Icon des Picker.

Das ist der Preview-Code, den ich fuer die Detailansicht eingerichtet habe – beachte, dass er im Wesentlichen eine Kopie der MultiSelector-Vorschau ist:

struct MultiSelectionView_Previews: PreviewProvider {
    struct IdentifiableString: Identifiable, Hashable {
        let string: String
        var id: String { string }
    }

    @State
    static var selected: Set<IdentifiableString> = Set(["A", "C"].map { IdentifiableString(string: $0) })

    static var previews: some View {
        NavigationView {
            MultiSelectionView(
                options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
                optionToString: { $0.string },
                selected: $selected
            )
        }
    }
}

So sieht es in der Xcode-Vorschau aus:

Implementing a multi 7

Jetzt integrieren wir schliesslich unsere MultiSelectionView in unseren MultiSelector, indem wir den TODO-Text-Eintrag ersetzen durch:

MultiSelectionView(
    options: options,
    optionToString: optionToString,
    selected: selected
)

Im Grunde geben wir einfach die Daten an die Detailansicht weiter. Aber schauen wir uns an, wie unsere App jetzt in diesem animierten GIF aussieht, das ich aus dem Simulator aufgenommen habe:

Implementing a multi

Super, es funktioniert!

Ich habe das Demo-Projekt auf GitHub hochgeladen. Wenn du einfach den Inhalt von MultiSelector und MultiSelectionView kopieren moechtest, findest du sie in diesem Ordner.

Du fandest diesen Artikel hilfreich? Hol dir meinen Expertenrat!

Hat dir dieser Beitrag gefallen? Folge mir auf Bluesky und Mastodon für mehr Swift-Tipps und Indie-Dev-Updates.