If you are an iOS Developer looking to take your skills to the next level, there is no better project for your portfolio than building real-world applications. Today we will dive into one of the most complete and rewarding challenges: creating a WhatsApp Clone in SwiftUI.
Swift programming has evolved drastically in recent years, and with the maturity of SwiftUI, we can now build fluid, reactive, and cross-platform user interfaces with a fraction of the code we used to need in UIKit. In this massive step-by-step tutorial, you will learn how to use Swift and the Xcode development environment to build a functional messaging application that not only runs on the iPhone, but also shares code to run natively on iPadOS, macOS, and watchOS.
1. Prerequisites for the iOS Developer
Before writing our first line of code in Swift, make sure you have:
- Xcode 15 or higher (required for the latest SwiftUI features).
- Intermediate knowledge of Swift programming.
- Familiarity with SwiftUI basics (VStack, HStack, List, NavigationStack).
- A spirit of continuous learning, the most important quality of any iOS Developer.
2. Step 1: Cross-Platform Project Setup in Xcode
The true power of SwiftUI lies in its “Learn once, apply anywhere” capability. Let’s set up our project to support the entire Apple ecosystem.
- Open Xcode and select Create a new Xcode project.
- In the template selection tab, go to the Multiplatform section and select App. This will set up our Swift project to share the codebase between iOS and macOS.
- Name your project:
WhatsAppClone. - Make sure Interface is set to SwiftUI and Language to Swift.
- For watchOS, we will go to File > New > Target, select the watchOS tab, and add an App Target to our existing project.
Congratulations! You now have the foundation for your WhatsApp Clone in SwiftUI.
3. Step 2: Data Modeling and Business Logic (Swift Programming)
A good iOS Developer knows that the interface is just the tip of the iceberg. We need a solid database. We will create data models using the modern features of Swift programming.
Create a new file named Models.swift.
import Foundation
// User Model
struct User: Identifiable, Hashable {
let id: UUID
let name: String
let profileImageName: String
let status: String
}
// Message Model
struct Message: Identifiable, Hashable {
let id = UUID()
let text: String
let timestamp: Date
let isCurrentUser: Bool
}
// Chat Model
struct Chat: Identifiable, Hashable {
let id = UUID()
let participant: User
var messages: [Message]
var unreadCount: Int
var lastMessage: Message? {
messages.last
}
}
These models are lightweight and conform to the Identifiable protocol, making them perfect for iterating over in SwiftUI lists.
Next, we will create our ViewModel. In the MVVM paradigm, this file will handle our app’s state. Create ChatViewModel.swift:
import Foundation
import Combine
class ChatViewModel: ObservableObject {
@Published var chats: [Chat] = []
init() {
loadMockData()
}
private func loadMockData() {
// Mock data for our WhatsApp Clone in SwiftUI
let user1 = User(id: UUID(), name: "Elena Gomez", profileImageName: "person.circle.fill", status: "Available")
let user2 = User(id: UUID(), name: "Carlos Dev", profileImageName: "person.circle", status: "At work")
let msg1 = Message(text: "Hi! Did you see the new Xcode update?", timestamp: Date().addingTimeInterval(-3600), isCurrentUser: false)
let msg2 = Message(text: "Yes, SwiftUI is amazing this year.", timestamp: Date().addingTimeInterval(-1800), isCurrentUser: true)
let chat1 = Chat(participant: user1, messages: [msg1, msg2], unreadCount: 0)
let chat2 = Chat(participant: user2, messages: [Message(text: "Are we uploading the app to TestFlight today?", timestamp: Date(), isCurrentUser: false)], unreadCount: 1)
self.chats = [chat1, chat2]
}
func sendMessage(_ text: String, to chatID: UUID) {
if let index = chats.firstIndex(where: { $0.id == chatID }) {
let newMessage = Message(text: text, timestamp: Date(), isCurrentUser: true)
chats[index].messages.append(newMessage)
}
}
}
4. Step 3: Building the iOS Interface with SwiftUI
Now comes the visual part. In our WhatsApp Clone in SwiftUI, we’ll start by building the main chat list view, the screen you see right when you open the app.
The Chat Row View (ChatRowView)
This view will represent each cell in our chat list. Create a file ChatRowView.swift:
import SwiftUI
struct ChatRowView: View {
let chat: Chat
var body: some View {
HStack(spacing: 15) {
Image(systemName: chat.participant.profileImageName)
.resizable()
.scaledToFill()
.frame(width: 50, height: 50)
.foregroundColor(.gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(chat.participant.name)
.font(.headline)
.fontWeight(.semibold)
Spacer()
if let lastMsg = chat.lastMessage {
Text(lastMsg.timestamp, style: .time)
.font(.caption)
.foregroundColor(.gray)
}
}
HStack {
if let lastMsg = chat.lastMessage {
Text(lastMsg.text)
.font(.subheadline)
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption2)
.padding(6)
.foregroundColor(.white)
.background(Color.blue)
.clipShape(Circle())
}
}
}
}
.padding(.vertical, 4)
}
}
The Main Chat List (ChatListView)
Taking advantage of SwiftUI‘s NavigationStack (or NavigationView in older versions, though every good iOS Developer should stay updated), we’ll create the main structure of the iOS app.
import SwiftUI
struct ChatListView: View {
@StateObject private var viewModel = ChatViewModel()
var body: some View {
NavigationStack {
List(viewModel.chats) { chat in
NavigationLink(value: chat) {
ChatRowView(chat: chat)
}
}
.listStyle(PlainListStyle())
.navigationTitle("Chats")
.navigationDestination(for: Chat.self) { chat in
ChatDetailView(viewModel: viewModel, chat: chat)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { /* New conversation */ }) {
Image(systemName: "square.and.pencil")
}
}
}
}
}
}
The Conversation View (ChatDetailView)
For our WhatsApp Clone in SwiftUI to feel real, we need an immersive messaging screen. Using ScrollViewReader is crucial here to automatically scroll to the last sent message, an essential trick of Swift programming applied to UIs.
import SwiftUI
struct ChatDetailView: View {
@ObservedObject var viewModel: ChatViewModel
var chat: Chat
@State private var messageText = ""
// Get the updated chat from the ViewModel
var currentChat: Chat? {
viewModel.chats.first(where: { $0.id == chat.id })
}
var body: some View {
VStack {
ScrollView {
ScrollViewReader { proxy in
VStack(spacing: 15) {
ForEach(currentChat?.messages ?? []) { message in
MessageBubbleView(message: message)
.id(message.id)
}
}
.padding()
.onChange(of: currentChat?.messages.count) { _ in
// Auto-scroll to the last message
if let lastID = currentChat?.messages.last?.id {
withAnimation {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
}
}
// Text Input Area
HStack {
Button(action: {}) {
Image(systemName: "plus")
.font(.title2)
.foregroundColor(.blue)
}
TextField("Type a message...", text: $messageText)
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(20)
Button(action: sendMessage) {
Image(systemName: messageText.isEmpty ? "mic" : "paperplane.fill")
.font(.title2)
.foregroundColor(messageText.isEmpty ? .gray : .blue)
}
}
.padding()
.background(Color(.systemBackground))
}
.navigationTitle(chat.participant.name)
.navigationBarTitleDisplayMode(.inline)
}
private func sendMessage() {
guard !messageText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
viewModel.sendMessage(messageText, to: chat.id)
messageText = ""
}
}
// Subview for chat bubbles
struct MessageBubbleView: View {
let message: Message
var body: some View {
HStack {
if message.isCurrentUser { Spacer() }
Text(message.text)
.padding(12)
.background(message.isCurrentUser ? Color.blue : Color(.systemGray5))
.foregroundColor(message.isCurrentUser ? .white : .primary)
.cornerRadius(16)
.frame(maxWidth: 250, alignment: message.isCurrentUser ? .trailing : .leading)
if !message.isCurrentUser { Spacer() }
}
}
}
5. Step 4: Taking our Clone to macOS in Xcode
Since you created a Multiplatform project in Xcode, the previous SwiftUI code will already compile on macOS. However, a professional iOS Developer knows that the user experience (UX) on desktop is different from mobile.
On iOS we use NavigationStack because navigation is hierarchical (forward and back). On macOS and iPadOS, it is ideal to use NavigationSplitView to show the chat list in a sidebar and the active conversation in the main panel.
We will modify our entry point (App.swift or a main ContentView) to use conditional compilation or take advantage of SwiftUI‘s adaptability:
import SwiftUI
@main
struct WhatsAppCloneApp: App {
@StateObject private var viewModel = ChatViewModel()
var body: some Scene {
WindowGroup {
#if os(macOS) || targetEnvironment(macCatalyst)
MacNavigationSplitView(viewModel: viewModel)
#else
ChatListView() // The iOS view we already created
#endif
}
}
}
// Specific view for macOS/iPadOS
struct MacNavigationSplitView: View {
@ObservedObject var viewModel: ChatViewModel
@State private var selectedChat: Chat?
var body: some View {
NavigationSplitView {
List(viewModel.chats, selection: $selectedChat) { chat in
NavigationLink(value: chat) {
ChatRowView(chat: chat)
}
}
.navigationTitle("Chats")
.frame(minWidth: 250)
} detail: {
if let chat = selectedChat {
ChatDetailView(viewModel: viewModel, chat: chat)
} else {
Text("Select a chat to start messaging")
.foregroundColor(.gray)
}
}
}
}
With just a few lines of Swift programming, we have transformed our mobile interface into a desktop app with adaptive panels. That is the magic of Xcode and the Apple ecosystem!
6. Step 5: Expanding to watchOS
The Apple Watch requires an even more simplified design. The screen is small, so elements like the search bar or complex text inputs must be handled with care. As an iOS Developer, you must design for the user’s context.
In your watchOS Target in Xcode, we’ll create a specialized interface. We will reuse the same model (Models.swift) and logic (ChatViewModel.swift) files, but we will create separate views for the watch.
import SwiftUI
// WatchContentView.swift (watchOS Target)
struct WatchContentView: View {
@StateObject private var viewModel = ChatViewModel()
var body: some View {
NavigationStack {
List(viewModel.chats) { chat in
NavigationLink(value: chat) {
VStack(alignment: .leading) {
Text(chat.participant.name)
.font(.headline)
Text(chat.lastMessage?.text ?? "")
.font(.caption)
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
.navigationTitle("Messages")
.navigationDestination(for: Chat.self) { chat in
WatchChatDetailView(viewModel: viewModel, chat: chat)
}
}
}
}
struct WatchChatDetailView: View {
@ObservedObject var viewModel: ChatViewModel
var chat: Chat
var currentChat: Chat? {
viewModel.chats.first(where: { $0.id == chat.id })
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(currentChat?.messages ?? []) { message in
Text(message.text)
.padding(8)
.background(message.isCurrentUser ? Color.blue : Color.gray.opacity(0.3))
.cornerRadius(10)
.frame(maxWidth: .infinity, alignment: message.isCurrentUser ? .trailing : .leading)
}
}
.padding(.horizontal)
}
.navigationTitle(chat.participant.name)
}
}
Notice how we removed the profile picture in the chat list to save vertical space, a fundamental design decision when programming for the wrist.
7. Advanced Swift Programming Techniques to Optimize Your Clone
If you want this WhatsApp Clone in SwiftUI to stand out in your portfolio against other developers, you must implement good practices that recruiters look for in a Senior iOS Developer:
1. Dependency Injection
Instead of initializing ChatViewModel directly inside views, inject it using the environment (@EnvironmentObject). This makes your Swift code more modular and much easier to test (Unit Testing).
2. Using Swift Concurrency (async/await)
If you were to connect this app to a real backend (for example, Firebase or Supabase), you should replace the mock data loading (loadMockData()) with asynchronous network calls using async/await. Modern Swift programming abandons traditional closures in favor of cleaner concurrent code that is safe against race conditions.
3. Accessibility
An application built with SwiftUI is only as good as its accessibility. Add modifiers like .accessibilityLabel("Message from \(chat.participant.name)") to your cells. In Xcode, you can use the Accessibility Inspector to verify that VoiceOver reads the interface correctly.
4. Local Persistence with SwiftData or CoreData
A real WhatsApp clone saves messages locally so you can read them offline. Implementing SwiftData (the native persistence framework recently introduced by Apple) in your model will turn your app from a simple prototype into a production-grade application.
Conclusion
Creating a WhatsApp Clone in SwiftUI is a masterclass in architecture, UI design, and Swift programming. Throughout this tutorial, we have seen how to configure Xcode for cross-platform development, how to structure data using MVVM, and how to adapt a single business logic to three completely different platforms: iPhone (iOS), Mac (macOS), and Apple Watch (watchOS).
Being a great iOS Developer isn’t just about knowing Swift syntax, but about understanding how to solve complex problems using the tools Apple provides. SwiftUI has shown us that writing fluid native interfaces is today more intuitive and faster than ever.