sagar-wants-to-make-ios-brainrot-apps
Swift & iOS Development Skill
Expert-level Swift and iOS development assistant. Covers SwiftUI, UIKit interop, architecture, design systems, networking, state management, testing, and production deployment patterns.
When to Use
- Building or modifying iOS/iPadOS/macOS apps
- Writing Swift code (views, models, networking, persistence)
- Designing UI with SwiftUI or UIKit
- Setting up Xcode projects, SPM packages, or CocoaPods
- Debugging iOS-specific issues (layout, navigation, lifecycle)
- App Store submission, provisioning, CI/CD for Apple platforms
Project Structure (Recommended)
MyApp/
├── MyApp.xcodeproj/ # Or .xcworkspace if using CocoaPods
├── MyApp/
│ ├── App/
│ │ ├── MyAppApp.swift # @main entry point
│ │ ├── AppDelegate.swift # Only if UIKit lifecycle needed
│ │ └── Info.plist
│ ├── Features/ # Feature modules
│ │ ├── Chat/
│ │ │ ├── Views/
│ │ │ │ ├── ChatView.swift
│ │ │ │ └── MessageBubble.swift
│ │ │ ├── ViewModels/
│ │ │ │ └── ChatViewModel.swift
│ │ │ └── Models/
│ │ │ └── Message.swift
│ │ ├── Settings/
│ │ │ ├── Views/
│ │ │ └── ViewModels/
│ │ └── Auth/
│ │ ├── Views/
│ │ └── Services/
│ ├── Core/
│ │ ├── Design/ # Design system
│ │ │ ├── Theme.swift
│ │ │ ├── Colors.swift
│ │ │ ├── Typography.swift
│ │ │ └── Components/ # Reusable UI components
│ │ │ ├── PrimaryButton.swift
│ │ │ ├── CardView.swift
│ │ │ └── LoadingView.swift
│ │ ├── Networking/
│ │ │ ├── APIClient.swift
│ │ │ ├── Endpoint.swift
│ │ │ └── NetworkError.swift
│ │ ├── Storage/
│ │ │ ├── UserDefaults+Extensions.swift
│ │ │ └── KeychainService.swift
│ │ ├── Extensions/
│ │ │ ├── View+Extensions.swift
│ │ │ ├── Color+Hex.swift
│ │ │ └── Date+Formatting.swift
│ │ └── Utilities/
│ │ ├── Logger.swift
│ │ └── Constants.swift
│ ├── Resources/
│ │ ├── Assets.xcassets/
│ │ ├── Localizable.xcstrings # String catalogs (Xcode 15+)
│ │ └── Fonts/ # Custom fonts (.ttf/.otf)
│ └── Preview Content/
│ └── Preview Assets.xcassets/
├── MyAppTests/
├── MyAppUITests/
└── Package.swift # If using SPM for modularization
Feature-Based Organization
Group by feature, not by type. Each feature folder contains its own Views, ViewModels, and Models. Shared code lives in Core/.
Architecture: MVVM + Services
The Pattern
View ←→ ViewModel ←→ Service/Repository ←→ API/Database
View — Pure SwiftUI. No business logic. Observes ViewModel.
ViewModel — @Observable class (iOS 17+) or ObservableObject. Holds state, handles user actions.
Service — Stateless. Networking, persistence, business rules.
Model — Plain structs. Codable, Identifiable, Hashable.
ViewModel Pattern (iOS 17+ with @Observable)
import SwiftUI
@Observable
final class ChatViewModel {
var messages: [Message] = []
var inputText = ""
var isLoading = false
var error: AppError?
private let chatService: ChatService
init(chatService: ChatService = .shared) {
self.chatService = chatService
}
func send() async {
guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let text = inputText
inputText = ""
isLoading = true
defer { isLoading = false }
let userMessage = Message(role: .user, content: text)
messages.append(userMessage)
do {
let response = try await chatService.sendMessage(text, history: messages)
messages.append(response)
} catch {
self.error = AppError(error)
messages.removeLast() // Remove optimistic user message on failure
}
}
}
ViewModel Pattern (iOS 16 and earlier with ObservableObject)
final class ChatViewModel: ObservableObject {
@Published var messages: [Message] = []
@Published var inputText = ""
@Published var isLoading = false
// Same logic, use @StateObject in view
}
View Consuming ViewModel
struct ChatView: View {
@State private var viewModel = ChatViewModel()
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom)
}
}
}
InputBar(text: $viewModel.inputText, isLoading: viewModel.isLoading) {
Task { await viewModel.send() }
}
}
}
}
Design System
Since you can't use Tailwind in native iOS, build a design system with Swift extensions and view modifiers.
Colors
import SwiftUI
extension Color {
// MARK: - Brand
static let brand = Color("Brand", bundle: .main) // Define in Assets.xcassets
static let brandDark = Color("BrandDark", bundle: .main)
// MARK: - Semantic
static let surfacePrimary = Color(.systemBackground)
static let surfaceSecondary = Color(.secondarySystemBackground)
static let surfaceTertiary = Color(.tertiarySystemBackground)
static let textPrimary = Color(.label)
static let textSecondary = Color(.secondaryLabel)
static let textMuted = Color(.tertiaryLabel)
static let border = Color(.separator)
static let borderLight = Color(.opaqueSeparator)
// MARK: - Status
static let success = Color.green
static let warning = Color.orange
static let destructive = Color.red
// MARK: - Hex initializer
init(hex: String) {
let hex = hex.trimmingCharacters(in: .alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default: (a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
Typography
import SwiftUI
enum AppFont {
// System Dynamic Type (recommended — respects accessibility)
static let largeTitle = Font.largeTitle.weight(.bold)
static let title = Font.title2.weight(.semibold)
static let headline = Font.headline
static let body = Font.body
static let callout = Font.callout
static let caption = Font.caption
static let captionSecondary = Font.caption2
// Custom font (register in Info.plist under "Fonts provided by application")
static func custom(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .rounded)
}
}
// Usage: Text("Hello").font(AppFont.title)
Spacing & Layout
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
static let xxxl: CGFloat = 48
}
enum CornerRadius {
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let full: CGFloat = 9999 // Capsule
}
Reusable Components
// MARK: - Primary Button
struct PrimaryButton: View {
let title: String
let isLoading: Bool
let action: () -> Void
init(_ title: String, isLoading: Bool = false, action: @escaping () -> Void) {
self.title = title
self.isLoading = isLoading
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text(title)
.font(.body.weight(.semibold))
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.brand)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
}
.disabled(isLoading)
}
}
// MARK: - Card
struct CardView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding(Spacing.lg)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
}
}
// MARK: - Async Image with Placeholder
struct RemoteImage: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
Image(systemName: "photo")
.foregroundStyle(.tertiary)
default:
ProgressView()
}
}
}
}
View Modifiers (Tailwind-like convenience)
extension View {
func cardStyle() -> some View {
self
.padding(Spacing.lg)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
}
func inputStyle() -> some View {
self
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.background(Color.surfaceTertiary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CornerRadius.md)
.stroke(Color.border, lineWidth: 0.5)
)
}
func shimmer(_ isActive: Bool = true) -> some View {
self.redacted(reason: isActive ? .placeholder : [])
.shimmering(active: isActive)
}
}
// Custom shimmer modifier
struct ShimmerModifier: ViewModifier {
let active: Bool
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
if active {
content
.overlay(
LinearGradient(
colors: [.clear, .white.opacity(0.3), .clear],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.mask(content)
)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 200
}
}
} else {
content
}
}
}
extension View {
func shimmering(active: Bool = true) -> some View {
modifier(ShimmerModifier(active: active))
}
}
Navigation
NavigationStack (iOS 16+, recommended)
@Observable
final class Router {
var path = NavigationPath()
func push<D: Hashable>(_ destination: D) {
path.append(destination)
}
func pop() {
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
// App entry
struct MyAppApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .chat(let id): ChatView(conversationId: id)
case .settings: SettingsView()
case .profile(let userId): ProfileView(userId: userId)
}
}
}
.environment(router)
}
}
}
enum Route: Hashable {
case chat(id: String)
case settings
case profile(userId: String)
}
Tab-Based Navigation
struct ContentView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house.fill", value: 0) {
HomeView()
}
Tab("Chat", systemImage: "bubble.left.and.bubble.right.fill", value: 1) {
ChatView()
}
Tab("Settings", systemImage: "gearshape.fill", value: 2) {
SettingsView()
}
}
}
}
Sheet / Modal Presentation
struct ParentView: View {
@State private var showLogin = false
@State private var selectedItem: Item?
var body: some View {
Button("Login") { showLogin = true }
.sheet(isPresented: $showLogin) {
LoginSheet()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.sheet(item: $selectedItem) { item in
ItemDetailSheet(item: item)
}
}
}
Networking
Modern Async/Await API Client
import Foundation
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let baseURL: URL
private let decoder: JSONDecoder
init(
baseURL: URL = URL(string: "https://api.example.com")!,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
// MARK: - Generic Request
func request<T: Decodable>(
_ endpoint: Endpoint,
as type: T.Type = T.self
) async throws -> T {
var request = URLRequest(url: baseURL.appending(path: endpoint.path))
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Auth
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Body
if let body = endpoint.body {
request.httpBody = try JSONEncoder().encode(body)
}
// Query parameters
if let params = endpoint.queryItems {
var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
components.queryItems = params
request.url = components.url
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.httpError(statusCode: httpResponse.statusCode, data: data)
}
return try decoder.decode(T.self, from: data)
}
// MARK: - Streaming (SSE)
func stream(_ endpoint: Endpoint) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
var request = URLRequest(url: baseURL.appending(path: endpoint.path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = endpoint.body {
request.httpBody = try? JSONEncoder().encode(body)
}
do {
let (bytes, response) = try await session.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
continuation.finish(throwing: NetworkError.invalidResponse)
return
}
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let data = String(line.dropFirst(6))
if data == "[DONE]" { break }
continuation.yield(data)
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
// MARK: - Endpoint
struct Endpoint {
let path: String
let method: HTTPMethod
let body: (any Encodable)?
let queryItems: [URLQueryItem]?
init(path: String, method: HTTPMethod = .get, body: (any Encodable)? = nil, queryItems: [URLQueryItem]? = nil) {
self.path = path
self.method = method
self.body = body
self.queryItems = queryItems
}
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
// MARK: - Typed Endpoints
extension Endpoint {
static func sendMessage(_ text: String, conversationId: String?) -> Endpoint {
Endpoint(path: "/chat/stream", method: .post, body: ChatRequest(
message: text,
conversationId: conversationId
))
}
static func getConversations(limit: Int = 50) -> Endpoint {
Endpoint(path: "/conversations", queryItems: [
URLQueryItem(name: "limit", value: "\(limit)")
])
}
static var me: Endpoint {
Endpoint(path: "/users/me")
}
}
// MARK: - Errors
enum NetworkError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int, data: Data)
case decodingError(Error)
case noConnection
var errorDescription: String? {
switch self {
case .invalidResponse: "Invalid server response"
case .httpError(let code, _): "Server error (\(code))"
case .decodingError: "Failed to parse response"
case .noConnection: "No internet connection"
}
}
}
State Management
@Observable (iOS 17+) — Preferred
@Observable
final class AppState {
var user: User?
var isAuthenticated: Bool { user != nil }
var conversations: [Conversation] = []
var selectedConversation: Conversation?
}
// Inject via environment
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
// Consume
struct HomeView: View {
@Environment(AppState.self) private var appState
var body: some View {
if let user = appState.user {
Text("Hello, \(user.name)")
}
}
}
Combine (iOS 13+, legacy but common)
final class AuthManager: ObservableObject {
static let shared = AuthManager()
@Published var currentUser: User?
@Published var accessToken: String?
var isAuthenticated: Bool { currentUser != nil }
func signIn(email: String, password: String) async throws {
// Supabase auth, Firebase, custom backend, etc.
}
func signOut() {
currentUser = nil
accessToken = nil
}
}
Data Persistence
SwiftData (iOS 17+, recommended)
import SwiftData
@Model
final class Conversation {
var id: String
var title: String
var createdAt: Date
var updatedAt: Date
@Relationship(deleteRule: .cascade) var messages: [ChatMessage]
init(id: String = UUID().uuidString, title: String) {
self.id = id
self.title = title
self.createdAt = .now
self.updatedAt = .now
self.messages = []
}
}
@Model
final class ChatMessage {
var id: String
var role: String // "user" | "assistant"
var content: String
var timestamp: Date
var conversation: Conversation?
init(role: String, content: String) {
self.id = UUID().uuidString
self.role = role
self.content = content
self.timestamp = .now
}
}
// Setup in App
@main
struct MyAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Conversation.self, ChatMessage.self])
}
}
// Query in View
struct ConversationListView: View {
@Query(sort: \Conversation.updatedAt, order: .reverse) var conversations: [Conversation]
@Environment(\.modelContext) private var context
var body: some View {
List(conversations) { conversation in
Text(conversation.title)
}
.onDelete { indexSet in
for index in indexSet {
context.delete(conversations[index])
}
}
}
}
UserDefaults (Simple preferences)
extension UserDefaults {
var hasCompletedOnboarding: Bool {
get { bool(forKey: "hasCompletedOnboarding") }
set { set(newValue, forKey: "hasCompletedOnboarding") }
}
var selectedModel: String {
get { string(forKey: "selectedModel") ?? "gpt-4" }
set { set(newValue, forKey: "selectedModel") }
}
}
// SwiftUI binding via @AppStorage
struct SettingsView: View {
@AppStorage("selectedModel") private var selectedModel = "gpt-4"
@AppStorage("hapticFeedback") private var hapticFeedback = true
var body: some View {
Form {
Picker("Model", selection: $selectedModel) {
Text("GPT-4").tag("gpt-4")
Text("Claude").tag("claude-sonnet")
}
Toggle("Haptic Feedback", isOn: $hapticFeedback)
}
}
}
Keychain (Secrets — tokens, API keys)
import Security
enum KeychainService {
static func save(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func load(for key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound { return nil }
throw KeychainError.loadFailed(status)
}
return result as? Data
}
static func delete(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
}
}
Authentication (Supabase Example)
import Supabase
final class AuthManager: ObservableObject {
static let shared = AuthManager()
let client = SupabaseClient(
supabaseURL: URL(string: ProcessInfo.processInfo.environment["SUPABASE_URL"] ?? "")!,
supabaseKey: ProcessInfo.processInfo.environment["SUPABASE_ANON_KEY"] ?? ""
)
@Published var session: Session?
var isAuthenticated: Bool { session != nil }
init() {
Task { await observeAuthChanges() }
}
func observeAuthChanges() async {
for await (event, session) in client.auth.authStateChanges {
await MainActor.run {
self.session = session
}
}
}
func signInWithGoogle() async throws {
try await client.auth.signInWithOAuth(.google) { url in
// Open URL in ASWebAuthenticationSession
await UIApplication.shared.open(url)
}
}
func signInWithEmail(_ email: String, password: String) async throws {
try await client.auth.signIn(email: email, password: password)
}
func signUp(email: String, password: String) async throws {
try await client.auth.signUp(email: email, password: password)
}
func signOut() async throws {
try await client.auth.signOut()
}
}
Common UI Patterns
Chat Interface
struct ChatView: View {
@State private var viewModel = ChatViewModel()
@FocusState private var isInputFocused: Bool
var body: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: Spacing.sm) {
ForEach(viewModel.messages) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
if viewModel.isLoading {
TypingIndicator()
.id("typing")
}
}
.padding()
}
.scrollDismissesKeyboard(.interactively)
.onChange(of: viewModel.messages.count) {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(viewModel.isLoading ? "typing" : viewModel.messages.last?.id, anchor: .bottom)
}
}
}
Divider()
// Input
HStack(alignment: .bottom, spacing: Spacing.sm) {
TextField("Message", text: $viewModel.inputText, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.plain)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(Color.surfaceTertiary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
.focused($isInputFocused)
Button {
Task { await viewModel.send() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(viewModel.inputText.isEmpty ? .tertiary : Color.brand)
}
.disabled(viewModel.inputText.isEmpty || viewModel.isLoading)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
}
struct MessageBubble: View {
let message: Message
private var isUser: Bool { message.role == .user }
var body: some View {
HStack {
if isUser { Spacer(minLength: 60) }
Text(message.content)
.font(AppFont.body)
.foregroundStyle(isUser ? .white : Color.textPrimary)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.background(isUser ? Color.brand : Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
if !isUser { Spacer(minLength: 60) }
}
}
}
struct TypingIndicator: View {
@State private var phase = 0.0
var body: some View {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.textMuted)
.frame(width: 8, height: 8)
.offset(y: sin(phase + Double(i) * .pi / 3) * 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.onAppear {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: false)) {
phase = .pi * 2
}
}
}
}
Pull to Refresh + Infinite Scroll
struct ConversationListView: View {
@State private var viewModel = ConversationListViewModel()
var body: some View {
List {
ForEach(viewModel.conversations) { conversation in
ConversationRow(conversation: conversation)
.task {
if conversation == viewModel.conversations.last {
await viewModel.loadMore()
}
}
}
if viewModel.isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
}
}
.refreshable {
await viewModel.refresh()
}
}
}
Empty State
struct EmptyState: View {
let icon: String
let title: String
let message: String
var action: (() -> Void)?
var actionTitle: String?
var body: some View {
ContentUnavailableView {
Label(title, systemImage: icon)
} description: {
Text(message)
} actions: {
if let action, let actionTitle {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
}
}
}
}
Search
struct SearchableListView: View {
@State private var searchText = ""
@State private var items: [Item] = []
var filteredItems: [Item] {
if searchText.isEmpty { return items }
return items.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search items")
}
}
Package Management
Swift Package Manager (preferred)
In Xcode: File > Add Package Dependencies, or add to Package.swift:
// Package.swift (for modular app or library)
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v17)],
dependencies: [
.package(url: "https://github.com/supabase/supabase-swift", from: "2.0.0"),
.package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "Supabase", package: "supabase-swift"),
.product(name: "Kingfisher", package: "Kingfisher"),
]),
]
)
Common iOS Packages
| Package | Purpose | URL |
|---|---|---|
| Supabase | BaaS (auth, DB, storage) | supabase/supabase-swift |
| Kingfisher | Image loading + cache | onevcat/Kingfisher |
| Alamofire | Networking (if URLSession isn't enough) | Alamofire/Alamofire |
| SwiftLint | Code style enforcement | realm/SwiftLint |
| Lottie | Animations | airbnb/lottie-ios |
| TCA | Composable Architecture | pointfreeco/swift-composable-architecture |
| KeychainAccess | Keychain wrapper | kishikawakatsumi/KeychainAccess |
| RevenueCat | In-app purchases | RevenueCat/purchases-ios-spm |
| PostHog | Analytics | PostHog/posthog-ios |
| Sentry | Crash reporting | getsentry/sentry-cocoa |
Testing
Unit Tests
import XCTest
@testable import MyApp
final class ChatViewModelTests: XCTestCase {
var sut: ChatViewModel!
var mockService: MockChatService!
override func setUp() {
mockService = MockChatService()
sut = ChatViewModel(chatService: mockService)
}
func testSendMessage_appendsUserMessage() async {
sut.inputText = "Hello"
mockService.response = Message(role: .assistant, content: "Hi!")
await sut.send()
XCTAssertEqual(sut.messages.count, 2)
XCTAssertEqual(sut.messages[0].role, .user)
XCTAssertEqual(sut.messages[0].content, "Hello")
XCTAssertEqual(sut.messages[1].role, .assistant)
}
func testSendMessage_clearsInput() async {
sut.inputText = "Hello"
mockService.response = Message(role: .assistant, content: "Hi!")
await sut.send()
XCTAssertTrue(sut.inputText.isEmpty)
}
func testSendMessage_emptyInput_doesNothing() async {
sut.inputText = " "
await sut.send()
XCTAssertTrue(sut.messages.isEmpty)
}
}
SwiftUI Preview
#Preview {
ChatView()
.environment(AppState())
}
#Preview("Dark Mode") {
ChatView()
.environment(AppState())
.preferredColorScheme(.dark)
}
#Preview("Message Bubble") {
VStack {
MessageBubble(message: .init(role: .user, content: "Hello!"))
MessageBubble(message: .init(role: .assistant, content: "Hi there! How can I help?"))
}
.padding()
}
App Store & Distribution
Info.plist Keys (Common)
<!-- Camera access -->
<key>NSCameraUsageDescription</key>
<string>Take photos to share in chat</string>
<!-- Photo library -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Select photos to share in chat</string>
<!-- Location -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Find nearby services</string>
<!-- Microphone -->
<key>NSMicrophoneUsageDescription</key>
<string>Record voice messages</string>
<!-- Face ID -->
<key>NSFaceIDUsageDescription</key>
<string>Unlock the app with Face ID</string>
App Icons
Place in Assets.xcassets/AppIcon.appiconset/. Xcode 15+ only needs a single 1024x1024 image.
Launch Screen
Use Info.plist storyboard or SwiftUI-based launch screen:
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
<key>UIImageName</key>
<string>LaunchLogo</string>
</dict>
Xcode Tips for Claude Code
When working with Xcode projects from the terminal/Claude Code:
# Build from CLI
xcodebuild -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator build
# Run tests
xcodebuild test -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16'
# Clean
xcodebuild clean -project MyApp.xcodeproj -scheme MyApp
# List schemes
xcodebuild -list -project MyApp.xcodeproj
# Open in Xcode
open MyApp.xcodeproj
# Generate xcodeproj from Package.swift (for SPM-based projects)
swift package generate-xcodeproj
# Resolve packages
swift package resolve
# Format Swift code (if SwiftFormat installed)
swift-format format --in-place --recursive Sources/
Working with .pbxproj
The .pbxproj file is Xcode's project file. When adding new Swift files from Claude Code:
- Create the
.swiftfile in the correct directory - Tell the user to add it to the Xcode project (drag into Xcode navigator, or use
xcodegen/tuistfor automated project generation) - Or use SPM-based project structure where files are auto-discovered
Recommended: Use Tuist or XcodeGen
For CLI-friendly project management:
# Tuist (recommended)
brew install tuist
tuist init --platform ios
tuist generate # Generates .xcodeproj from Project.swift
# XcodeGen
brew install xcodegen
xcodegen generate # Generates .xcodeproj from project.yml
Swift 6 Concurrency & Sendable
// Mark types as Sendable when crossing concurrency boundaries
struct Message: Codable, Identifiable, Sendable {
let id: String
let role: Role
let content: String
let timestamp: Date
enum Role: String, Codable, Sendable {
case user, assistant, system
}
}
// Use @MainActor for UI-bound classes
@MainActor
@Observable
final class ChatViewModel {
var messages: [Message] = []
// ...
}
// Structured concurrency
func loadData() async {
async let user = fetchUser()
async let conversations = fetchConversations()
let (u, c) = await (try? user, try? conversations)
self.user = u
self.conversations = c ?? []
}
Quick Reference
| Web/Tailwind | SwiftUI Equivalent |
|---|---|
flex |
HStack / VStack / ZStack |
flex-col |
VStack |
flex-row |
HStack |
grid |
LazyVGrid / LazyHGrid |
gap-4 |
.spacing(16) on Stack or .padding() |
p-4 |
.padding(16) or .padding() |
px-4 |
.padding(.horizontal, 16) |
rounded-xl |
.clipShape(RoundedRectangle(cornerRadius: 12)) |
rounded-full |
.clipShape(Capsule()) or .clipShape(Circle()) |
bg-gray-100 |
.background(Color(.systemGray6)) |
text-sm |
.font(.caption) |
text-lg |
.font(.title3) |
font-bold |
.fontWeight(.bold) |
text-center |
.multilineTextAlignment(.center) |
opacity-50 |
.opacity(0.5) |
shadow-md |
.shadow(radius: 4) |
hidden |
.hidden() or conditional if |
overflow-scroll |
ScrollView |
absolute / relative |
ZStack with .offset() or GeometryReader |
transition |
.animation(.easeInOut, value: state) |
hover: |
.onHover { } (macOS) / no direct equivalent on iOS |
dark: |
@Environment(\.colorScheme) or adaptive Color(.label) |
max-w-lg |
.frame(maxWidth: 512) |
w-full |
.frame(maxWidth: .infinity) |
border |
.overlay(RoundedRectangle(...).stroke(...)) |
divide-y |
Use List with built-in dividers, or manual Divider() |
truncate |
.lineLimit(1) |
sr-only |
.accessibilityHidden(true) on visual, .accessibilityLabel() on interactive |
Minimum Deployment Targets
| Feature | Minimum iOS |
|---|---|
| SwiftUI | 13.0 |
| Combine | 13.0 |
| async/await | 15.0 |
| NavigationStack | 16.0 |
| Charts (Swift Charts) | 16.0 |
| @Observable | 17.0 |
| SwiftData | 17.0 |
| #Preview macro | 17.0 |
| String Catalogs | 17.0 |
| TipKit | 17.0 |
| Interactive Widgets | 17.0 |
| Custom container views | 18.0 |
| Liquid Glass | 26.0 |
Recommended minimum: iOS 17 — gives you @Observable, SwiftData, #Preview, and modern APIs while covering 90%+ of active devices.