SwiftUI Essentials
Build modern iOS apps with declarative UI programming
🎯 Learning Objectives
Master SwiftUI fundamentals to create beautiful, responsive iOS applications:
- Understand declarative UI programming concepts
- Build complex layouts with stacks and containers
- Manage app state effectively
- Create reusable custom components
- Implement navigation and data flow
🏗️ SwiftUI Architecture
Declarative vs Imperative UI
// ❌ Imperative (UIKit way)
let label = UILabel()
label.text = "Hello, World!"
label.textColor = .blue
label.font = UIFont.systemFont(ofSize: 24)
view.addSubview(label)
// ✅ Declarative (SwiftUI way)
Text("Hello, World!")
.foregroundColor(.blue)
.font(.title)
View Protocol and Body
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, SwiftUI!")
.font(.largeTitle)
.foregroundColor(.primary)
}
}
// Custom view with parameters
struct WelcomeView: View {
let userName: String
let isFirstTime: Bool
var body: some View {
VStack(spacing: 20) {
Text("Welcome, \(userName)!")
.font(.title)
.fontWeight(.bold)
if isFirstTime {
Text("Thanks for joining us!")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding()
}
}
📱 Basic UI Components
Text and Styling
struct TextExamples: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Basic text
Text("Simple text")
// Styled text
Text("Styled Text")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.blue)
// Multi-line text
Text("This is a longer text that will wrap to multiple lines when the content is too wide for the screen.")
.lineLimit(nil)
.multilineTextAlignment(.leading)
// Text with formatting
Text("**Bold** and *italic* text")
.font(.body)
// Concatenated text with different styles
Text("Price: ")
.font(.body) +
Text("$29.99")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.green)
}
.padding()
}
}
Images and SF Symbols
struct ImageExamples: View {
var body: some View {
VStack(spacing: 20) {
// SF Symbol
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundColor(.red)
// Custom image
Image("app-logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
.clipShape(Circle())
// Async image loading (iOS 15+)
AsyncImage(url: URL(string: "https://picsum.photos/200")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
Buttons and Actions
struct ButtonExamples: View {
@State private var counter = 0
@State private var isLiked = false
var body: some View {
VStack(spacing: 20) {
// Basic button
Button("Tap Me") {
counter += 1
}
.buttonStyle(.borderedProminent)
// Custom button with icon
Button(action: {
isLiked.toggle()
}) {
HStack {
Image(systemName: isLiked ? "heart.fill" : "heart")
Text(isLiked ? "Liked" : "Like")
}
.foregroundColor(isLiked ? .red : .primary)
}
.buttonStyle(.bordered)
// Counter display
Text("Counter: \(counter)")
.font(.title2)
// Destructive button
Button("Reset", role: .destructive) {
counter = 0
isLiked = false
}
}
.padding()
}
}
📐 Layout System
Stacks - The Foundation
struct StackExamples: View {
var body: some View {
VStack(spacing: 20) {
// HStack - Horizontal arrangement
HStack(spacing: 16) {
Image(systemName: "person.circle.fill")
.font(.title)
VStack(alignment: .leading) {
Text("John Doe")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button("Follow") { }
.buttonStyle(.bordered)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
// ZStack - Layered arrangement
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(LinearGradient(
colors: [.blue, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(height: 150)
VStack {
Text("Featured")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Text("Special Offer")
.font(.subheadline)
.foregroundColor(.white.opacity(0.8))
}
}
}
.padding()
}
}
LazyVStack and LazyHStack
struct LazyStackExample: View {
let items = Array(1...1000)
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items, id: \.self) { item in
HStack {
Text("Item \(item)")
Spacer()
Text("Value")
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
.padding()
}
}
}
Grid Layouts
struct GridExample: View {
let colors: [Color] = [.red, .blue, .green, .orange, .purple, .pink]
let columns = [
GridItem(.adaptive(minimum: 100))
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(colors.indices, id: \.self) { index in
RoundedRectangle(cornerRadius: 12)
.fill(colors[index])
.frame(height: 100)
.overlay(
Text("Item \(index + 1)")
.foregroundColor(.white)
.fontWeight(.semibold)
)
}
}
.padding()
}
}
}
🔄 State Management
@State - Local State
struct CounterView: View {
@State private var count = 0
@State private var isAnimating = false
var body: some View {
VStack(spacing: 30) {
Text("\(count)")
.font(.system(size: 60, weight: .bold, design: .rounded))
.scaleEffect(isAnimating ? 1.2 : 1.0)
.animation(.spring(response: 0.3), value: isAnimating)
HStack(spacing: 20) {
Button("-") {
count -= 1
animateChange()
}
.buttonStyle(.bordered)
.disabled(count <= 0)
Button("+") {
count += 1
animateChange()
}
.buttonStyle(.borderedProminent)
}
Button("Reset") {
count = 0
animateChange()
}
.buttonStyle(.bordered)
}
.padding()
}
private func animateChange() {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isAnimating = false
}
}
}
@Binding - Shared State
struct SettingsView: View {
@State private var isNotificationsEnabled = true
@State private var isDarkModeEnabled = false
@State private var fontSize: Double = 16
var body: some View {
NavigationView {
Form {
Section("Preferences") {
ToggleRow(
title: "Notifications",
isOn: $isNotificationsEnabled
)
ToggleRow(
title: "Dark Mode",
isOn: $isDarkModeEnabled
)
}
Section("Appearance") {
SliderRow(
title: "Font Size",
value: $fontSize,
range: 12...24
)
}
}
.navigationTitle("Settings")
}
}
}
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View {
HStack {
Text(title)
Spacer()
Toggle("", isOn: $isOn)
}
}
}
struct SliderRow: View {
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
Spacer()
Text("\(Int(value))")
.foregroundColor(.secondary)
}
Slider(value: $value, in: range, step: 1)
}
}
}
@ObservableObject and @StateObject
import Combine
class UserStore: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var errorMessage: String?
func loadUsers() {
isLoading = true
errorMessage = nil
// Simulate network request
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.users = [
User(name: "Alice", email: "alice@example.com"),
User(name: "Bob", email: "bob@example.com"),
User(name: "Charlie", email: "charlie@example.com")
]
self.isLoading = false
}
}
func addUser(_ user: User) {
users.append(user)
}
func deleteUser(at indexSet: IndexSet) {
users.remove(atOffsets: indexSet)
}
}
struct User: Identifiable {
let id = UUID()
let name: String
let email: String
}
struct UserListView: View {
@StateObject private var userStore = UserStore()
@State private var showingAddUser = false
var body: some View {
NavigationView {
Group {
if userStore.isLoading {
ProgressView("Loading users...")
} else if userStore.users.isEmpty {
ContentUnavailableView(
"No Users",
systemImage: "person.slash",
description: Text("Tap the + button to add users")
)
} else {
List {
ForEach(userStore.users) { user in
UserRow(user: user)
}
.onDelete(perform: userStore.deleteUser)
}
}
}
.navigationTitle("Users")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add") {
showingAddUser = true
}
}
}
.sheet(isPresented: $showingAddUser) {
AddUserView(userStore: userStore)
}
.onAppear {
if userStore.users.isEmpty {
userStore.loadUsers()
}
}
}
}
}
struct UserRow: View {
let user: User
var body: some View {
HStack {
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
.overlay(
Text(String(user.name.prefix(1)))
.foregroundColor(.white)
.fontWeight(.semibold)
)
VStack(alignment: .leading) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
struct AddUserView: View {
@ObservedObject var userStore: UserStore
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var email = ""
var body: some View {
NavigationView {
Form {
Section("User Information") {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
.navigationTitle("Add User")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let newUser = User(name: name, email: email)
userStore.addUser(newUser)
dismiss()
}
.disabled(name.isEmpty || email.isEmpty)
}
}
}
}
}
🧭 Navigation
NavigationView and NavigationLink
struct NavigationExample: View {
let categories = ["Technology", "Science", "Sports", "Entertainment"]
var body: some View {
NavigationView {
List(categories, id: \.self) { category in
NavigationLink(destination: CategoryDetailView(category: category)) {
HStack {
Image(systemName: iconForCategory(category))
.foregroundColor(.blue)
.frame(width: 30)
Text(category)
.font(.headline)
}
.padding(.vertical, 4)
}
}
.navigationTitle("Categories")
}
}
private func iconForCategory(_ category: String) -> String {
switch category {
case "Technology": return "laptopcomputer"
case "Science": return "atom"
case "Sports": return "sportscourt"
case "Entertainment": return "tv"
default: return "folder"
}
}
}
struct CategoryDetailView: View {
let category: String
var body: some View {
VStack(spacing: 20) {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.yellow)
Text("Welcome to \(category)")
.font(.title)
.fontWeight(.bold)
Text("This is the detail view for the \(category) category.")
.font(.body)
.multilineTextAlignment(.center)
.padding()
}
.navigationTitle(category)
.navigationBarTitleDisplayMode(.large)
}
}
TabView
struct MainTabView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Image(systemName: "house")
Text("Home")
}
SearchView()
.tabItem {
Image(systemName: "magnifyingglass")
Text("Search")
}
FavoritesView()
.tabItem {
Image(systemName: "heart")
Text("Favorites")
}
ProfileView()
.tabItem {
Image(systemName: "person")
Text("Profile")
}
}
}
}
struct HomeView: View {
var body: some View {
NavigationView {
Text("Home Content")
.navigationTitle("Home")
}
}
}
struct SearchView: View {
var body: some View {
NavigationView {
Text("Search Content")
.navigationTitle("Search")
}
}
}
struct FavoritesView: View {
var body: some View {
NavigationView {
Text("Favorites Content")
.navigationTitle("Favorites")
}
}
}
struct ProfileView: View {
var body: some View {
NavigationView {
Text("Profile Content")
.navigationTitle("Profile")
}
}
}
🎨 Styling and Modifiers
Custom Modifiers
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
// Usage
struct StyledView: View {
var body: some View {
VStack(spacing: 16) {
Text("Card 1")
.cardStyle()
Text("Card 2")
.cardStyle()
}
.padding()
}
}
Environment and Themes
struct ThemeKey: EnvironmentKey {
static let defaultValue = Theme.light
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
struct Theme {
let backgroundColor: Color
let textColor: Color
let accentColor: Color
static let light = Theme(
backgroundColor: .white,
textColor: .black,
accentColor: .blue
)
static let dark = Theme(
backgroundColor: .black,
textColor: .white,
accentColor: .orange
)
}
struct ThemedView: View {
@Environment(\.theme) var theme
var body: some View {
VStack {
Text("Themed Content")
.foregroundColor(theme.textColor)
Button("Action") { }
.foregroundColor(theme.accentColor)
}
.background(theme.backgroundColor)
}
}
🎯 Real-World Project: Weather App
import SwiftUI
struct WeatherApp: View {
@StateObject private var weatherStore = WeatherStore()
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 20) {
if let weather = weatherStore.currentWeather {
CurrentWeatherCard(weather: weather)
HourlyForecastView(forecast: weatherStore.hourlyForecast)
DailyForecastView(forecast: weatherStore.dailyForecast)
} else if weatherStore.isLoading {
ProgressView("Loading weather...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView(
"No Weather Data",
systemImage: "cloud.slash",
description: Text("Pull to refresh")
)
}
}
.padding()
}
.navigationTitle("Weather")
.refreshable {
await weatherStore.loadWeather()
}
}
.task {
await weatherStore.loadWeather()
}
}
}
struct CurrentWeatherCard: View {
let weather: Weather
var body: some View {
VStack(spacing: 16) {
HStack {
VStack(alignment: .leading) {
Text(weather.location)
.font(.title2)
.fontWeight(.semibold)
Text("Today")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing) {
Text("\(weather.temperature)°")
.font(.system(size: 48, weight: .thin))
Text(weather.condition)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
HStack {
WeatherDetail(title: "Feels like", value: "\(weather.feelsLike)°")
Spacer()
WeatherDetail(title: "Humidity", value: "\(weather.humidity)%")
Spacer()
WeatherDetail(title: "Wind", value: "\(weather.windSpeed) mph")
}
}
.padding()
.background(
LinearGradient(
colors: [.blue.opacity(0.6), .purple.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.foregroundColor(.white)
.cornerRadius(16)
}
}
struct WeatherDetail: View {
let title: String
let value: String
var body: some View {
VStack {
Text(title)
.font(.caption)
.opacity(0.8)
Text(value)
.font(.subheadline)
.fontWeight(.semibold)
}
}
}
struct HourlyForecastView: View {
let forecast: [HourlyWeather]
var body: some View {
VStack(alignment: .leading) {
Text("Hourly Forecast")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(forecast) { hour in
VStack(spacing: 8) {
Text(hour.time)
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: hour.icon)
.font(.title2)
.foregroundColor(.blue)
Text("\(hour.temperature)°")
.font(.subheadline)
.fontWeight(.semibold)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
.padding(.horizontal)
}
}
}
}
struct DailyForecastView: View {
let forecast: [DailyWeather]
var body: some View {
VStack(alignment: .leading) {
Text("7-Day Forecast")
.font(.headline)
.padding(.horizontal)
VStack(spacing: 0) {
ForEach(forecast) { day in
HStack {
Text(day.day)
.font(.subheadline)
.frame(width: 60, alignment: .leading)
Image(systemName: day.icon)
.font(.title3)
.foregroundColor(.blue)
.frame(width: 30)
Spacer()
Text("\(day.low)°")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(day.high)°")
.font(.subheadline)
.fontWeight(.semibold)
.frame(width: 40, alignment: .trailing)
}
.padding(.horizontal)
.padding(.vertical, 12)
if day.id != forecast.last?.id {
Divider()
.padding(.horizontal)
}
}
}
.background(Color(.systemGray6))
.cornerRadius(12)
.padding(.horizontal)
}
}
}
// Data Models
struct Weather {
let location: String
let temperature: Int
let condition: String
let feelsLike: Int
let humidity: Int
let windSpeed: Int
}
struct HourlyWeather: Identifiable {
let id = UUID()
let time: String
let temperature: Int
let icon: String
}
struct DailyWeather: Identifiable {
let id = UUID()
let day: String
let high: Int
let low: Int
let icon: String
}
// Store
class WeatherStore: ObservableObject {
@Published var currentWeather: Weather?
@Published var hourlyForecast: [HourlyWeather] = []
@Published var dailyForecast: [DailyWeather] = []
@Published var isLoading = false
func loadWeather() async {
await MainActor.run {
isLoading = true
}
// Simulate API call
try? await Task.sleep(nanoseconds: 1_000_000_000)
await MainActor.run {
currentWeather = Weather(
location: "San Francisco",
temperature: 72,
condition: "Partly Cloudy",
feelsLike: 75,
humidity: 65,
windSpeed: 8
)
hourlyForecast = [
HourlyWeather(time: "Now", temperature: 72, icon: "cloud.sun"),
HourlyWeather(time: "1 PM", temperature: 74, icon: "sun.max"),
HourlyWeather(time: "2 PM", temperature: 76, icon: "sun.max"),
HourlyWeather(time: "3 PM", temperature: 75, icon: "cloud.sun"),
HourlyWeather(time: "4 PM", temperature: 73, icon: "cloud")
]
dailyForecast = [
DailyWeather(day: "Today", high: 76, low: 62, icon: "cloud.sun"),
DailyWeather(day: "Tue", high: 78, low: 64, icon: "sun.max"),
DailyWeather(day: "Wed", high: 75, low: 61, icon: "cloud.rain"),
DailyWeather(day: "Thu", high: 73, low: 59, icon: "cloud.rain"),
DailyWeather(day: "Fri", high: 71, low: 58, icon: "cloud"),
DailyWeather(day: "Sat", high: 74, low: 60, icon: "sun.max"),
DailyWeather(day: "Sun", high: 77, low: 63, icon: "sun.max")
]
isLoading = false
}
}
}
📚 Key Takeaways
- Think Declaratively - Describe what the UI should look like, not how to build it
- Use @State for Local Data - Keep component state private when possible
- Leverage @Binding for Shared State - Pass data between parent and child views
- Embrace Single Source of Truth - Use @ObservableObject for shared app state
- Compose Views - Break complex UIs into smaller, reusable components
- Use Environment for Themes - Share configuration across the app hierarchy
🔗 What's Next?
In the next chapter, we'll explore Navigation & User Input, covering advanced navigation patterns, form handling, and user interaction techniques.
Practice building these examples in Xcode to master SwiftUI fundamentals!