DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

A small SwiftUI task-list tutorial

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

What You Need Before We Start

Hardware: Any Mac with Apple Silicon (M1 or newer). Intel Macs work too, but the Simulator is painfully slow on them.

Software:

  • macOS 15 or later
  • Xcode 16 or later (free from the Mac App Store)
  • An Apple ID (also free)

You can follow the simulator examples without buying a developer membership. Check Apple's current account and signing requirements for device testing and distribution.

Step 1: Create Your First Xcode Project

Open Xcode. Click Create New Project. Select iOS > App. Click Next.

Fill in these details:

  • Product Name: MyFirstApp
  • Organization Identifier: com.yourname (use your domain or just make something up)
  • Interface: SwiftUI (this is critical, do NOT pick Storyboard)
  • Language: Swift
  • Storage: None (we will add data later)

Click Next, choose where to save it, and click Create.

Step 2: Understanding the Project Structure

Xcode just generated several files for you. Here is what matters:

MyFirstApp/
  MyFirstAppApp.swift    // Entry point
  ContentView.swift       // Your first screen
  Assets.xcassets         // Images and colors
  Preview Content/        // Preview assets
Enter fullscreen mode Exit fullscreen mode

Open MyFirstAppApp.swift:

import SwiftUI

@main
struct MyFirstAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the entry point. The @main attribute tells iOS "start here." The WindowGroup creates a window, and ContentView() is the first thing users see.

Now open ContentView.swift:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}
Enter fullscreen mode Exit fullscreen mode

Hit Cmd+R to run it. You should see "Hello, world!" on the simulator. Congrats, you just built an iOS app. But we are not stopping here.

Step 3: Building a Real UI with SwiftUI

Let's build something useful. We will create a simple task manager app with a list of tasks, the ability to add new ones, and mark them as complete.

First, let's create our data model. Create a new Swift file (Cmd+N > Swift File) called Task.swift:

import Foundation

struct TaskItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
    var createdAt: Date = .now
}
Enter fullscreen mode Exit fullscreen mode

Why TaskItem and not just Task? Because Swift Concurrency already has a Task type. A distinct model name avoids shadowing and confusion.

The Identifiable protocol tells SwiftUI "each item has a unique id." This is required for lists.

Step 4: Creating the ViewModel

SwiftUI works best with the MVVM pattern (Model-View-ViewModel). The ViewModel holds your data and business logic. The View just displays it.

Create a new file called TaskViewModel.swift:

import SwiftUI

@Observable
class TaskViewModel {
    var tasks: [TaskItem] = []
    var newTaskTitle: String = ""

    func addTask() {
        guard !newTaskTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
            return
        }

        let task = TaskItem(title: newTaskTitle)
        tasks.insert(task, at: 0)
        newTaskTitle = ""
    }

    func toggleTask(_ task: TaskItem) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].isCompleted.toggle()
        }
    }

    func deleteTask(_ task: TaskItem) {
        tasks.removeAll { $0.id == task.id }
    }

    var completedCount: Int {
        tasks.filter(\.isCompleted).count
    }

    var pendingCount: Int {
        tasks.count - completedCount
    }
}
Enter fullscreen mode Exit fullscreen mode

This example uses @observable, available with Observation on iOS 17 and later. ObservableObject and Published remain relevant for compatible deployment targets and existing designs; use the ownership pattern appropriate to the model.

Step 5: Building the Task List View

Now replace the contents of ContentView.swift:

import SwiftUI

struct ContentView: View {
    @State private var viewModel = TaskViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Stats bar
                HStack {
                    Label("\(viewModel.pendingCount) pending",
                          systemImage: "clock")
                    Spacer()
                    Label("\(viewModel.completedCount) done",
                          systemImage: "checkmark.circle")
                }
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .padding()

                // Input field
                HStack {
                    TextField("Add a new task...",
                              text: $viewModel.newTaskTitle)
                        .textFieldStyle(.roundedBorder)
                        .onSubmit {
                            viewModel.addTask()
                        }

                    Button(action: viewModel.addTask) {
                        Image(systemName: "plus.circle.fill")
                            .font(.title2)
                    }
                    .disabled(
                        viewModel.newTaskTitle
                            .trimmingCharacters(in: .whitespaces)
                            .isEmpty
                    )
                }
                .padding(.horizontal)

                // Task list
                List {
                    ForEach(viewModel.tasks) { task in
                        TaskRowView(task: task) {
                            viewModel.toggleTask(task)
                        }
                    }
                    .onDelete { indexSet in
                        let selectedTasks = indexSet.map { viewModel.tasks[$0] }
                        for task in selectedTasks {
                            viewModel.deleteTask(task)
                        }
                    }
                }
                .listStyle(.plain)
                .overlay {
                    if viewModel.tasks.isEmpty {
                        ContentUnavailableView(
                            "No Tasks Yet",
                            systemImage: "checklist",
                            description: Text(
                                "Add your first task above"
                            )
                        )
                    }
                }
            }
            .navigationTitle("My Tasks")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Creating the Task Row Component

Create a new file called TaskRowView.swift:

import SwiftUI

struct TaskRowView: View {
    let task: TaskItem
    let onToggle: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            Button(action: onToggle) {
                Image(systemName: task.isCompleted
                      ? "checkmark.circle.fill"
                      : "circle")
                    .font(.title3)
                    .foregroundStyle(
                        task.isCompleted ? .green : .gray
                    )
            }
            .buttonStyle(.plain)

