Project 7 — iExpense
iExpense хранит личные и рабочие расходы, добавляет записи на отдельном экране и сохраняет их между запусками. Проект вводит shared reference state и persistence.
Готовность к проекту
0 из 7 рекомендуемых тем изучено. Проект всегда доступен — это не ограничение.
Рабочий vertical slice
import SwiftUI
import Observation
struct ExpenseItem: Identifiable, Codable {
var id = UUID()
let name: String
let amount: Double
}
@Observable
final class Expenses {
var items: [ExpenseItem] = [] { didSet { save() } }
var persistenceError: String?
init() {
guard let data = UserDefaults.standard.data(forKey: "expenses") else { return }
do {
items = try JSONDecoder().decode([ExpenseItem].self, from: data)
} catch {
persistenceError = "Не удалось прочитать сохранённые расходы: \(error.localizedDescription)"
}
}
private func save() {
do {
let data = try JSONEncoder().encode(items)
UserDefaults.standard.set(data, forKey: "expenses")
persistenceError = nil
} catch {
persistenceError = "Не удалось сохранить расходы: \(error.localizedDescription)"
}
}
}
struct ContentView: View {
@State private var expenses = Expenses()
@State private var showingAdd = false
var body: some View {
NavigationStack {
List {
ForEach(expenses.items) { item in
HStack {
Text(item.name)
Spacer()
Text(item.amount, format: .currency(code: "RUB"))
}
}
.onDelete { expenses.items.remove(atOffsets: $0) }
}
.overlay {
if expenses.items.isEmpty {
ContentUnavailableView("Расходов нет", systemImage: "creditcard")
}
}
.navigationTitle("iExpense")
.toolbar { Button("Добавить", systemImage: "plus") { showingAdd = true } }
.sheet(isPresented: $showingAdd) { AddExpenseView(expenses: expenses) }
.alert(
"Ошибка данных",
isPresented: Binding(
get: { expenses.persistenceError != nil },
set: { if !$0 { expenses.persistenceError = nil } }
)
) {
Button("OK") { expenses.persistenceError = nil }
} message: {
Text(expenses.persistenceError ?? "Неизвестная ошибка")
}
}
}
}
struct AddExpenseView: View {
@Environment(\.dismiss) private var dismiss
let expenses: Expenses
@State private var name = ""
@State private var amount = 0.0
var body: some View {
NavigationStack {
Form {
TextField("Название", text: $name)
TextField("Сумма", value: $amount, format: .number)
.keyboardType(.decimalPad)
}
.navigationTitle("Новый расход")
.toolbar {
Button("Сохранить") {
expenses.items.append(ExpenseItem(name: name, amount: amount))
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || amount <= 0)
}
}
}
}Главная концепция — один Expenses, созданный владельцем через @State и переданный editor обычным параметром. Сначала проверьте add/delete и перезапуск, затем добавляйте категории.
Модель расхода
struct ExpenseItem: Identifiable, Codable {
var id = UUID()
let name: String
let type: String
let amount: Double
}Это знакомый struct, который соответствует двум protocols. Identifiable даёт стабильный id для List, а Codable позволяет кодировать значение в данные.
Общий observable state
@Observable
final class Expenses {
var items = [ExpenseItem]()
}Class выбирается осмысленно: нескольким View нужен один объект с общей идентичностью. @Observable сообщает SwiftUI, какие reads зависят от его properties.
View-владелец создаёт модель через @State private var expenses = Expenses(). Дочернему экрану передаётся обычная var expenses: Expenses; @Bindable нужен только там, где требуется $expenses.someProperty. Это сохраняет один источник истины и ясный срок жизни модели.
Sheet и dismiss
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: expenses)
}Sheet показывает временный экран поверх текущего. В дочернем View @Environment(\.dismiss) получает системное действие закрытия — его не нужно передавать вручную через каждый уровень.
Сохранение
do {
let encoded = try JSONEncoder().encode(items)
UserDefaults.standard.set(encoded, forKey: "Items")
persistenceError = nil
} catch {
persistenceError = "Не удалось сохранить расходы: \(error.localizedDescription)"
}do/catch сохраняет понятный persistenceError, который рабочий slice показывает через alert. Так ошибка кодирования или повреждённые данные не исчезают молча. UserDefaults подходит для небольшого учебного массива; связанные и растущие данные лучше перенести в SwiftData.
Удаление из List
.onDelete(perform: removeItems)
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}Modifier передаёт function как значение — это знакомая идея closures/functions.
Испытание
Задание
Покажи личные и рабочие расходы отдельными sections, добавь валютное форматирование и цвет для крупных сумм.Что уже нужно знать
Array filtering, conditions, computed properties, `List` и modifiers.Показать подсказку 1
Показать подсказку 2
Показать решение
var personalItems: [ExpenseItem] { items.filter { $0.type == "Personal" } }
var businessItems: [ExpenseItem] { items.filter { $0.type == "Business" } }Что ты сейчас использовал
Swift Знакомые основы
SwiftUI Новое в проекте
- + @State
- + Binding и $property
- + Navigation
- + Observable state
- + UserDefaults
- + Codable
- + sheet
Дальше загрузим более сложные локальные данные в Moonshot →.
Комментарии
Вопросы, находки и подсказки по этой странице.
Войди, чтобы задать вопрос или помочь другим.