Try it free
Write faster with AI writing tools
WriterPilots gives you 10+ free AI writing tools — blog writer, email writer, paraphraser, grammar fixer and more. Free to try, no credit card needed.
Try Blog Writer → Paraphraser Summarizer Grammar Fixer
AI Writing Tools

Hands-on: Build an Expense Entry Feature in SwiftUI (Camera Receipt, Validation, Persistence)

March 22, 2026 · 7 min read · 21 views

Introduction

This tutorial shows how to build an expense entry feature in SwiftUI. You will make a small app that accepts a title, amount, date, category, and an optional receipt photo. The app validates input and saves data to a JSON file in the app Documents folder. You will use MVVM and a simple file-based persistence. Follow each step. Each step explains the goal, gives the code, explains why it works, and tells where to put the file in Xcode.

Step 1: Create the Expense model

Goal

Create a simple, Codable model to represent one expense.

Code

import Foundation
struct Expense: Identifiable, Codable {
let id: UUID
let title: String
let amount: Double
let date: Date
let category: String
let receiptFilename: String?
}

Why it works

The struct is Codable so the app can encode and decode it to JSON. Identifiable makes the model easy to show in SwiftUI lists. receiptFilename stores an optional filename for an image saved to disk.

Where to put the file

Place this code in Models/Expense.swift in your Xcode project.

Step 2: Add a simple persistence store

Goal

Create a class that loads and saves expenses and writes receipt images to disk.

Code

import Foundation
import SwiftUI
final class ExpenseStore: ObservableObject {
@Published private(set) var expenses: [Expense] = []
private let fileURL: URL
private let receiptsFolder: URL
init() {
let fm = FileManager.default
let doc = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
self.fileURL = doc.appendingPathComponent("expenses.json")
self.receiptsFolder = doc.appendingPathComponent("Receipts")
try? fm.createDirectory(at: receiptsFolder, withIntermediateDirectories: true)
load()
}
func addExpense(title: String, amount: Double, date: Date, category: String, receipt: UIImage?) {
var filename: String? = nil
if let image = receipt, let data = image.pngData() {
filename = UUID().uuidString + ".png"
let url = receiptsFolder.appendingPathComponent(filename!)
try? data.write(to: url)
}
let expense = Expense(id: UUID(), title: title, amount: amount, date: date, category: category, receiptFilename: filename)
expenses.append(expense)
save()
}
private func save() {
if let data = try? JSONEncoder().encode(expenses) {
try? data.write(to: fileURL)
}
}
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return }
if let decoded = try? JSONDecoder().decode([Expense].self, from: data) {
self.expenses = decoded
}
}
func receiptImage(for filename: String?) -> UIImage? {
guard let name = filename else { return nil }
let url = receiptsFolder.appendingPathComponent(name)
if let data = try? Data(contentsOf: url) {
return UIImage(data: data)
}
return nil
}
}

Why it works

ExpenseStore uses the Documents folder so data persists between launches. The store encodes expenses to JSON and writes them to a file. It saves receipt images to a Receipts folder and keeps only filenames in the model. The class is ObservableObject so views can observe changes.

Where to put the file

Place this code in Services/ExpenseStore.swift.

Step 3: Create the view model for the entry form

Goal

Provide a view model to hold form state and validation logic for the expense entry screen.

Code

import Foundation
import SwiftUI
final class ExpenseEntryViewModel: ObservableObject {
@Published var title = ""
@Published var amountText = ""
@Published var date = Date()
@Published var category = "Other"
@Published var receiptImage: UIImage? = nil
@Published var errorMessage: String? = nil
let categories = ["Food","Transport","Supplies","Other"]
func validate() -> Bool {
errorMessage = nil
if title.trimmingCharacters(in: .whitespaces).isEmpty {
errorMessage = "Title is required."
return false
}
guard let amount = Double(amountText), amount > 0 else {
errorMessage = "Enter a valid amount."
return false
}
return true
}
func amountValue() -> Double {
Double(amountText) ?? 0
}
}

Why it works

The view model keeps the form fields in published properties. validate uses simple checks on title and amount. The view model stays separate from the store. That keeps the view logic clean.

