Skip to content

SwiftUI Basics — Building Declarative User Interfaces

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about SwiftUI Basics. We cover key concepts, practical examples, and best practices to help you master this topic.

SwiftUI is Apple's declarative UI framework where you describe what your interface should look like and how it should behave using simple Swift code, and the framework handles layout, rendering, and updates automatically.

What You'll Learn

  • Views and modifiers: the building blocks of SwiftUI
  • Layout containers: VStack, HStack, ZStack
  • Core views: Text, Image, Button, TextField
  • @State for local state management
  • @Binding for child-to-parent communication
  • Building and previewing SwiftUI views

Why It Matters

SwiftUI replaces UIKit's imperative approach with a declarative model. You describe the UI for each state, and SwiftUI computes the transitions. This leads to fewer bugs, less code, and automatic support for dynamic type, dark mode, and Accessibility. SwiftUI is the future of Apple platform development.

Real-World Use

A food delivery app's order screen is built entirely in SwiftUI: a VStack containing the restaurant name (Text), a horizontal scroll of menu item photos (HStack with ScrollView), a list of selected items (List), and a "Place Order" button at the bottom. Each component updates automatically when the underlying data changes.

Learning Path

flowchart LR
  A[Combine Framework
Lesson 24] --> B[SwiftUI Basics
You are here] B --> C[SwiftUI Data Flow
Lesson 26] B --> D[SwiftUI Navigation
Lesson 27] style B fill:#f90,color:#fff

Your First SwiftUI View

Every SwiftUI view conforms to the View protocol and returns a body property.

import SwiftUI

struct GreetingView: View {
    var body: some View {
        Text("Hello, SwiftUI!")
            .font(.largeTitle)
            .foregroundColor(.blue)
            .padding()
            .background(Color.yellow.opacity(0.3))
            .cornerRadius(12)
    }
}

// In a real app:
// struct ContentView: View {
//     var body: some View {
//         GreetingView()
//     }
// }

The view declares what to show: a Text with modifiers applied. Modifiers return a new view with the applied change. The order of modifiers matters because each wraps the previous result.

Layout Containers

SwiftUI provides three primary layout containers: VStack (vertical), HStack (horizontal), and ZStack (depth-based).

import SwiftUI

struct LayoutExample: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("Profile Card")
                .font(.title)
                .fontWeight(.bold)

            HStack(spacing: 12) {
                Image(systemName: "person.circle.fill")
                    .resizable()
                    .frame(width: 60, height: 60)
                    .foregroundColor(.blue)

                VStack(alignment: .leading, spacing: 4) {
                    Text("Alice Johnson")
                        .font(.headline)
                    Text("iOS Developer")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                    Text("San Francisco, CA")
                        .font(.caption)
                        .foregroundColor(.gray)
                }
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(12)

            HStack(spacing: 40) {
                StatView(value: "1.2K", label: "Followers")
                StatView(value: "340", label: "Following")
                StatView(value: "89", label: "Posts")
            }

            ZStack {
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.blue)
                    .frame(height: 44)

                Text("Edit Profile")
                    .foregroundColor(.white)
                    .fontWeight(.semibold)
            }
        }
        .padding()
    }
}

struct StatView: View {
    let value: String
    let label: String

    var body: some View {
        VStack(spacing: 2) {
            Text(value)
                .font(.headline)
            Text(label)
                .font(.caption)
                .foregroundColor(.secondary)
        }
    }
}

VStack and HStack distribute children along their axis. ZStack overlays children, useful for badges, gradients, and layered content.

State with @State

@State creates mutable state that SwiftUI observes. When the state changes, the view re-renders automatically.

import SwiftUI

struct CounterView: View {
    @State private var count = 0
    @State private var isOn = false
    @State private var name = ""

    var body: some View {
        VStack(spacing: 20) {
            Text("Count: \(count)")
                .font(.largeTitle)

            HStack(spacing: 20) {
                Button("Decrement") {
                    count -= 1
                }
                .buttonStyle(.bordered)
                .disabled(count <= 0)

                Button("Increment") {
                    count += 1
                }
                .buttonStyle(.borderedProminent)
            }

            Toggle("Switch is \(isOn ? "ON" : "OFF")", isOn: $isOn)
                .padding(.horizontal, 40)

            TextField("Enter your name", text: $name)
                .textFieldStyle(.roundedBorder)
                .padding(.horizontal, 40)

            if !name.isEmpty {
                Text("Hello, \(name)!")
                    .font(.title2)
                    .foregroundColor(.green)
            }
        }
        .padding()
    }
}

@State properties are private and owned by the view. The $ prefix creates a binding ($count, $isOn, $name) that passes read-write access to child controls like Toggle and TextField.

@Binding for Child Communication

@Binding lets a child view read and write a state variable owned by a parent.

import SwiftUI

struct ParentView: View {
    @State private var volume: Double = 0.5
    @State private var isMuted = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Volume Control")
                .font(.title)

            VolumeSliderView(volume: $volume, isMuted: $isMuted)

            Text("Volume: \(Int(volume * 100))%")
                .font(.headline)

            Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
                .font(.largeTitle)
                .foregroundColor(isMuted ? .red : .blue)
        }
        .padding()
    }
}

struct VolumeSliderView: View {
    @Binding var volume: Double
    @Binding var isMuted: Bool

