İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

SwiftUI First App: A Step-by-Step Guide for Beginners

Ahmet Balaman

7 min read

SwiftSwiftUIiOSXcodeStateList
SwiftUI First App: A Step-by-Step Guide for Beginners

SwiftUI is Apple's UI framework where you describe the interface in code: you write what should be on screen, and keeping the screen in sync with your data is SwiftUI's job. In this post we go from an empty Xcode project to a working to-do list app. The goal is not memorising APIs but seeing how App, View, @State, List and TextField connect to each other.

If Swift syntax is completely new to you, skim the Optionals and guard let post first; the code below uses guard and assumes you know what an optional is.

Creating the Project in Xcode

You need a Mac and Xcode, which is free on the App Store. Running in the simulator does not require a paid developer account.

  1. Open Xcode and choose Create New Project.
  2. Pick iOS at the top and the App template.
  3. Type TodoList as the Product Name. Interface should be SwiftUI, Language Swift.
  4. Leave Storage at None; we are not touching a database in a first app.
  5. Choose a folder and create the project.

Xcode generates two Swift files: TodoListApp.swift and ContentView.swift. For now, that is the whole app.

App, Scene and View: Three Layers

TodoListApp.swift is the entry point:

import SwiftUI

@main
struct TodoListApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Three concepts live here:

  • App: the application itself. @main means "the program starts here", and there is exactly one per project.
  • Scene: a window of the app. On iPhone, WindowGroup means one full-screen window; on iPad and Mac the same code can open several.
  • View: everything you see on screen. ContentView is our first screen.

Coming from Flutter, the mapping is easy: App is roughly runApp plus MaterialApp, and a View is a widget. Every View is a struct, and its only requirement is to return a body. Why a struct and not a class is a topic of its own, covered in struct vs class in Swift.

Layout with VStack and HStack

Layout in SwiftUI is nested boxes. VStack stacks its children vertically, HStack horizontally, and ZStack on top of each other.

struct HelloView: View {
    var body: some View {
        VStack(spacing: 12) {
            Text("Hello SwiftUI")
                .font(.title)
            HStack {
                Image(systemName: "star.fill")
                Text("My first screen")
            }
        }
        .padding()
    }
}

Calls like .font(.title) and .padding() are modifiers. Each modifier wraps the view before it and returns a new view, so order matters. .padding() followed by .background(...) paints the padding too; the other way round does not.

@State: Connecting the Screen to Data

Because views are structs, they cannot change their own properties from inside body. Data that changes has to be handed over to SwiftUI, and the simplest way to do that is @State.

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("Count: \(count)")
                .font(.largeTitle)
            Button("Increment") {
                count += 1
            }
        }
    }
}

When count changes, SwiftUI recomputes body and updates only what actually changed on screen. There is no setState to call and no label to update by hand. Keep @State properties private: that data belongs to this view and nobody else.

Building the To-Do List

Start with the model. You can put it at the top of ContentView.swift:

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isDone = false
}

Identifiable is what lets SwiftUI tell list rows apart. When a row is deleted or moved, it tracks which row is which through id.

Now the screen itself:

struct ContentView: View {
    @State private var items: [TodoItem] = [
        TodoItem(title: "Install Xcode"),
        TodoItem(title: "Write the first SwiftUI screen")
    ]
    @State private var newTitle = ""

    private var trimmedTitle: String {
        newTitle.trimmingCharacters(in: .whitespaces)
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                HStack {
                    TextField("New task", text: $newTitle)
                        .textFieldStyle(.roundedBorder)
                        .onSubmit(addItem)

                    Button("Add", action: addItem)
                        .disabled(trimmedTitle.isEmpty)
                }
                .padding()

                List {
                    ForEach($items) { $item in
                        TodoRow(item: $item)
                    }
                    .onDelete { offsets in
                        items.remove(atOffsets: offsets)
                    }
                }
            }
            .navigationTitle("To-Do")
        }
    }

    private func addItem() {
        guard !trimmedTitle.isEmpty else { return }
        items.append(TodoItem(title: trimmedTitle))
        newTitle = ""
    }
}

Three details deserve attention.

$newTitle: a TextField does not just read the text, it changes it as the user types. So it wants a two-way connection to the value rather than the value itself. The $ prefix means "give me the Binding of this state".

ForEach($items) { $item in … }: we iterate over the array as bindings so each row can modify its own element. If the rows only displayed data, ForEach(items) { item in … } would be enough.

.onDelete: one line gives you swipe-to-delete. List also takes care of scrolling, separators and row reuse.

The row lives in its own view:

struct TodoRow: View {
    @Binding var item: TodoItem

    var body: some View {
        Button {
            item.isDone.toggle()
        } label: {
            HStack {
                Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle")
                    .foregroundStyle(item.isDone ? .green : .secondary)
                Text(item.title)
                    .strikethrough(item.isDone)
                    .foregroundStyle(item.isDone ? .secondary : .primary)
                Spacer()
            }
        }
        .buttonStyle(.plain)
    }
}

@Binding is for views that do not own the data but may change it. The data lives in ContentView; TodoRow only borrows it. That ownership split becomes important as an app grows, and it continues in data flow with @State, @Binding and @Observable.

Previews: Seeing It Without Running

Add previews at the bottom of the file:

#Preview {
    ContentView()
}

#Preview("Completed row") {
    TodoRow(item: .constant(TodoItem(title: "Sample task", isDone: true)))
}

The canvas on the right side of Xcode refreshes as you save. If it is hidden, turn it on via Editor > Canvas. The .constant(...) in the second preview is how you feed a fixed value to a view that expects a binding. Previewing a view in each of its states is much faster than tapping your way to that state in the simulator.

Running on the Simulator and a Real Device

Pick an iPhone simulator from the device menu at the top of the Xcode window and press Cmd + R. The first build takes a while; later ones are quick.

To try it on your own phone:

  1. Connect the iPhone by cable and confirm the "Trust This Computer" prompt.
  2. In the project settings, open Signing & Capabilities and select your Apple account as the Team. A free account is enough for testing on your own device.
  3. On the iPhone, enable Settings > Privacy & Security > Developer Mode.
  4. Select your phone in the device menu and run.

Distributing the app to other people requires a paid Apple Developer Program membership; that part is covered step by step in the App Store publishing guide.

When to Use It and When Not To

For someone starting out, SwiftUI is the right entry point: you get results with little code, and Apple ships its newer APIs for SwiftUI first. For a new project it should be the default.

The alternative is UIKit. If you will work in an older codebase, or you need a heavily customised component, UIKit knowledge pays off. The two are not mutually exclusive; a UIKit component can be embedded in SwiftUI through UIViewRepresentable.

If you want to ship the same app to both iOS and Android from one codebase, SwiftUI is the wrong tool, because it only runs on Apple platforms. A cross-platform framework such as Flutter makes more sense there; I compared the options in Flutter vs React Native.

Common Mistakes

1. Mutating a property without @State

struct BadCounter: View {
    var count = 0
    var body: some View {
        Button("Increment") { count += 1 }  // does not compile
    }
}

Symptom: a compile error along the lines of Left side of mutating operator isn't mutable: 'self' is immutable. Fix: declare it as @State private var count = 0.

2. Passing a value to TextField without $

Symptom: Cannot convert value 'text' of type 'String' to expected type 'Binding<String>'. Write text: $newTitle instead of text: newTitle. The rule is simple: if the control is going to change the value, it wants a binding.

3. Forgetting Identifiable on the model

Symptom: an error on the ForEach or List line saying it requires that 'TodoItem' conform to 'Identifiable'. Add Identifiable and a unique id. Using the title as identity with id: \.self looks tempting, but once two items share a title the rows get mixed up and the wrong one is deleted.

4. Writing everything in one body

Symptom: The compiler is unable to type-check this expression in reasonable time, or simply an unreadable body hundreds of lines long. Fix: split it into small views, as we did with TodoRow. Small views are easier on the compiler and can be previewed on their own.

Where to Go from Here

Close the app and the list resets, because the data only lives in memory. Natural next steps: persisting the data, navigating to a second screen with NavigationLink, and loading data from the network. For the last one, the async/await guide is a good follow-up. If you would rather work through these topics in order with a teacher, see the Swift lessons page.

Frequently Asked Questions

Do I need to learn UIKit before SwiftUI?

No. You can start directly with SwiftUI; basic Swift syntax is enough. You turn to UIKit when you work on an older project or need a custom component SwiftUI does not cover.

Do I need a Mac to build a SwiftUI app?

Yes. Xcode only runs on macOS, and you need Xcode to build and sign an iOS app. You can try the Swift language itself on other platforms, but the SwiftUI interface and the simulator require a Mac.

Do I need a paid account to test on my own iPhone?

No. A free Apple account lets you install the app on your own device from Xcode. The paid Apple Developer Program is needed once you want to distribute through TestFlight or the App Store.

What is the difference between @State and @Binding?

@State owns the data; that view stores the value. @Binding is a two-way connection to data owned by another view. A child view that needs to change the data takes a @Binding, and the parent passes it with $.

Comments