Complete iOS Tutorial 2024
Instructor: Carlos (Blossom Build) | Updated: 2024 | ⏱ Estimated Read: 55–70 Minutes
In this comprehensive guide, you will learn how to build a fully-featured, production-quality iOS movie and TV browsing application completely from scratch using SwiftUI, Xcode, Swift Data, the TMDB (The Movie Database) API, and the YouTube Data API. By the end of this tutorial, you will have a real app running on your simulator (or device) featuring a dynamic home screen, powerful async search, YouTube trailer playback, and offline download management — complete with persistent storage using Swift Data.
This project is inspired by a UI Kit version built by Omer on YouTube. This updated version uses SwiftUI and the latest iOS features, making it an ideal learning project for developers in 2024 and beyond. Whether you are a beginner or an intermediate Swift developer, this tutorial is packed with industry best practices, design patterns, and real API integration that will prepare you for professional iOS development.
📌 What You Will Build
A complete iOS movie and TV browsing app called Blossom Movie with 4 tabs: Home (hero title + trending/top-rated lists), Upcoming (vertical list of upcoming movies), Search (grid layout with live search for movies & TV), and Downloads (offline titles saved with Swift Data). Full YouTube trailer playback included.
Section 1 — App Overview
App Overview & Features
The app we are building is called Blossom Movie. It is a fully functional iOS application that allows users to browse trending movies and TV shows, watch YouTube trailers, search for titles, and save favorites for offline viewing.
🏠
Home Screen
Hero title that changes on every open, trending movies & TV, top-rated movies & TV — all loaded from TMDB API
📅
Upcoming
Vertical scrollable list of upcoming movies with real-time API data and navigation to detail screen
🔍
Search
Grid layout search with live debounced API queries for both movies and TV shows with toolbar toggle
⬇️
Downloads
Offline title storage using Swift Data with swipe-to-delete, duplicate prevention, and alphabetical sorting
▶️
YouTube Trailers
Each title detail screen fetches and plays the correct YouTube trailer using the YouTube Data API v3
🏗️
MVVM Architecture
Clean separation of UI and business logic using the Model-View-ViewModel design pattern throughout
Section 2 — Requirements
Requirements & Prerequisites
| Requirement | Details | Where to Get It |
| Mac Computer | macOS 14 Sonoma or later recommended. macOS 13 Ventura minimum for Xcode 15+ | Required — iOS development requires a Mac |
| Xcode 16+ | Latest stable version. Must be updated for Swift 6 & iOS 18 features | Mac App Store (free) |
| Apple ID | Required for simulator. Free account works. Paid ($99/yr) required for real device & App Store publishing | appleid.apple.com (free) |
| TMDB API Key | Free account on The Movie Database API. Provides movie & TV show data | themoviedb.org (free) |
| YouTube Data API v3 Key | Free tier via Google Developer Console. 10,000 units/day quota | console.developers.google.com (free) |
| Swift Knowledge | Basic Swift and SwiftUI experience recommended. Section 1 of the Blossom Build course covers the basics | Blossom Build YouTube channel |
| RAM & Storage | Minimum 8GB RAM. 50GB+ free storage for Xcode, simulators, and derived data | Internal requirement |
Section 3 — Installation
Full Installation Guide
iOS development with SwiftUI requires macOS and Xcode. Unlike most programming languages, Swift and iOS development have a tight dependency on Apple’s ecosystem. Below is a complete guide for every scenario.
macOS Setup Primary Platform
1
Check Your macOS Version
Click the Apple menu → About This Mac. For Xcode 16 you need macOS 14 Sonoma or later. For Xcode 15 you need macOS 13 Ventura minimum.
2
Update macOS if Needed
Go to System Settings → General → Software Update. Install any available updates. A fully updated system prevents compatibility issues with Xcode.
3
Install Xcode Command Line Tools (if needed)
Open Terminal and run:
xcode-select –install
This installs the basic developer tools including Git and compilers.
Installing Xcode Free
1
Open the Mac App Store
Search for Xcode. It is developed by Apple and is completely free. Click Get → Install. Note: Xcode is approximately 8–14GB in size. Ensure you have enough disk space and a fast internet connection.
2
Launch Xcode After Installation
On first launch, Xcode will ask to install additional components. Click Install and enter your password. This is required and takes a few minutes.
3
Verify Xcode Version
Go to Xcode menu → About Xcode. You should see version 15 or 16 for this tutorial. Running older versions may cause compatibility issues.
4
Sign In with Apple ID
Go to Xcode → Settings → Accounts. Click the + button and sign in with your Apple ID. This is needed to run apps on the simulator and physical devices.
Installing iOS Simulators
Xcode comes with iOS simulators built in. However, you may need to download specific device simulators for testing.
1
Open Simulator Downloads
Go to Xcode → Settings → Platforms. This shows all available simulator runtimes.
2
Download iOS 18 Simulator
Click the + button next to iOS and download the latest iOS 18 simulator. This tutorial uses the iPhone 16 simulator. The download is approximately 5–8GB.
3
Run on Physical Device (Optional)
Connect your iPhone via USB. In Xcode, select your device from the target dropdown. A paid Apple Developer account ($99/year) is required for App Store deployment, but a free account works for personal device testing.
💡 Running on iPhone 16 Simulator
For this tutorial, Carlos uses the iPhone 16 simulator. In Xcode’s top toolbar, click the device dropdown and select iPhone 16. Press Cmd + R to build and run. The simulator launches automatically.
Swift on Linux & Termux (Android) Advanced
While full iOS development requires a Mac, Swift as a language does run on Linux and Android (via Termux). Note that SwiftUI is not available on these platforms — you would need to use server-side Swift frameworks like Vapor for backend work.
🐧 Linux (Ubuntu)
sudo apt update && sudo apt upgrade
# Download Swift from swift.org
wget https://swift.org/builds/swift-5.10-release/
# Extract and add to PATH
tar xzf swift-5.10*.tar.gz
export PATH=”$PWD/usr/bin:$PATH”
# Verify
swift –version
📱 Termux (Android)
# Install Termux from F-Droid
pkg update && pkg upgrade
pkg install clang
# Swift on Termux (unofficial)
pkg install swift
swift –version
# Run a swift file
swift hello.swift
⚠️ Important Note on Linux & Termux
Swift on Linux and Termux supports the Swift language and standard library but does NOT support SwiftUI, UIKit, or any Apple-specific frameworks. You cannot build or run iOS apps on these platforms. They are suitable for learning Swift syntax, server-side development (Vapor/Hummingbird), and scripting only. For the full iOS app in this tutorial, a Mac running macOS is required.
32-bit vs 64-bit Architecture Notes
| Architecture | macOS / Xcode Support | iOS Simulator | Notes |
| 64-bit (x86_64) |
✅ Intel Macs — Full support |
✅ All simulators supported |
All Intel Macs (pre-2020) use x86_64. Xcode builds 64-bit apps by default. |
| 64-bit (ARM64) |
✅ Apple Silicon (M1/M2/M3/M4) — Best performance |
✅ Native ARM simulators, faster |
Apple Silicon Macs run Xcode natively on ARM64. Significantly faster build times. |
| 32-bit |
❌ Not supported since macOS Catalina |
❌ iOS 11+ dropped 32-bit app support |
Apple dropped 32-bit app support from iOS in 2017 (iOS 11). All modern iOS apps are 64-bit only. |
| Universal Binary |
✅ Fat binaries containing both x86_64 and ARM64 |
✅ Runs natively on both architectures |
Xcode builds Universal Binaries for App Store submissions ensuring compatibility across all Mac hardware. |
✅ Bottom Line for This Tutorial
If you have a Mac made in 2017 or later with macOS 13+ and a copy of Xcode 15 or 16, you are fully equipped. Apple Silicon Macs (M1 and later) will have noticeably faster build times. All iOS devices running iOS 11 or later support 64-bit apps exclusively — 32-bit is completely retired from the iOS ecosystem.
Section 4 — Project Setup
Creating the Xcode Project
1
Open Xcode & Create New Project
Launch Xcode. On the welcome screen, click Create New Project. Select iOS as the platform template.
2
Configure Project Settings
- Product Name: Blossom Movie
- Team: Your Apple ID team (or None)
- Organization Identifier: Your reverse domain (e.g., com.yourbrand)
- Interface: SwiftUI (required)
- Language: Swift
- Testing System: None
- Storage: None (we’ll add Swift Data manually — the template adds unnecessary boilerplate)
3
Save & Create
Choose a save location and click Create. Xcode generates the project with ContentView.swift, which shows the famous “Hello, World!” text. Press Cmd + Ctrl + F for full screen.
📌 Why Not Select Swift Data in the Project Template?
When you select Storage: Swift Data during project creation, Xcode generates boilerplate code including a sample model and container setup that doesn’t match our app structure. It’s much cleaner to add Swift Data manually when we need it. This is a deliberate best practice, not an oversight.
Section 5 — Tab View
Building the Tab View
The foundation of the app is a four-tab bottom toolbar. Each tab represents one of the four main screens: Home, Upcoming, Search, and Downloads. SwiftUI makes this remarkably simple using the TabView component.
ContentView.swift
import SwiftUI
struct ContentView:
View {
var body: some
View {
TabView {
Tab(
“Home”, systemImage:
“house”) {
Text(
“Home”)
}
Tab(
“Upcoming”, systemImage:
“play.circle”) {
Text(
“Upcoming”)
}
Tab(
“Search”, systemImage:
“magnifyingglass”) {
Text(
“Search”)
}
Tab(
“Download”, systemImage:
“arrow.down.2.line”) {
Text(
“Download”)
}
}
}
}
Each Tab takes two parameters: a display string for the tab name and a system image name from Apple’s SF Symbols library. The curly braces define the view content for that tab. SwiftUI automatically creates the bottom toolbar with the icons and labels.
Section 6 — Constants
Constants File & Good Coding Practices
Hard-coded strings scattered throughout your code are a maintenance nightmare. They cause typos, make refactoring painful, and block easy localization. The solution is a centralized constants file.
Constants.swift
import SwiftUI
struct Constants {
// Tab names
static let homeString =
“Home”
static let upcomingString =
“Upcoming”
static let searchString =
“Search”
static let downloadString =
“Download”
// Tab icons (SF Symbols)
static let homeIconString =
“house”
static let upcomingIconString =
“play.circle”
static let searchIconString =
“magnifyingglass”
static let downloadIconString =
“arrow.down.2.line”
// Button labels
static let playString =
“Play”
// List headers
static let trendingMovieString =
“Trending Movies”
static let trendingTVString =
“Trending TV”
static let topRatedMovieString =
“Top Rated Movies”
static let topRatedTVString =
“Top Rated TV”
// Poster path base URL
static let posterURLStart =
“https://image.tmdb.org/t/p/w500”
// Search strings
static let movieSearchString =
“Movie Search”
static let tvSearchString =
“TV Search”
static let moviePlaceholderString =
“Search for a movie”
static let tvPlaceholderString =
“Search for a TV show”
}
The Ghost Button Extension (DRY Principle)
The DRY (Don’t Repeat Yourself) principle is one of the most important rules in software engineering. When you find yourself writing the same code in multiple places, it’s time to refactor. The ghost button style is used multiple times in our app. Rather than copy-pasting modifiers everywhere, we write it once as a Swift extension:
Constants.swift (extension)
extension Text {
/// Apply the ghost button style (border only, no fill)
func ghostButton() -> some
View {
self
.frame(width:
100, height:
50)
.bold()
.foregroundStyle(.buttonText)
.background {
RoundedRectangle(cornerRadius:
20, style: .continuous)
.stroke(.buttonBorder, lineWidth:
1)
}
}
/// Apply the error message style
func errorMessage() -> some
View {
self
.foregroundStyle(.red)
.padding()
.background(.ultraThinMaterial)
.clipShape(
RoundedRectangle(cornerRadius:
10))
}
}
Section 7 — Home View
Building the Home Screen
The home screen is the most feature-rich screen in the app. It contains a hero title image (random on each open), two action buttons, and four horizontal scrolling lists of titles loaded from the TMDB API.
Key SwiftUI Concepts Used
- GeometryReader: Gets the available screen space to size the hero image correctly
- ScrollView: Enables vertical scrolling for content that exceeds screen height
- LazyVStack: Loads child views only when they appear on screen — critical for performance
- AsyncImage: Loads remote images asynchronously on a background thread without blocking the UI
- LinearGradient: Creates a smooth color transition overlay on the hero image
HomeView.swift (simplified)
struct HomeView:
View {
let viewModel =
ViewModel()
@State private var titleDetailPath =
NavigationPath()
var body: some
View {
NavigationStack(path: $titleDetailPath) {
GeometryReader { geo
in
ScrollView {
LazyVStack {
// Hero image with gradient overlay
AsyncImage(
url: URL(string: viewModel.heroTitle.posterPath ??
“”)) { image
in
image.resizable().scaledToFit()
} placeholder: {
ProgressView()
}
.frame(width: geo.size.width, height: geo.size.height *
0.85)
.overlay {
LinearGradient(
stops: [
.init(color: .clear, location:
0.8),
.init(color: .gradient, location:
1.0)
],
startPoint: .top, endPoint: .bottom
)
}
// Play & Download buttons
HStack {
Button { titleDetailPath.append(viewModel.heroTitle) } label: {
Text(Constants.playString).ghostButton()
}
Button {
/* download logic */ } label: {
Text(Constants.downloadString).ghostButton()
}
}
// Horizontal lists populated by TMDB API
HorizontalListView(header: Constants.trendingMovieString,
titles: viewModel.trendingMovies,
onSelect: { title
in titleDetailPath.append(title) })
HorizontalListView(header: Constants.trendingTVString,
titles: viewModel.trendingTV,
onSelect: { title
in titleDetailPath.append(title) })
HorizontalListView(header: Constants.topRatedMovieString,
titles: viewModel.topRatedMovies,
onSelect: { title
in titleDetailPath.append(title) })
HorizontalListView(header: Constants.topRatedTVString,
titles: viewModel.topRatedTV,
onSelect: { title
in titleDetailPath.append(title) })
}
}
}
.navigationDestination(for:
Title.self) { title
in
TitleDetailView(title: title)
}
}
.task {
await viewModel.getTitles() }
}
}
Section 8 — API & Data Modeling
Modeling API JSON Data
Before fetching any data, you must create Swift models that match the structure of the JSON returned by the API. This process is called JSON modeling and is a fundamental skill in any iOS app that communicates with a backend.
The TMDB API returns data in this structure for trending movies:
TMDB API Response (JSON)
{
“page”:
1,
“results”: [
{
“id”:
12345,
“title”:
“Beetlejuice Beetlejuice”,
// movies use “title”
“name”: null,
// TV shows use “name” instead
“overview”:
“After an unexpected…”,
“poster_path”:
“/kKgQzkUCnQmeTPkyIwHly2t6ZFI.jpg”
}
]
}
Notice that movies use title while TV shows use name. This is an important difference that our model must handle. Both are marked as optionals with ? to prevent crashes when either is missing.
Title.swift
import SwiftData
@Model
class Title:
Decodable,
Identifiable,
Hashable {
@Attribute(.unique)
var id: Int?
var title: String?
var name: String?
var overview: String?
var posterPath: String?
// Required for Swift Data + Decodable compatibility
enum CodingKeys:
CodingKey {
case id, title, name, overview, posterPath
}
required init(from decoder:
Decoder)
throws {
let container =
try decoder.container(keyedBy:
CodingKeys.self)
id =
try container.decodeIfPresent(
Int.self, forKey: .id)
title =
try container.decodeIfPresent(
String.self, forKey: .title)
name =
try container.decodeIfPresent(
String.self, forKey: .name)
overview =
try container.decodeIfPresent(
String.self, forKey: .overview)
posterPath =
try container.decodeIfPresent(
String.self, forKey: .posterPath)
}
// TMDB API returns results array
static var previewTitles: [
Title] = [
/* sample data */ ]
}
// Top-level API response container
struct TMDBApiObject:
Decodable {
var results: [
Title] = []
}
⚠️ Most Common Mistake: Misspelling Property Names
Property names in your Swift structs must exactly match the JSON keys (or be handled with CodingKeys). A single misspelling will cause silent failures where your data doesn’t load. Always double-check capitalization and spelling against the actual API response.
Section 9 — API Keys
Getting & Securing API Keys
TMDB (The Movie Database) API Key
2
Request an API Key
Go to your Profile → Settings → API. Apply for an API key (it’s free). Fill out the form — it just asks for basic information about your intended use.
3
Copy Your API Key
The key is a long alphanumeric string. Keep it handy — you’ll paste it into the app’s JSON config file.
YouTube Data API v3 Key
2
Enable YouTube Data API v3
Navigate to Library → search for YouTube Data API v3 → Click Enable.
3
Create Credentials
Click Create Credentials → select Public Data → your API key is generated. Copy it.
Storing API Keys Safely (APIConfig.json)
Hardcoding API keys directly in your Swift source code is a security risk. Instead, store them in a JSON config file that stays out of version control:
APIConfig.json
{
“tmdbBaseURL”:
“https://api.themoviedb.org”,
“tmdbAPIKey”:
“YOUR_TMDB_KEY_HERE”,
“youtubeBaseURL”:
“https://www.youtube.com/embed/”,
“youtubeAPIKey”:
“YOUR_YOUTUBE_KEY_HERE”,
“youtubeSearchURL”:
“https://www.googleapis.com/youtube/v3/search”
}
APIConfig.swift — Singleton Pattern
struct APIConfig:
Decodable {
let tmdbBaseURL: String
let tmdbAPIKey: String
let youtubeBaseURL: String
let youtubeAPIKey: String
let youtubeSearchURL: String
/// Singleton — only one instance ever created
static let shared:
APIConfig? = {
guard let url = Bundle.main.url(forResource:
“APIConfig”, withExtension:
“json”)
else { fatalError(
“APIConfig.json is missing!”) }
do {
let data =
try Data(contentsOf: url)
return try JSONDecoder().decode(
APIConfig.self, from: data)
}
catch {
print(
“Failed to load APIConfig: \(error)”)
return nil
}
}()
}
💡 Singleton Design Pattern
A singleton ensures only one instance of a class or struct is created. Here, APIConfig.shared is that one instance. It loads the JSON file once at app startup and makes the keys available everywhere in the app without reading the file multiple times. This is a classic example of the singleton pattern used for centralized resource management.
Section 10 — Error Handling
Error Handling & Enums
Robust error handling is the difference between an app that crashes and one that gracefully tells the user what went wrong. Swift’s error system uses enum types conforming to the Error protocol.
Errors.swift
enum APIConfigError:
Error,
LocalizedError {
case fileNotFound
case dataLoadingFailed(underlyingError: any
Error)
case decodingFailed(underlyingError: any
Error)
var errorDescription: String? {
switch self {
case .fileNotFound:
return “APIConfig.json file not found in bundle.”
case .dataLoadingFailed(
let error):
return “Data loading failed: \(error.localizedDescription)”
case .decodingFailed(
let error):
return “JSON decoding failed: \(error.localizedDescription)”
}
}
}
enum NetworkError:
Error,
LocalizedError {
case badURLResponse(underlyingError: any
Error)
case missingConfig
case urlBuildFailed
var errorDescription: String? {
switch self {
case .badURLResponse(
let error):
return “Bad response: \(error.localizedDescription)”
case .missingConfig:
return “API configuration is missing.”
case .urlBuildFailed:
return “Failed to build URL.”
}
}
}
Section 11 — Data Fetcher
Data Fetcher & Network Calls
The data fetcher is a dedicated service struct responsible for all network communication. By isolating network logic here, the rest of the app remains clean and testable.
DataFetcher.swift
struct DataFetcher {
private let tmdbBaseURL = APIConfig.shared?.tmdbBaseURL
private let tmdbAPIKey = APIConfig.shared?.tmdbAPIKey
/// Generic fetch + decode function using Swift Generics
func fetchAndDecode<T:
Decodable>(url:
URL, type: T.
Type)
async throws -> T {
let (data, urlResponse) =
try await URLSession.shared.data(from: url)
guard let response = urlResponse
as? HTTPURLResponse,
response.statusCode ==
200
else {
let code = (urlResponse
as? HTTPURLResponse)?.statusCode ?? –
1
throw NetworkError.badURLResponse(underlyingError:
NSError(domain:
“DataFetcher”, code: code,
userInfo: [NSLocalizedDescriptionKey:
“Invalid HTTP response”]))
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// poster_path → posterPath
return try decoder.decode(T.self, from: data)
}
/// Fetch titles (movies or TV) by type (trending, top_rated, upcoming)
func fetchTitles(for media: String, by type: String,
title: String? = nil)
async throws -> [
Title] {
let url =
try buildURL(media: media, type: type, searchPhrase: title)
var titles =
try await fetchAndDecode(url: url, type: TMDBApiObject.self).results
Constants.addPosterPath(to: &titles)
return titles
}
/// Fetch YouTube video ID for a title’s trailer
func fetchVideoID(for title: String)
async throws -> String {
let url =
try buildYouTubeSearchURL(for: title)
let response =
try await fetchAndDecode(url: url, type: YouTubeSearchResponse.self)
return response.items?.first?.id?.videoId ??
“”
}
}
💡 Swift Generics Explained
The <T: Decodable> syntax defines a generic type parameter. This means fetchAndDecode can decode any type that conforms to Decodable — TMDBApiObject, YouTubeSearchResponse, or any future response type. Instead of writing nearly identical parsing code for each API, we write it once and use it everywhere. This is the practical benefit of generics.
Section 12 — MVVM Pattern
View Model & MVVM Architecture
The Model-View-ViewModel (MVVM) pattern is one of the most important architectural patterns in iOS development. It separates your application into three layers:
| Layer | File in This App | Responsibility |
| Model | Title.swift, TMDBApiObject | Defines data structures and conforms to Decodable for JSON parsing |
| View | HomeView, SearchView, etc. | Only handles UI rendering and user interaction. Contains no business logic |
| ViewModel | ViewModel.swift, SearchViewModel.swift | Fetches data, manages state, and prepares data for the view |
ViewModel.swift
import Observation
@Observable
class ViewModel {
// MARK: – Enums
enum FetchStatus {
case notStarted, fetching, success
case failed(underlyingError: any
Error)
}
// MARK: – Published Properties
private(set) var homeStatus:
FetchStatus = .notStarted
private(set) var videoIDStatus:
FetchStatus = .notStarted
var trendingMovies: [
Title] = []
var trendingTV: [
Title] = []
var topRatedMovies: [
Title] = []
var topRatedTV: [
Title] = []
var heroTitle = Title.previewTitles[
0]
var videoID =
“”
private let dataFetcher =
DataFetcher()
// MARK: – Fetch All Home Titles
func getTitles()
async {
homeStatus = .fetching
guard trendingMovies.isEmpty
else {
homeStatus = .success
// Already loaded — skip API call
return
}
do {
// Parallel async calls with async let
async let tMovies = dataFetcher.fetchTitles(for:
“movie”, by:
“trending”)
async let tTV = dataFetcher.fetchTitles(for:
“tv”, by:
“trending”)
async let trMovies = dataFetcher.fetchTitles(for:
“movie”, by:
“top_rated”)
async let trTV = dataFetcher.fetchTitles(for:
“tv”, by:
“top_rated”)
trendingMovies =
try await tMovies
trendingTV =
try await tTV
topRatedMovies =
try await trMovies
topRatedTV =
try await trTV
heroTitle = trendingMovies.randomElement() ?? heroTitle
homeStatus = .success
}
catch {
print(error)
homeStatus = .failed(underlyingError: error)
}
}
}
Async Let & Parallel API Calls
By default, try await runs asynchronous calls sequentially. That means each call must complete before the next one starts. With four API calls, this is 4x slower than it needs to be.
async let starts all tasks simultaneously. All four lists fetch at the same time and the app waits for all of them together before proceeding — significantly faster.
| Approach | Behavior | Speed |
try await (sequential) | Call 1 → wait → Call 2 → wait → Call 3 → wait → Call 4 | Slowest — sum of all call durations |
async let (parallel) | Call 1, 2, 3, 4 all start simultaneously → wait for all to finish | Fastest — duration of the slowest single call |
Section 13 — Navigation
Navigation Stack & Navigation Path
SwiftUI provides two primary ways to navigate between screens. Understanding both is essential for building well-behaved iOS apps.
| Method | Use Case | Pros | Cons |
| NavigationLink |
Simple user-tap navigation. Good for practice apps |
Very easy to implement. Minimal boilerplate |
Performance issues — views can initialize before being tapped. No programmatic control. No async navigation |
| NavigationPath + NavigationStack |
Production apps. Complex navigation including deep links, push notifications, async flows |
Full programmatic control. Works with async. Better performance. Deep linking support |
More boilerplate required. Slightly more complex to set up |
Navigation Path Pattern
// 1. Define navigation path in the parent view
@State private var navigationPath =
NavigationPath()
// 2. Bind it to NavigationStack
NavigationStack(path: $navigationPath) {
// Main content here
// 3. Define where to go when a Title is pushed
.navigationDestination(for:
Title.self) { title
in
TitleDetailView(title: title)
}
}
// 4. Push a screen programmatically (from a button, tap gesture, etc.)
navigationPath.append(selectedTitle)
// Navigate to detail
Section 14 — YouTube Integration
WebKit & YouTube Player
At the time of this writing, SwiftUI does not have a native web view component. We use WebKit — a UIKit framework — wrapped in a UIViewRepresentable to bridge it into SwiftUI.
YouTubePlayer.swift
import SwiftUI
import WebKit
struct YouTubePlayer:
UIViewRepresentable {
let webView =
WKWebView()
let videoID: String
let youtubeBaseURL = APIConfig.shared?.youtubeBaseURL
// Called once — creates the UIKit view
func makeUIView(context: Context) ->
some UIView { webView }
// Called on updates — loads the video
func updateUIView(_ uiView: UIViewType, context: Context) {
guard let baseURLString = youtubeBaseURL,
let baseURL = URL(string: baseURLString)
else {
return }
let fullURL = baseURL.appending(path: videoID)
webView.load(URLRequest(url: fullURL))
}
}
YouTube API JSON Model
YouTubeSearchResponse.swift
// YouTube search returns:
// { “items”: [{ “id”: { “videoId”: “dQw4w9WgXcQ” } }] }
struct YouTubeSearchResponse:
Codable {
let items: [
ItemProperties]?
}
struct ItemProperties:
Codable {
let id:
IDProperties?
}
struct IDProperties:
Codable {
let videoId: String?
}
Section 15 — Search Screen
Search Screen with Grid Layout
The search screen uses a LazyVGrid — a grid that loads items lazily (only when visible) and arranges them in defined columns.
SearchView.swift (grid)
LazyVGrid(columns: [
GridItem(),
GridItem(),
GridItem()
]) {
ForEach(searchViewModel.searchTitles) { title
in
AsyncImage(url: URL(string: title.posterPath ??
“”)) { image
in
image
.resizable()
.scaledToFit()
.clipShape(
RoundedRectangle(cornerRadius:
10))
} placeholder: {
ProgressView()
}
.frame(width:
120, height:
200)
.onTapGesture { navigationPath.append(title) }
}
}
Search Bar & Toolbar
SearchView.swift (toolbar + search)
@State private var searchByMovies =
true
@State private var searchText =
“”
NavigationStack {
ScrollView {
/* grid here */ }
.navigationTitle(searchByMovies ? Constants.movieSearchString : Constants.tvSearchString)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
searchByMovies.toggle()
} label: {
Image(systemName: searchByMovies ? Constants.movieIconString : Constants.tvIconString)
}
}
}
.searchable(text: $searchText,
prompt: searchByMovies ? Constants.moviePlaceholderString
: Constants.tvPlaceholderString)
}
Debouncing & Async Search
Without debouncing, the API gets hit after every single keystroke — extremely wasteful and potentially hitting rate limits fast. Debouncing adds a short delay so the API is only called after the user stops typing.
Debounced Search with Task
// The .task(id:) modifier re-runs the task whenever searchText changes
.task(id: searchText) {
try? await Task.sleep(for: .milliseconds(
500))
// 0.5s delay
guard !Task.isCancelled
else {
return }
// Cancel if text changed again
await searchViewModel.getSearchTitles(
by: searchByMovies ?
“movie” :
“tv”,
or: searchText
)
}
Section 16 — Swift Data
Swift Data – Offline Downloads
Swift Data is Apple’s modern data persistence framework introduced in iOS 17. It replaces Core Data with a much simpler, Swift-native API using macros. In our app, it powers the Downloads tab — saving and retrieving titles for offline viewing.
Setting Up Swift Data
BlossomMovieApp.swift
import SwiftUI
import SwiftData
@main
struct BlossomMovieApp:
App {
var body: some
Scene {
WindowGroup {
ContentView()
}
.modelContainer(for:
Title.self)
// Register Title as a Swift Data model
}
}
Saving a Title
HomeView.swift — Download Button
@Environment(\.modelContext)
var modelContext
Button {
// Save hero title with consistent name placement
modelContext.insert(viewModel.heroTitle)
try? modelContext.save()
} label: {
Text(Constants.downloadString).ghostButton()
}
Saving, Sorting & Deleting Data
DownloadView.swift
import SwiftUI
import SwiftData
struct DownloadView:
View {
// Fetch all saved titles, sorted alphabetically by title
@Query(sort: \
Title.title)
var savedTitles: [
Title]
var body: some
View {
NavigationStack {
if savedTitles.isEmpty {
Text(
“No Downloads”).font(.title3).bold().padding()
}
else {
VerticalListView(titles: savedTitles, canDelete:
true)
}
}
}
}
VerticalListView.swift — Swipe to Delete
@Environment(\.modelContext)
var modelContext
let canDelete: Bool
// Inside the list item…
.swipeActions(edge: .trailing) {
if canDelete {
Button {
modelContext.delete(title)
try? modelContext.save()
} label: {
Image(systemName:
“trash”)
}
.tint(.red)
}
}
Preventing Duplicate Saves
Title.swift — Unique Attribute
@Model
class Title {
@Attribute(.unique)
var id: Int?
// Same ID = no duplicate saved
// …rest of properties
}
Section 17 — Pros & Cons
Advantages of SwiftUI for iOS Development
vs. Disadvantages
✅ Advantages of SwiftUI
- Declarative UI: You describe what the UI should look like and SwiftUI figures out how to render it. This dramatically reduces boilerplate compared to UIKit.
- Live Previews: Xcode’s canvas shows a live preview of your UI as you type code — no need to run the simulator for every change.
- Cross-platform: Write once, deploy to iOS, macOS, watchOS, tvOS, and visionOS with minimal changes. SwiftUI abstracts away platform differences.
- Swift Data integration:
@Query and @Model macros make offline storage incredibly easy — no Core Data XML files or complex context management.
- @Observable macro: Automatic UI updates when data changes. No need for complex
ObservableObject + @Published boilerplate from older SwiftUI versions.
- Concurrency support:
async/await, async let, and Task modifiers make network calls clean, readable, and safe without callback hell.
- SF Symbols: 6,000+ free, high-quality vector icons built into every Apple device. Accessible with a simple string name. Automatically adapts to system font size and weight.
- Composable views: Small views are easy to extract and reuse across screens. The horizontal list view was used 4 times without rewriting code.
- Strong type system: Swift’s type safety catches bugs at compile time rather than at runtime — particularly valuable with enums for fetch states and error handling.
- Modern design patterns: MVVM, generics, protocols, and dependency injection all fit naturally into SwiftUI’s design philosophy.
- Active ecosystem: Massive community, first-party documentation from Apple, and a thriving open-source ecosystem on GitHub and Swift Package Index.
- Animation & transitions: Adding smooth, native-quality animations is effortless with SwiftUI’s built-in transition and animation system.
❌ Disadvantages of SwiftUI
- Mac-only development: iOS development strictly requires a Mac running macOS. There is no official way to develop iOS apps on Windows or Linux (excluding cloud-based solutions).
- Rapidly evolving APIs: SwiftUI changes significantly with each major iOS version. Code written for iOS 15 may behave differently on iOS 17 or 18. Staying current requires continuous learning.
- Less control than UIKit: UIKit provides lower-level access to the rendering engine. For highly customized UIs (certain animations, complex gesture systems), UIKit or hybrid approaches may be needed.
- WebKit gap: As demonstrated in this project, there is still no native SwiftUI web view. Developers must bridge UIKit’s WKWebView using UIViewRepresentable — adding complexity.
- Debugging challenges: SwiftUI’s opaque view hierarchy can make debugging layout issues frustrating. Xcode’s view debugger helps but isn’t always sufficient.
- Preview instability: Xcode’s live preview canvas frequently crashes or shows incorrect state, especially in complex views with network calls or SwiftData.
- Limited backwards compatibility: Certain SwiftUI features (like Swift Data, @Observable) require iOS 17+. Supporting older iOS versions means either missing features or maintaining dual UIKit/SwiftUI code paths.
- Performance in very large lists: LazyVStack and LazyVGrid help, but extremely large data sets can still exhibit performance issues compared to properly optimized UIKit table views.
- Apple ecosystem lock-in: Everything learned for iOS development (SwiftUI, Xcode, Swift Data, UIKit) is entirely Apple-specific. Skills are not directly transferable to Android or web development.
- Cost of entry: A paid Apple Developer account ($99/year) is required for App Store distribution. Publishing to Android’s Play Store costs a one-time $25 by comparison.
- YouTube API quotas: The free YouTube Data API tier provides only 10,000 units per day. Opening enough title detail screens will exhaust the daily quota, causing errors — relevant for any app using the YouTube API in production.
SwiftUI vs UIKit vs React Native vs Flutter
| Feature | SwiftUI | UIKit | React Native | Flutter |
| Language | Swift | Swift/Obj-C | JavaScript/TypeScript | Dart |
| UI Style | Declarative | Imperative | Declarative | Declarative |
| Cross-platform | Apple only | Apple only | iOS + Android | iOS + Android + Web |
| Dev Platform | macOS only | macOS only | macOS / Windows / Linux | macOS / Windows / Linux |
| Performance | 🥇 Native | 🥇 Native | Near-native | Near-native |
| Learning curve | Moderate | Steep | Moderate (JS devs) | Moderate |
| Live previews | 🥇 Yes (Xcode) | Limited | Fast Refresh | Hot Reload |
| Apple API access | 🥇 Full & first | 🥇 Full | Via bridges | Via plugins |
| Community | Large & growing | Large & mature | 🥇 Very large | Large & growing |
FAQ
Frequently Asked Questions
Q1: Do I need a paid Apple Developer account to follow this tutorial?
No. A free Apple ID is sufficient to build and run the app on the iOS Simulator within Xcode. You only need the $99/year paid Apple Developer Program membership if you want to: (1) install the app on a physical iPhone or iPad, (2) submit the app to the App Store, or (3) access certain advanced capabilities like push notifications in production. For learning purposes, the free account and simulator are completely adequate.
Q2: Why is my app crashing on startup after adding the APIConfig.json file?
The most common causes are: (1) The JSON file is not added to the app’s target — select the file in Xcode’s navigator, open the File Inspector on the right, and ensure your app target is checked under Target Membership. (2) A property name in your Swift struct doesn’t match the JSON key — check capitalization and spelling carefully. (3) The file extension might be wrong (.JSON vs .json). Xcode and Swift are case-sensitive. Also check the extension argument in your Bundle.main.url call.
Q3: The YouTube trailer loads but shows the wrong video. Why?
The YouTube API returns search results based on the title name + “trailer” query. If the title has a common name or the API returns a different video first, you’ll get the wrong one. The app uses the first result from the search response, which is the most likely match. For a production app, you could implement additional filtering — for example, checking that the video title contains the word “trailer” or “official” before accepting it.
Q4: Why does the app show a white screen when pressing Download in the Upcoming tab?
This is a known bug caused by using NavigationLink (instead of NavigationPath) for the upcoming and download tabs. NavigationLink doesn’t handle programmatic navigation (like dismissing after an action) as cleanly as NavigationPath. The fix in this tutorial is to add the @Environment(\.dismiss) property to TitleDetailView and call dismiss() after saving. The long-term fix is to replace NavigationLink with NavigationPath in those tabs.
Q5: What is the difference between @Observable and ObservableObject?
@Observable is a newer macro introduced in iOS 17 / Swift 5.9. It automatically makes all stored properties observable without needing @Published on each one. ObservableObject is the older approach requiring @Published on every property that should trigger UI updates, plus @StateObject or @ObservedObject in views. @Observable is significantly cleaner and is the recommended approach for iOS 17 and later. For older iOS versions, stick with ObservableObject.
Q6: How do I prevent the API from being called every time I switch tabs?
The solution in this tutorial is to check if the array is already populated before making a network request. In the ViewModel’s getTitles() function, we check guard trendingMovies.isEmpty else { homeStatus = .success; return }. This ensures the API is only called when the data array is empty — i.e., on the very first load. Subsequent tab switches just display the already-loaded data without making new API calls.
Q7: Can I use this app with any other movie API besides TMDB?
Yes, with modifications. The Title model and DataFetcher would need to be updated to match the new API’s JSON structure. The key files to modify are Title.swift (model), DataFetcher.swift (URL building and decoding), and APIConfig.json (base URL and key). OMDb API and TVmaze are free alternatives worth exploring. TMDB is recommended for this project as it has both movies and TV shows with consistent metadata and free, generous API limits.
Q8: What is the @Attribute(.unique) macro and why is it important?
The @Attribute(.unique) macro in Swift Data tells the storage engine that no two saved objects can have the same value for that property — in this case, the title’s id. When a user presses download multiple times on the same title, only the first save will succeed; subsequent attempts are silently ignored. Without this, your Downloads tab would show duplicate entries for the same movie or TV show.
Q9: Is it possible to run this SwiftUI project on Windows or a non-Mac computer?
Not with the full SwiftUI/iOS features. There are some unofficial workarounds: (1) Use a cloud Mac service like MacStadium or MacInCloud to rent a virtual Mac. (2) Use GitHub Codespaces or similar CI for building. (3) Hackintosh (running macOS on non-Apple hardware) — works but violates Apple’s EULA and is unsupported. For legitimate, Apple-compliant iOS development, a Mac is required. An M1 Mac mini is the most affordable entry point at around $600 new (or much less used).
Q10: What is the inout keyword and when should I use it?
In Swift, function parameters are constants by default — you can’t modify them inside the function. The inout keyword lets you pass a variable by reference, meaning modifications inside the function affect the original variable outside of it. In this project, it’s used in Constants.addPosterPath(to: &titles) — the function modifies the titles array directly without needing to return a new copy. Use inout when you need to modify the caller’s variable and want to avoid creating an unnecessary copy of a large data structure.
Conclusion
Conclusion
🎉 You Built a Real iOS App — Congratulations!
From a blank Xcode project to a fully functioning iOS movie app with live API data, YouTube trailers, and offline storage — you’ve come a long way. Every concept in this guide is directly applicable to professional iOS development.
Over the course of this tutorial, you have built a complete, feature-rich iOS application from scratch. Here’s a quick recap of every major concept you’ve covered:
- ✅ SwiftUI Fundamentals: TabView, ScrollView, LazyVStack, LazyVGrid, AsyncImage, GeometryReader, HStack, VStack
- ✅ Project Architecture: MVVM pattern with a clean separation of Model, View, and ViewModel
- ✅ API Integration: TMDB API for movie data, YouTube Data API v3 for trailer videos
- ✅ Networking: async/await, async let for parallel calls, URLSession, JSONDecoder, Generics
- ✅ Security: API key management using a JSON config file and the Singleton design pattern
- ✅ Error Handling: Custom enums conforming to Error and LocalizedError, do-catch blocks, graceful UI states
- ✅ Navigation: NavigationStack with NavigationPath (programmatic), NavigationLink (simple)
- ✅ Swift Data: @Model, @Query, modelContainer, saving, deleting, sorting, preventing duplicates
- ✅ UIKit Bridge: UIViewRepresentable for WebKit YouTube player integration
- ✅ Best Practices: DRY principle, constants file, extensions, private access control, helper functions
- ✅ Debugging: Simulated API failures, fixing white screen bugs, navigation path vs navigation link
The skills you’ve practiced here — API integration, data modeling, MVVM architecture, async networking, and offline storage — are exactly what iOS employers and clients look for in a developer. This project is an excellent addition to your portfolio.
A special thanks to Carlos from Blossom Build for this excellent tutorial, and to Omer whose original UIKit version inspired this SwiftUI rewrite. Keep building, keep learning, and happy coding! 🚀
📚 Further Learning Resources
0 Comments