Lernziel
In diesem Projektteil machst du den Ausgaben-Tracker benutzbar: ein Formular, das neue Ausgaben erfasst und bestehende bearbeitet, eine Verwaltung für Kategorien mit Symbol- und Farbauswahl im Raster, sicheres Löschen mit Rückfrage und einen Filter nach Kategorie mit dynamischer Abfrage.
Ausgangslage
Nach Teil 1 zeigt die App Ausgaben an, gruppiert nach Tagen. Aber eintragen kann man noch nichts. In diesem Teil kommen dazu:
- Erfassen und Bearbeiten von Ausgaben in einem Formular,
- Kategorien verwalten: anlegen, ändern, löschen,
- Filtern der Liste nach einer Kategorie.
Schritt 1: Das Formular
Wie das BookForm aus Lektion 3.15 soll ein einziges Formular beides können: neue Ausgaben anlegen und bestehende bearbeiten.
Aufgabe
Baue ExpenseForm mit einem optionalen Parameter expense: Expense?:
- Felder für Titel, Betrag (in Euro), Datum (nur Tag, nicht in der Zukunft), Kategorie (inklusive „Keine“) und eine mehrzeilige Notiz.
- Beim Bearbeiten werden die Felder mit den bestehenden Werten gefüllt.
- „Sichern“ ist nur aktiv, wenn ein Titel eingegeben und der Betrag größer als null ist.
- Eine neue Ausgabe bekommt die erste Kategorie vorausgewählt, damit der Nutzer seltener tippen muss.
LösungErst selbst probieren!
struct ExpenseForm: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \Category.name) private var categories: [Category]
let expense: Expense?
@State private var title = ""
@State private var amount = 0.0
@State private var date = Date.now
@State private var category: Category?
@State private var note = ""
private var isNew: Bool { expense == nil }
private var isValid: Bool {
!title.trimmingCharacters(in: .whitespaces).isEmpty && amount > 0
}
init(expense: Expense? = nil) {
self.expense = expense
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Wofür?", text: $title)
TextField("Betrag", value: $amount, format: .currency(code: "EUR"))
.keyboardType(.decimalPad)
}
Section {
Picker("Kategorie", selection: $category) {
Text("Keine").tag(nil as Category?)
ForEach(categories) { category in
Label(category.name, systemImage: category.icon)
.tag(category as Category?)
}
}
DatePicker("Datum", selection: $date, in: ...Date.now, displayedComponents: .date)
}
Section("Notiz") {
TextField("Optional", text: $note, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle(isNew ? "Neue Ausgabe" : "Bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") {
save()
dismiss()
}
.disabled(!isValid)
}
}
.onAppear {
if let expense {
title = expense.title
amount = expense.amount
date = expense.date
category = expense.category
note = expense.note
} else if category == nil {
category = categories.first
}
}
}
}
private func save() {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if let expense {
expense.title = trimmed
expense.amount = amount
expense.date = date
expense.category = category
expense.note = note
} else {
let new = Expense(title: trimmed, amount: amount, date: date, note: note, category: category)
context.insert(new)
}
}
}
#Preview {
ExpenseForm()
.modelContainer(previewContainer)
}Ein paar Details:
in: ...Date.nowerlaubt nur Daten bis heute. Ausgaben in der Zukunft wären ein Tippfehler.displayedComponents: .datezeigt nur den Tag, keine Uhrzeit.axis: .verticalmit.lineLimit(3...6)macht das Notizfeld mehrzeilig: mindestens drei Zeilen hoch, wächst bis sechs.- Die Tags im Picker sind
Category?, passend zur optionalen Auswahl (Lektion 3.16). - Beim Bearbeiten werden die Änderungen erst in
save()auf das Modell übertragen. Bricht der Nutzer ab, bleibt alles, wie es war.
Der eigene Initializer
init(expense: Expense? = nil) erlaubt beide Schreibweisen: ExpenseForm() für eine neue und ExpenseForm(expense: e) für eine bestehende Ausgabe. Ohne eigenen Initializer müsstest du immer ExpenseForm(expense: nil) schreiben.
Schritt 2: Formular öffnen
Aufgabe
- Füge in
ExpenseListVieweinen Plus-Button in die Toolbar ein, der das Formular für eine neue Ausgabe als Sheet öffnet. - Ein Tipp auf eine Zeile öffnet das Formular zum Bearbeiten dieser Ausgabe. Verwende
.sheet(item:).
LösungErst selbst probieren!
@State private var showingNew = false
@State private var editing: Expense?Die Zeile wird zum Button:
ForEach(day.items) { expense in
Button {
editing = expense
} label: {
ExpenseRow(expense: expense)
}
.tint(.primary)
}Und an der List:
.toolbar {
Button("Neue Ausgabe", systemImage: "plus") {
showingNew = true
}
}
.sheet(isPresented: $showingNew) {
ExpenseForm()
}
.sheet(item: $editing) { expense in
ExpenseForm(expense: expense)
}.sheet(item:)öffnet das Sheet, sobaldeditingeinen Wert hat, und übergibt ihn (Lektion 2.19). Das funktioniert, weil@Model-Klassen automatischIdentifiablesind..tint(.primary)verhindert, dass der Text in der Zeile blau wird, wie es bei Buttons in Listen sonst passiert.
Erwartetes Ergebnis
Plus öffnet ein leeres Formular mit vorausgewählter Kategorie. Nach dem Sichern erscheint die Ausgabe sofort unter „Heute“, und die Monatssumme wächst. Ein Tipp auf eine Zeile öffnet sie zum Bearbeiten.
Schritt 3: Kategorien anzeigen
Aufgabe
Baue CategoryListView: eine Liste aller Kategorien, sortiert nach Name. Jede Zeile zeigt Symbol und Name und rechts die Anzahl der Ausgaben. Öffne sie aus der Ausgabenliste über einen Toolbar-Button mit dem Symbol "square.grid.2x2" als Sheet.
LösungErst selbst probieren!
struct CategoryListView: View {
@Query(sort: \Category.name) private var categories: [Category]
var body: some View {
List(categories) { category in
HStack {
Image(systemName: category.icon)
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(category.tint.color, in: .rect(cornerRadius: 8))
Text(category.name)
Spacer()
Text("\(category.expenses.count)")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Kategorien")
}
}In ExpenseListView:
@State private var showingCategories = false
// in .toolbar
ToolbarItem(placement: .topBarLeading) {
Button("Kategorien", systemImage: "square.grid.2x2") {
showingCategories = true
}
}
// an der List
.sheet(isPresented: $showingCategories) {
NavigationStack {
CategoryListView()
}
}Weil jetzt zwei Toolbar-Elemente da sind, bekommt der Plus-Button ein eigenes ToolbarItem(placement: .primaryAction).
Schritt 4: Das Kategorie-Formular
Für eine Kategorie braucht der Nutzer eine Auswahl an Symbolen und Farben. Beides passt wunderbar in ein Raster (Lektion 3.17).
Aufgabe
Baue CategoryForm mit optionalem category: Category?, analog zu ExpenseForm:
- Ein Textfeld für den Namen.
- Ein Raster mit etwa 20 passenden SF Symbols. Das gewählte ist hervorgehoben.
- Eine Reihe farbiger Kreise für alle Fälle von
CategoryTint. Der gewählte hat einen Rahmen. - Ganz oben eine Vorschau der Kategorie mit Symbol und Farbe.
- Sichern ist nur möglich, wenn der Name nicht leer ist und keine andere Kategorie schon so heißt.
LösungErst selbst probieren!
struct CategoryForm: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query private var allCategories: [Category]
let category: Category?
@State private var name = ""
@State private var icon = "cart.fill"
@State private var tint = CategoryTint.orange
static let icons = [
"cart.fill", "fork.knife", "cup.and.saucer.fill", "tram.fill", "car.fill", "fuelpump.fill",
"house.fill", "bolt.fill", "wifi", "gamecontroller.fill", "film.fill", "book.fill",
"tshirt.fill", "gift.fill", "heart.fill", "pawprint.fill", "airplane", "graduationcap.fill",
"dumbbell.fill", "ellipsis"
]
init(category: Category? = nil) {
self.category = category
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespaces)
}
private var nameIsTaken: Bool {
allCategories.contains { other in
other !== category && other.name.localizedCaseInsensitiveCompare(trimmedName) == .orderedSame
}
}
private var isValid: Bool {
!trimmedName.isEmpty && !nameIsTaken
}
var body: some View {
NavigationStack {
Form {
Section {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.largeTitle)
.foregroundStyle(.white)
.frame(width: 72, height: 72)
.background(tint.color, in: .rect(cornerRadius: 18))
Text(trimmedName.isEmpty ? "Neue Kategorie" : trimmedName)
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
Section {
TextField("Name", text: $name)
} footer: {
if nameIsTaken {
Text("Eine Kategorie mit diesem Namen gibt es schon.")
.foregroundStyle(.red)
}
}
Section("Symbol") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44))], spacing: 12) {
ForEach(Self.icons, id: \.self) { symbol in
Image(systemName: symbol)
.font(.title3)
.frame(width: 44, height: 44)
.foregroundStyle(symbol == icon ? Color.white : Color.primary)
.background(symbol == icon ? tint.color : Color(.tertiarySystemFill),
in: .rect(cornerRadius: 10))
.onTapGesture { icon = symbol }
}
}
.padding(.vertical, 4)
}
Section("Farbe") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 36))], spacing: 12) {
ForEach(CategoryTint.allCases, id: \.self) { option in
Circle()
.fill(option.color)
.frame(width: 32, height: 32)
.padding(3)
.overlay {
if option == tint {
Circle().strokeBorder(.primary, lineWidth: 2)
}
}
.onTapGesture { tint = option }
}
}
.padding(.vertical, 4)
}
}
.navigationTitle(category == nil ? "Neue Kategorie" : "Kategorie bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") {
save()
dismiss()
}
.disabled(!isValid)
}
}
.onAppear {
if let category {
name = category.name
icon = category.icon
tint = category.tint
}
}
}
}
private func save() {
if let category {
category.name = trimmedName
category.icon = icon
category.tint = tint
} else {
context.insert(Category(name: trimmedName, icon: icon, tint: tint))
}
}
}Die wichtigsten Stellen:
other !== category:!==vergleicht bei Klassen, ob es sich um dasselbe Objekt handelt (Lektion 1.13). Beim Bearbeiten darf die Kategorie ihren eigenen Namen natürlich behalten.localizedCaseInsensitiveComparevergleicht ohne Rücksicht auf Groß- und Kleinschreibung. „essen“ und „Essen“ gelten als gleich.- Der Hinweis im
footererscheint nur, wenn der Name vergeben ist. - Die Vorschau oben aktualisiert sich bei jeder Eingabe, weil sie aus denselben States gebaut wird.
Schritt 5: Anlegen, Bearbeiten, Löschen
Aufgabe
Ergänze CategoryListView:
- Ein Plus-Button öffnet
CategoryForm(), ein Tipp auf eine Zeile öffnet sie zum Bearbeiten. - Wischen zum Löschen fragt mit einem
.confirmationDialognach (Lektion 2.20). Hat die Kategorie Ausgaben, nennt die Nachricht ihre Anzahl und erklärt, dass sie erhalten bleiben. - Ein „Fertig“-Button schließt das Sheet.
LösungErst selbst probieren!
struct CategoryListView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \Category.name) private var categories: [Category]
@State private var showingNew = false
@State private var editing: Category?
@State private var deleting: Category?
var body: some View {
List {
ForEach(categories) { category in
Button {
editing = category
} label: {
CategoryRow(category: category)
}
.tint(.primary)
}
.onDelete { offsets in
deleting = offsets.first.map { categories[$0] }
}
}
.navigationTitle("Kategorien")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("Neue Kategorie", systemImage: "plus") { showingNew = true }
}
}
.sheet(isPresented: $showingNew) {
CategoryForm()
}
.sheet(item: $editing) { category in
CategoryForm(category: category)
}
.confirmationDialog(
"Kategorie löschen?",
isPresented: Binding(
get: { deleting != nil },
set: { if !$0 { deleting = nil } }
),
titleVisibility: .visible,
presenting: deleting
) { category in
Button("„\(category.name)“ löschen", role: .destructive) {
context.delete(category)
}
} message: { category in
if category.expenses.isEmpty {
Text("Die Kategorie wird entfernt.")
} else {
Text("\(category.expenses.count) Ausgaben bleiben erhalten und stehen danach unter „Ohne Kategorie“.")
}
}
}
}
struct CategoryRow: View {
let category: Category
var body: some View {
HStack {
Image(systemName: category.icon)
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(category.tint.color, in: .rect(cornerRadius: 8))
Text(category.name)
Spacer()
Text("\(category.expenses.count)")
.foregroundStyle(.secondary)
}
}
}- Die Zeile ist in eine eigene View
CategoryRowausgelagert (Lektion 2.21). offsets.first.map { categories[$0] }: Aus dem ersten Index wird die passende Kategorie, odernil, wenn es keinen gibt.presenting: deletingübergibt die Kategorie an den Dialog, sodass Button und Nachricht sie verwenden können.- Das eigene Binding übersetzt zwischen „ist
deletinggesetzt?“ und demBool, das der Dialog erwartet. Schließt der Dialog, wirddeletingwiedernil.
Weil die Löschregel .nullify ist, verlieren die Ausgaben nur ihre Kategorie. In der Liste erscheinen sie dann mit Fragezeichen und „Ohne Kategorie“.
Schritt 6: Nach Kategorie filtern
Zum Schluss soll die Ausgabenliste nur eine Kategorie zeigen können. Die Abfrage muss sich dafür ändern, und @Query lässt sich nur im Initializer einer View festlegen (Lektion 3.15). Also wird die Liste aufgeteilt: Die äußere View hält den Filter, die innere fragt passend ab.
Aufgabe
- Benenne die bisherige Liste in
ExpenseListum. Sie bekommt einen Initializer mitfilter: Category?und baut ihre@Querydarin: ohne Filter alle Ausgaben, mit Filter nur die der Kategorie. ExpenseListViewhält einen@State private var filter: Category?, denNavigationStack, die Toolbar und die Sheets. In der Toolbar gibt es einMenumit einemPickerfür den Filter.- Ist ein Filter aktiv, zeigt der Titel den Namen der Kategorie.
LösungErst selbst probieren!
struct ExpenseList: View {
@Environment(\.modelContext) private var context
@Query private var expenses: [Expense]
@Binding var editing: Expense?
init(filter: Category?, editing: Binding<Expense?>) {
_editing = editing
if let filter {
let name = filter.name
_expenses = Query(
filter: #Predicate<Expense> { $0.category?.name == name },
sort: \Expense.date,
order: .reverse
)
} else {
_expenses = Query(sort: \Expense.date, order: .reverse)
}
}
// days, monthTotal und title(for:) wie in Teil 1
var body: some View {
List {
// Monatssumme und Tagesabschnitte wie in Teil 1,
// Zeilen als Button, die `editing` setzen
}
.overlay {
if expenses.isEmpty {
ContentUnavailableView("Keine Ausgaben", systemImage: "eurosign.circle")
}
}
}
}
struct ExpenseListView: View {
@Query(sort: \Category.name) private var categories: [Category]
@State private var filter: Category?
@State private var showingNew = false
@State private var showingCategories = false
@State private var editing: Expense?
var body: some View {
NavigationStack {
ExpenseList(filter: filter, editing: $editing)
.navigationTitle(filter?.name ?? "Ausgaben")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Kategorien", systemImage: "square.grid.2x2") {
showingCategories = true
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu("Filter", systemImage: filter == nil
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill") {
Picker("Kategorie", selection: $filter) {
Text("Alle").tag(nil as Category?)
ForEach(categories) { category in
Label(category.name, systemImage: category.icon)
.tag(category as Category?)
}
}
}
}
ToolbarItem(placement: .primaryAction) {
Button("Neue Ausgabe", systemImage: "plus") {
showingNew = true
}
}
}
.sheet(isPresented: $showingNew) {
ExpenseForm()
}
.sheet(item: $editing) { expense in
ExpenseForm(expense: expense)
}
.sheet(isPresented: $showingCategories) {
NavigationStack { CategoryListView() }
}
}
}
}- Ändert sich
filter, baut SwiftUIExpenseListmit dem neuen Wert, und der Initializer erzeugt eine neue Abfrage. - Im
#Predicatewird der Name vorher in eine Konstantenamekopiert. Ein Predicate kann keine Eigenschaften von Objekten außerhalb lesen, nur einfache Werte (Lektion 3.15). Weil Namen eindeutig sind (Schritt 4), ist der Filter zuverlässig. - Das gefüllte Filtersymbol zeigt auf einen Blick, dass ein Filter aktiv ist.
editinglebt jetzt in der äußeren View und wird als@Bindingan die Liste gegeben. So bleibt das Sheet in der äußeren View, egal welche Liste gerade angezeigt wird.
Erwartetes Ergebnis
Über das Filtersymbol wählst du etwa „Essen gehen“. Die Liste zeigt nur noch diese Ausgaben, der Titel heißt „Essen gehen“, und die Monatssumme gilt nur für diese Kategorie. „Alle“ hebt den Filter wieder auf.
Bonus-Aufgaben
Bonus 1: Wischen zum Duplizieren
Füge jeder Zeile eine Wischaktion von links hinzu, die die Ausgabe mit heutigem Datum dupliziert. Praktisch für wiederkehrende Ausgaben wie den täglichen Kaffee. Tipp: .swipeActions(edge: .leading).
Bonus 2: Zuletzt verwendete Kategorie
Merke dir mit @AppStorage den Namen der zuletzt verwendeten Kategorie und wähle sie im Formular für neue Ausgaben vor, statt immer der ersten.
Zusammenfassung
- Ein Formular für Neu und Bearbeiten: optionales Modell, lokale States, Übernahme erst in
save(). .sheet(item:)öffnet das Formular für ein bestimmtes Modell.- Raster mit
LazyVGrideignen sich hervorragend für Symbol- und Farbauswahl. - Validierung mit berechneten Eigenschaften, Hinweise im
footereines Abschnitts. !==prüft bei Klassen, ob es dasselbe Objekt ist..confirmationDialog(presenting:)fragt vor dem Löschen nach und nennt die Folgen.- Dynamische Abfragen: Filter in der äußeren View,
@Queryim Initializer der inneren View.