Skip to main content

Command Palette

Search for a command to run...

Swift UI Masterclass: Building a Complete App

A ground-up walkthrough of SwiftUI navigation, Core Data persistence, and user interactions — build a real iOS task manager from scratch without touching a storyboard.

Updated
9 min read
Swift UI Masterclass: Building a Complete App
H
Himanshu Pant is COO & Co-Founder at Innostax , focused on scaling engineering teams and delivering impactful digital solutions.

I spent my first afternoon with SwiftUI looking for viewDidLoad. Checked the app delegate, then the scene delegate, then figured I must be missing a file. It isn't there. Neither is reloadData(), or the delegate pattern I'd been wiring up without thinking for years. What you get instead is a body property and property wrappers, and you're expected to describe what the screen looks like rather than tell it how to update. That shift is jarring enough that most people spend the first couple of days convinced something is wrong with their project.

Nothing's wrong. It just works differently.

This guide builds a task manager from scratch — screen-to-screen navigation, swipe-to-delete, and storage that sticks around after the app closes. Real features, not throwaway demo code. Each section shows one piece, the code that goes with it, and the reasoning behind choices that aren't obvious from reading the syntax alone.


What We're Covering

The guide runs in order: environment setup, then building the list, then wiring up navigation, then replacing the in-memory array with Core Data. Jumping to the persistence section without the earlier context will leave some of the code looking incomplete — the files reference each other. If you already have the UI built and just need the Core Data wiring, the last two sections are what you want.


Before You Start

Xcode — current version, from the Mac App Store. SwiftUI APIs move with every major release, and running a version behind means your error messages won't line up with anything in the documentation. That's a surprisingly effective way to waste an afternoon.

Some Swift familiaritystruct, @State, and closures specifically. You don't need to be fluent in the language, but those three should at least be shapes you've seen before. Apple's SwiftUI documentation is readable and worth an hour if any of those are new. This guide doesn't stop to explain them as it goes.


A Quick Note on Swift Itself

Apple introduced Swift in 2014 to replace Objective-C — less ceremony, a stronger type system, and a compiler that catches at build time the things Objective-C was happy to let explode at runtime. It runs on iOS, macOS, watchOS, tvOS, and Linux.

SwiftUI is the UI layer written in Swift. The type enforcement that slows you down at first — the build failing over a mismatched type you barely changed — is the exact same mechanism preventing a class of runtime crashes from ever reaching a device. It feels like obstruction until the day it catches something that would have been a real bug. After that it stops feeling like obstruction.


Setting Up the Xcode Project

Open Xcode, choose Create a new Xcode project, pick iOS → App, and select SwiftUI as the interface. Name the project, pick a location, hit Create.

Xcode generates the entry point, preview infrastructure, and ContentView.swift. That last file is where everything in this guide begins. The surrounding boilerplate — scene delegate, app struct — can stay untouched.


Building the Task List UI

At this stage the app needs three things: a visible list of tasks, a way to add new ones, and swipe-to-delete. Simple enough that the SwiftUI mechanics stay visible, real enough that it's worth running on a device.

Open ContentView.swift and replace whatever Xcode generated:

import SwiftUI

struct ContentView: View {
    @State private var tasks = ["Task 1", "Task 2", "Task 3"]
    @State private var newTask = ""

    var body: some View {
        NavigationView {
            List {
                ForEach(tasks, id: \.self) { task in
                    Text(task)
                }
                .onDelete(perform: deleteTask)
            }
            .navigationTitle("Task Manager")
            .navigationBarItems(trailing: addButton)
        }
    }

    var addButton: some View {
        Button(action: {
            tasks.append(newTask)
            newTask = ""
        }) {
            Image(systemName: "plus")
        }
        .disabled(newTask.isEmpty)
    }