Where to put the file

Place this code in ViewModels/ExpenseEntryViewModel.swift.

Step 4: Add an ImagePicker for camera and library

Goal

Make a small wrapper to present UIImagePickerController in SwiftUI.

Code

import SwiftUI
import UIKit
struct ImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
var sourceType: UIImagePickerController.SourceType = .photoLibrary
@Binding var image: UIImage?
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = sourceType
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}

Why it works

The wrapper uses UIViewControllerRepresentable to show UIImagePickerController. The coordinator passes the selected UIImage back to the SwiftUI view via a binding.

Where to put the file

Place this code in Views/ImagePicker.swift.

Step 5: Build the Expense entry view

Goal

Create a SwiftUI form that uses the view model and the image picker. Add a save action that uses the store.

Code

import SwiftUI
struct ExpenseEntryView: View {
@ObservedObject var vm = ExpenseEntryViewModel()
@EnvironmentObject var store: ExpenseStore
@Environment(\.presentationMode) var presentationMode
@State private var showImagePicker = false
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
var body: some View {
NavigationView {
Form {
Section {
TextField("Title", text: $vm.title)
TextField("Amount", text: $vm.amountText)
.keyboardType(.decimalPad)
DatePicker("Date", selection: $vm.date, displayedComponents: .date)
Picker("Category", selection: $vm.category) {
ForEach(vm.categories, id: \.self) { Text($0) }
}
}
Section {
if let img = vm.receiptImage {
Image(uiImage: img).resizable().scaledToFit().frame(maxHeight: 200)
}
HStack {
Button("Photo") { imageSource = .photoLibrary; showImagePicker = true }
Spacer()
Button("Camera") { imageSource = .camera; showImagePicker = true }
}
}
if let error = vm.errorMessage {
Section { Text(error).foregroundColor(.red) }
}
Section {
Button("Save") {
if vm.validate() {
store.addExpense(title: vm.title, amount: vm.amountValue(), date: vm.date, category: vm.category, receipt: vm.receiptImage)
presentationMode.wrappedValue.dismiss()
}
}
.disabled(!vm.validate())
}
}
.navigationTitle("New Expense")
.sheet(isPresented: $showImagePicker) {
ImagePicker(sourceType: imageSource, image: $vm.receiptImage)
}
}
}
}

Why it works

The view binds UI controls to the view model. The image picker sets vm.receiptImage when the user picks a photo. Save calls store.addExpense to persist the data and then dismisses the view.

Where to put the file

Place this code in Views/ExpenseEntryView.swift.

Step 6: Add a simple listing and wire the app

Goal

Create a content view that lists saved expenses and opens the entry view. Wire the store into the App file.

Code

import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: ExpenseStore
@State private var showNew = false
var body: some View {
NavigationView {
List {
ForEach(store.expenses) { exp in
VStack(alignment: .leading) {
Text(exp.title).font(.headline)
Text("\(exp.amount, specifier: "%.2f") • \(exp.category)")
}
}
}
.navigationTitle("Expenses")
.toolbar {
Button(action: { showNew = true }) { Text("New") }
}
.sheet(isPresented: $showNew) {
ExpenseEntryView().environmentObject(store)
}
}
}
}
import SwiftUI
@main
struct ExpenseApp: App {
@StateObject private var store = ExpenseStore()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(store)
}
}
}

Why it works

ContentView shows saved expenses from the shared store. The App file creates one ExpenseStore and injects it into the environment. That makes data available across views.

Where to put the file

Place ContentView code in Views/ContentView.swift and the app struct in ExpenseApp.swift at the project root.

Notes and small improvements

Use a safe image format like PNG for receipts. Use a background queue for file writes if performance becomes an issue. Add removal of expenses and image cleanup for file deletes. For production, consider Core Data or SwiftData when you need queries and relationships.

Wrap up

You now have a working expense entry flow. You have a Codable model, a simple file-based store, a view model for form logic, an image picker wrapper, a SwiftUI form, and a main view to list entries. Each file has a clear place in the project. Build on this foundation to add editing, search, or sync later.

Related Posts