            VStack(alignment: .leading, spacing: 4) {
                Text(task.title)
                    .strikethrough(task.isCompleted)
                    .foregroundStyle(
                        task.isCompleted ? .secondary : .primary
                    )

                Text(task.createdAt, style: .relative)
                    .font(.caption)
                    .foregroundStyle(.tertiary)
            }

            Spacer()
        }
        .padding(.vertical, 4)
        .animation(.easeInOut, value: task.isCompleted)
    }
}
Enter fullscreen mode Exit fullscreen mode

Build and run your adapted example, then check adding, completing and deleting tasks. The snippets have not been presented as a verified complete project.

Step 7: Adding Navigation to a Detail Screen

Most apps have multiple screens. Let's add a detail view for each task. Create TaskDetailView.swift:

import SwiftUI

struct TaskDetailView: View {
    let task: TaskItem

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            HStack {
                Image(systemName: task.isCompleted
                      ? "checkmark.circle.fill"
                      : "circle")
                    .font(.largeTitle)
                    .foregroundStyle(
                        task.isCompleted ? .green : .orange
                    )

                VStack(alignment: .leading) {
                    Text(task.isCompleted
                         ? "Completed"
                         : "In Progress")
                        .font(.headline)
                    Text(
                        "Created \(task.createdAt, style: .relative) ago"
                    )
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.ultraThinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            Spacer()
        }
        .padding()
        .navigationTitle(task.title)
        .navigationBarTitleDisplayMode(.large)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now update the ForEach in ContentView.swift to wrap each row in a NavigationLink:

ForEach(viewModel.tasks) { task in
    NavigationLink(value: task) {
        TaskRowView(task: task) {
            viewModel.toggleTask(task)
        }
    }
}
.onDelete { indexSet in
    let selectedTasks = indexSet.map { viewModel.tasks[$0] }
                        for task in selectedTasks {
                            viewModel.deleteTask(task)
                        }
}
Enter fullscreen mode Exit fullscreen mode

And add a .navigationDestination modifier to the NavigationStack:

NavigationStack {
    VStack(spacing: 0) {
        // ... existing code
    }
    .navigationTitle("My Tasks")
    .navigationDestination(for: TaskItem.self) { task in
        TaskDetailView(task: task)
    }
}
Enter fullscreen mode Exit fullscreen mode

For this to work, make TaskItem conform to Hashable:

struct TaskItem: Identifiable, Hashable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
    var createdAt: Date = .now
}
Enter fullscreen mode Exit fullscreen mode

Now tapping any task row navigates to its detail view with a smooth push animation.

Step 8: Saving Data with UserDefaults

Right now, your tasks disappear when you close the app. Let's fix that by saving to UserDefaults. This is the simplest persistence option in iOS.

Update TaskItem to conform to Codable:

struct TaskItem: Identifiable, Hashable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    var createdAt: Date

    init(id: UUID = UUID(), title: String,
         isCompleted: Bool = false,
         createdAt: Date = .now) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = createdAt
    }
}
Enter fullscreen mode Exit fullscreen mode

Now add save/load methods to the ViewModel:

@Observable
class TaskViewModel {
    var tasks: [TaskItem] = [] {
        didSet { save() }
    }
    var newTaskTitle: String = ""

    private let key = "saved_tasks"

    init() {
        load()
    }

    private func save() {
        if let data = try? JSONEncoder().encode(tasks) {
            UserDefaults.standard.set(data, forKey: key)
        }
    }

    private func load() {
        guard let data = UserDefaults.standard.data(forKey: key),
              let decoded = try? JSONDecoder()
                  .decode([TaskItem].self, from: data)
        else { return }
        tasks = decoded
    }

    // ... rest of the methods stay the same
}
Enter fullscreen mode Exit fullscreen mode

That is it. Your tasks now persist between app launches. This tutorial uses UserDefaults for a small demonstration. Choose persistence for a real app based on data volume, security, migration and concurrency requirements.

Step 9: Polishing the UI

Let's add some visual polish. SwiftUI makes this easy.

Add a custom color scheme to your ContentView:

.navigationTitle("My Tasks")
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Menu {
            Button("Clear Completed") {
                viewModel.tasks.removeAll(
                    where: \.isCompleted
                )
            }
        } label: {
            Image(systemName: "ellipsis.circle")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Add haptic feedback when completing a task. In the toggleTask method:

func toggleTask(_ task: TaskItem) {
    if let index = tasks.firstIndex(
        where: { $0.id == task.id }
    ) {
        tasks[index].isCompleted.toggle()

        let generator = UIImpactFeedbackGenerator(
            style: .light
        )
        generator.impactOccurred()
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 10: Running on Your Real iPhone

You do NOT need a paid developer account for this. Just:

  1. Plug your iPhone into your Mac with a cable
  2. In Xcode, select your iPhone from the device dropdown (top center)
  3. Go to Xcode > Settings > Accounts and sign in with your Apple ID
  4. Select your team in the project settings (Signing & Capabilities)
  5. Hit Cmd+R

Follow the current Xcode signing instructions and the prompts on your device; the exact steps depend on OS and account configuration.

What to Learn Next

The tutorial sketches these areas:

  • SwiftUI views and components
  • MVVM architecture with @observable
  • NavigationStack for multi-screen apps
  • Data persistence with UserDefaults
  • Custom UI components
  • Haptic feedback

Here is what I would learn next, in this order:

  1. SwiftData for proper database storage
  2. Async/await for network requests
  3. Charts framework for data visualization
  4. WidgetKit for home screen widgets
  5. App Store submission process

The best way to learn is to build something you actually want to use. A habit tracker, a recipe app, a workout log. Pick something personal and start hacking.

Scope of the code examples

These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.

Top comments (0)