    func deleteTask(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

@State is doing the core work here. Both variables — tasks and newTask — are the source of truth for this view. When tasks changes, SwiftUI re-renders whatever depends on it. There's no reloadData() call to remember, no notification to fire. You update the array; the list reflects it.

.onDelete handles swipe-to-delete in a single modifier. .disabled(newTask.isEmpty) keeps the add button inert when the field is blank — no guard statement, no separate validation pass. The rule lives in the same place as the button.

In UIKit that same logic would be split across at least two places: a validation check, and a method updating the button's isEnabled state, with you responsible for keeping them in sync. Here they're the same line. That compression is the thing that takes time to stop second-guessing.


Adding Navigation to a Detail View

Tapping a task currently does nothing. To fix that, make a new SwiftUI file — call it TaskDetailView.swift:

import SwiftUI

struct TaskDetailView: View {
    var task: String

    var body: some View {
        Text(task)
            .navigationTitle("Task Detail")
    }
}

struct TaskDetailView_Previews: PreviewProvider {
    static var previews: some View {
        TaskDetailView(task: "Sample Task")
    }
}

Then back in ContentView.swift, replace the bare Text inside the ForEach with a NavigationLink:

ForEach(tasks, id: \.self) { task in
    NavigationLink(destination: TaskDetailView(task: task)) {
        Text(task)
    }
}
.onDelete(perform: deleteTask)

NavigationLink takes a destination and a label. Give it those two things. The push animation, the back button, the stack management — none of that is your problem. SwiftUI owns it. You declare where a tap leads; the framework works out the rest.


Making Data Persist with Core Data

Here's the part most tutorials skip, and the reason most demo apps feel hollow: everything built so far lives in RAM. Close the app and reopen it — the array is gone, reset to whatever the initializer sets. That's a detail that doesn't matter for a tutorial but kills the usefulness of a real app immediately.

Core Data is Apple's on-device storage layer. It's been in the platform for a long time, and the SwiftUI bindings are clean enough that you're not fighting the integration. Three files need updating, and the changes build on each other.

Start with Task+CoreDataProperties.swift — this defines the fetch request and the managed property:

import CoreData

extension Task {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<Task> {
        return NSFetchRequest<Task>(entityName: "Task")
    }

    @NSManaged public var title: String?
}

Then TaskDetailView.swift — swap the plain String parameter for a Core Data Task object:

import SwiftUI
import CoreData

struct TaskDetailView: View {
    @Environment(\.managedObjectContext) private var viewContext

    var task: Task

    var body: some View {
        Text(task.title ?? "No title")
            .navigationTitle("Task Detail")
    }
}

Then ContentView.swift — replace the hardcoded array with a @FetchRequest that pulls from the store:

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(entity: Task.entity(), sortDescriptors: []) var tasks: FetchedResults<Task>
    @State private var newTask = ""

    var body: some View {
        NavigationView {
            List {
                ForEach(tasks, id: \.self) { task in
                    NavigationLink(destination: TaskDetailView(task: task)) {
                        Text(task.title ?? "No title")
                    }
                }
                .onDelete(perform: deleteTask)
            }
            .navigationTitle("Task Manager")
            .navigationBarItems(trailing: addButton)
        }
    }

    // addButton and deleteTask remain the same
}

@FetchRequest loads the data when the view appears and keeps watching the store. Add a task and a row appears. Delete one and it's gone — from disk, not just from a variable that was going to disappear on the next launch regardless. You write none of the sync code; the property wrapper handles it.

Worth noting: If you're working on an existing app with a Core Data model that already defines a Task entity differently, migrating to this schema involves more than swapping files. A new project takes roughly ten minutes. An app with live user data is a different problem — read Apple's Core Data migration docs before touching the model file, because getting the migration wrong is technically recoverable but unpleasant to debug.


What You Have Now — and Where to Go Next

The app navigates between screens, writes tasks to disk, and handles input — built without a storyboard, without IBOutlet, without a single delegate protocol.

What the project actually showed is that SwiftUI keeps coming back to one move: describe the UI for a given state, and the framework handles transitions when state changes. The list, the navigation push, the Core Data sync — each of those was the same idea applied to a different layer. Once that pattern is familiar it starts looking familiar everywhere, including in SwiftUI APIs you haven't touched yet.

@ObservableObject is worth learning next if you need state shared across views that don't have a direct parent-child relationship. The task() modifier handles async data loading without blocking the UI thread. The Observable macro that shipped in iOS 17 makes a lot of what @ObservableObject required unnecessary. None of those require unlearning anything built here — they extend it.

Read more - SwiftUI Masterclass: Building a Complete App


Himanshu Pant - COO, Innostax

About Innostax

Founded in 2014, Innostax is a software development company built on accountability and ownership. We deliver progress with clarity—flagging risks early, aligning teams, and ensuring quality at every step. With a commitment to responsibility and reliability, we take full ownership of everything we build, making software development seamless and dependable.

Swift UI Masterclass: Building a Complete App