    var body: some View {
        VStack(spacing: 12) {
            Slider(value: $volume, in: 0...1)
                .tint(isMuted ? .gray : .blue)

            Button(isMuted ? "Unmute" : "Mute") {
                isMuted.toggle()
                if isMuted {
                    volume = 0
                } else {
                    volume = 0.5
                }
            }
            .buttonStyle(.bordered)
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(12)
    }
}

The child view does not own the state — it only has read-write access through the @Binding. When the child changes the binding, both the parent and the child re-render.

Common Views

Text and Image

struct TextImageExample: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("Styling Text")
                .font(.system(.title, design: .monospaced))
                .fontWeight(.black)
                .italic()
                .underline()
                .strikethrough()

            Text("Multiline text that automatically wraps to the next line when it reaches the available width of the container.")
                .lineSpacing(8)
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)

            Image(systemName: "cloud.sun.rain.fill")
                .font(.system(size: 60))
                .foregroundStyle(.blue, .yellow, .cyan)
                .symbolRenderingMode(.palette)

            AsyncImage(url: URL(string: "https://picsum.photos/200")) { phase in
                switch phase {
                case .empty:
                    ProgressView()
                case .success(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 200, height: 200)
                        .cornerRadius(12)
                case .failure:
                    Image(systemName: "photo")
                        .font(.largeTitle)
                @unknown default:
                    EmptyView()
                }
            }
            .frame(width: 200, height: 200)
        }
        .padding()
    }
}

Button Styles

struct ButtonStylesExample: View {
    var body: some View {
        VStack(spacing: 16) {
            Button("Default Button") { print("tapped") }

            Button("Bordered") { }
                .buttonStyle(.bordered)

            Button("Bordered Prominent") { }
                .buttonStyle(.borderedProminent)

            Button("Borderless") { }
                .buttonStyle(.borderless)

            Button(role: .destructive) {
                print("Delete tapped")
            } label: {
                Label("Delete", systemImage: "trash")
            }
            .buttonStyle(.borderedProminent)

            Button {
                print("Custom tapped")
            } label: {
                HStack {
                    Image(systemName: "star.fill")
                    Text("Custom Button")
                }
                .padding()
                .background(
                    LinearGradient(colors: [.purple, .blue],
                                   startPoint: .leading,
                                   endPoint: .trailing)
                )
                .foregroundColor(.white)
                .cornerRadius(12)
            }
        }
        .padding()
    }
}

Modifier Order Matters

The order of modifiers changes the result significantly.

struct ModifierOrderExample: View {
    var body: some View {
        VStack(spacing: 20) {
            // Correct order: background inside padding
            Text("Correct order")
                .padding(20)
                .background(Color.yellow)
                .cornerRadius(8)

            // Wrong order: padding outside background
            Text("Wrong order")
                .background(Color.yellow)
                .cornerRadius(8)
                .padding(20)

            // Frame then background
            Text("Frame first")
                .frame(width: 200, height: 60)
                .background(Color.blue)
                .foregroundColor(.white)

            // Background then frame
            Text("Background first")
                .background(Color.red)
                .foregroundColor(.white)
                .frame(width: 200, height: 60)
        }
        .padding()
    }
}

Each modifier wraps the previous view. Think of modifiers as a chain: view.modifier1().modifier2() applies modifier1 to the view, then modifier2 to the result.

Common Mistakes

  1. Putting logic in body: The body property should only describe the UI. Move logic to computed properties, methods, or view models.

  2. Forgetting $ prefix for bindings: Controls like TextField, Slider, and Toggle need a Binding. Use $ prefix on @State variables.

  3. Using too many ZStacks: Complex ZStack nesting can hurt performance. Prefer overlay and background modifiers.

  4. Not handling optional data: When displaying optional strings or images, unwrap with if let or use ?? default values.

  5. Ignoring dynamic type: Test your UI with larger accessibility text sizes. Use font(.body) instead of fixed sizes where possible.

Practice Questions

  1. What is the difference between @State and @Binding?
  2. Why does modifier order matter in SwiftUI?
  3. How do VStack, HStack, and ZStack differ?
  4. What does the $ prefix do in front of a @State variable?
  5. Challenge: Build a "Tip Calculator" with a TextField for bill amount, a Slider for tip percentage (0-30%), a Toggle to split the bill, and a Stepper for number of people. Display the total bill, tip amount, and per-person cost.

Mini Project

Create a ProfileCardView with:

  • An AsyncImage loading a profile picture from a URL
  • Text fields for name, bio, and location
  • HStack showing follower stats
  • A "Follow" button that toggles between "Follow" and "Following"
  • A custom color theme using gradients
  • Proper padding, corner radius, and shadow styling

FAQ

Can I mix SwiftUI and UIKit?

Yes. Use UIHostingController to embed SwiftUI views in UIKit, and UIViewRepresentable to embed UIKit views in SwiftUI.

What is the View protocol?

The View protocol requires a body: some View computed property. SwiftUI renders whatever view the body returns.

How do I make a view conditionally visible?

Use if statements in the body, the opacity modifier, or the hidden() modifier. The if statement conditionally includes or excludes the view from the layout.

What is 'some View' return type?

The some keyword is an opaque type. It means the body returns one specific view type, but the caller does not need to know which one.

How do I handle button taps from SwiftUI?

Buttons take an action closure: Button('Tap') { print('tapped') }. For more complex gestures, use onTapGesture, onLongPressGesture, or gesture modifiers.

What's Next

Master data flow with SwiftUI Data Flow using ObservableObject and EnvironmentObject, or learn screen transitions in SwiftUI Navigation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro