Skip to main content

23 posts tagged with "SwiftUI"

View All Tags

How Appxiom MCP Helps iOS Developers Debug and Fix Bugs Faster

Published: · 14 min read
Don Peter
Cofounder and CTO, Appxiom

The iOS Debugging Crisis: The Illusion of "99.9% Crash-Free"​

Every experienced iOS developer knows the sinking feeling of opening a bug report from production:

  • "The app froze when I tapped Pay with Apple Pay."
  • "Everything became unresponsive on my iPhone 15 Pro after scrolling for 20 seconds."
  • "The app just randomly vanished while I was reviewing my cart."

You open your APM dashboard: Firebase Crashlytics, Sentry, or Datadog. The dashboard proudly displays a 99.9% Crash-Free Users vanity metric. There are no fatal crash logs. No EXC_BAD_ACCESS, no SIGSEGV, no unhandled Swift panics.

Yet, your App Store rating is dropping, and product analytics show a 22% drop-off in the checkout funnel.

The Traditional iOS Debugging Tax​

Step / MetricDiagnostic StageTraditional iOS Debugging Experience
1. Bug DetectionVague Support TicketAmbiguous user reports or negative App Store reviews
2. APM VisibilityCrashlytics False Security99.9% Crash-Free Users (zero fatal crashes recorded in APM tools)
3. Local Reproduction"Works on My Simulator" Trap2–4 hours guessing gestures, thermal states & network latency
4. Diagnostic IngestionRaw Log ExtractionDumping raw console / IPS logs (token bloat, unsymbolicated hex addresses & AI hallucinations)
5. Manual ProfilingInstruments InvestigationSpinning up Xcode Instruments manually (hand-profiling Time Profiler & Allocations)
6. Resolution TimeTotal MTTR6 to 8 Hours per Regression

The Anatomy of Silent iOS Failures​

Traditional crash reporters were architected a decade ago for an era when bugs meant fatal process crashes. But modern iOS applications rarely die from catastrophic memory corruption. Instead, they suffer from silent, conversion-killing runtime friction:

  1. iOS App Hangs (>250ms Main-Thread Freezes): Apple's MetricKit defines an App Hang as any period where the main run loop (CFRunLoop) is blocked from processing events for longer than 250 milliseconds. The app does not crash: it simply becomes a brick. Users tap the screen three times, assume the app is broken, force-quit, and leave.
  2. Watchdog Terminations (0x8badf00d): If an operation blocks the main thread for too long during launch (5 seconds) or transitions (10–20 seconds), Apple's OS Watchdog kills the process with exception code 0x8badf00d ("ate bad food"). Traditional tools frequently classify these as generic terminations without an actionable call stack.
  3. Swift 6 @MainActor Contention & Deadlocks: With Swift 6 strict concurrency, operations mistakenly isolated to the @MainActor (synchronous Keychain reads, disk I/O, cryptographic hashing, CoreData/SwiftData faulting) stall the UI thread while awaiting system locks.
  4. Memory Retention & Jetsam Terminations (0xdead10cc): Escaping closures missing [weak self], retained SwiftUI NavigationStack coordinators, or unbounded image caches push memory past the device's high-water mark, causing the iOS kernel (jetsam) to silently terminate the process.
  5. Silent State Deadlocks in SwiftUI: Swallowed try? expressions, unhandled network timeouts, or unhandled 4xx/5xx responses that leave views stranded indefinitely on blank screens or spinning progress views.

Why Raw iOS Logs Break AI Coding Assistants​

When developers ask AI coding assistants (Cursor, Claude Code, Antigravity) to help debug, they typically copy-paste messy Xcode console logs, raw MetricKit JSON, or unsymbolicated .ips crash files.

This immediately degrades the AI agent's performance:

  • Token Exhaustion: Raw logs contain thousands of lines of noisy system prints (nw_connection, CoreAnimation transaction commits, metal compiler notices).
  • Demangling & Symbolication Blindness: Raw memory addresses (0x00000001048b2a34) provide zero semantic meaning to an LLM without DWARF symbolication.
  • Context Degradation & Hallucinations: Because the AI agent cannot see the user's preceding journey or thread state, it hallucinates circular, speculative fixes (e.g., advising you to slap DispatchQueue.main.async everywhere).

Comprehensive Detection of iOS Issues​

Appxiom eliminates the iOS crash reporting blindspot. Traditional APMs only listen for catastrophic process terminations, missing the silent performance and user friction bugs that drive real churn.

Through a lightweight, zero-overhead Swift SDK, Appxiom continuously monitors the iOS runtime and automatically tracks 10 comprehensive issue types across responsiveness, memory, networking, and critical feature stability, with zero manual breadcrumb logging required:

  1. iOS App Hangs: Captures unresponsive main-thread behavior (>250ms) where CFRunLoop stalls, freezing the user interface without crashing.
  2. Cold Start Delays: Pinpoints startup bottlenecks during app initialization and launch sequences to optimize cold launch times.
  3. Screen Load Delays: Detects slow view transitions, layout stalls, and heavy rendering passes that leave users staring at blank screens.
  4. iOS Crashes: Automatically captures unhandled exceptions, EXC_BAD_ACCESS, fatal memory corruptions, and panics with full symbolication.
  5. Memory Leaks: Detects retain cycles, unreleased closures, and leaking view controllers during development and in production.
  6. Abnormal Memory Usage: Monitors high memory spikes and runaway heap consumption before the OS triggers memory warnings or termination.
  7. HTTP API Failures: Detects network request failures, timeouts, 4xx/5xx HTTP errors, and dropped network calls impacting user journeys.
  8. Crucial Feature Failures: Tracks core business feature breakdowns (such as failed payment flows or broken checkout) to prevent user drop-offs.
  9. Function Failures: Identifies failures in specific underlying functions and critical methods that break user-facing features.
  10. Custom Issues & Failures: Captures domain-specific exceptions, assertions, and custom business failure patterns tailored to your app.

Automatic iOS App Hang Detection: The Ground Truth​

Unlike MetricKit, which aggregates hangs into delayed 24-hour diagnostic batches, Appxiom monitors the CFRunLoop in real time.

The moment a main-thread block exceeds 250ms, Appxiom:

  1. Samples the exact call stack of com.apple.main-thread.
  2. Demangles all Swift symbols down to the exact source file and line number.
  3. Records the wall-clock stall duration.
  4. Captures the active device state: device model (e.g., iPhone 15 Pro), thermal state (ProcessInfo.ThermalState), battery level, and memory pressure.

How Appxiom MCP Works: Structured Ground Truth for iOS AI Agents​

Detecting these critical iOS issues in production is half the battle. The true leap in developer velocity happens when this telemetry is piped directly into your AI coding agent via the Model Context Protocol (MCP).

MCP is the open JSON-RPC standard that enables AI models in your editor (Cursor, Claude Code, Antigravity) to query external development tools directly.

Instead of switching between Xcode, Sentry, App Store Connect, and terminal logs, Appxiom MCP exposes your iOS production runtime as structured, machine-readable JSON tools inside your editor.

Raw Xcode Logs vs. Appxiom Structured MCP Data​

Diagnostic PropertyTraditional Raw iOS LogsAppxiom MCP Structured Telemetry
Data FormatUnstructured console prints, hex addressesClean, typed JSON schema
Token Overhead4,000–8,000 tokens of noisy system output~350–500 tokens of pure diagnostic signal
User JourneyCompletely invisibleActivity Trail: Chronological array of user actions/events
Stack TraceUnsymbolicated machine offsetsDemangled Swift 6 symbols with file and line numbers
Business ImpactUnordered error countGoal Friction Impact (GFI): Funnel drop-off and revenue loss
AI Hallucination RateHigh (speculative guessing)Near zero (deterministic runtime proof)

The 4 Structured Pillars Appxiom MCP Passes to Your Agent​

When your AI coding assistant queries Appxiom MCP, it receives four structured inputs:

  1. The SwiftUI / UIKit Activity Trail: A deterministic flight-recorder array of user taps, screen transitions, view presentations, and network calls leading to the stall: CartView ➔ AddressSelectionView ➔ PaymentMethodView ➔ Button("Confirm Apple Pay").tap
  2. Normalized Swift Thread Diagnostics: Demangled call trees showing the blocked thread (com.apple.main-thread), lock contention, and the exact offending line in your Swift file.
  3. Hardware & Environment Context: Device model (iPhone 16 Pro), thermal condition (ProcessInfo.thermalState = .serious), low-power mode status, and available RAM.
  4. Goal Friction Impact (GFI): A quantified metric linking the technical glitch directly to business impact (e.g., "This App Hang causes a 24.2% drop-off in the checkout funnel with an estimated $14,200/hr loss").

The 4-Step Accelerated iOS Debugging Workflow​

With Appxiom MCP integrated into your editor, the iOS debugging loop compresses from an entire afternoon to under 15 minutes:

StepWorkflowWhat HappensTime
1Intelligent Triage via MCPAgent queries Appxiom MCP and identifies the highest-friction regressions by GFI.30 seconds
2Ingest the SwiftUI Activity TrailAgent loads exact user actions and the view stack, eliminating guesswork.1 minute
3Precision Root-Cause IsolationAgent inspects the demangled Swift stack trace and pinpoints the exact line causing the issue.2 minutes
4Automated Swift 6 TDD Fix & Local VerificationAgent writes tests, refactors using Swift 6 concurrency patterns, and runs the test suite.5–8 minutes
ResultVerified Production Bug FixThe issue is fixed and locally verified.Under 15 minutes

Real-World iOS Bug Walkthroughs: The Workflow in Action​

Here are three real-world iOS engineering scenarios demonstrating how Appxiom MCP transforms complex debugging into immediate, deterministic fixes.

Walkthrough 1: The Invisible iOS Checkout Hang (Synchronous Keychain on @MainActor)​

The Symptom​

Users on iOS 18 tap "Pay with Apple Pay" during checkout, and the screen freezes for nearly two seconds. No crash report appears in Crashlytics. However, analytics indicate a 24.2% drop-off at this step.

How Appxiom MCP Surfaced It​

Appxiom automatically detected an iOS App Hang of 1,840ms on com.apple.main-thread.

Inside Cursor, the developer prompts:

"Query Appxiom MCP for the highest-friction checkout drop-offs on iOS."

The AI agent receives Appxiom's structured diagnostic payload:

  • Anomaly: ios_app_hang
  • Duration: 1,840ms
  • Thread: com.apple.main-thread
  • Activity Trail: CartView ➔ CheckoutView ➔ PaymentSelectionView ➔ Button.tap("Apple Pay")
  • Demangled Stack:
    0 libsystem_kernel.dylib         mach_msg_trap + 8
    1 Security SecItemCopyMatching + 148
    2 ShopAppCore KeychainWrapper.readToken(key:) (KeychainWrapper.swift:42)
    3 CheckoutFeature CheckoutViewModel.processApplePay() (CheckoutViewModel.swift:188)
    4 SwiftUI Button.action.callAsFunction() + 32

The Root Cause​

SecItemCopyMatching is a synchronous C-function that communicates with the securityd daemon over Mach ports. Under biometric authentication or system load, this call blocks the calling thread. Because CheckoutViewModel was marked @MainActor, it blocked the main thread for 1.84 seconds, freezing the UI.

The Swift 6 Fix​

The AI agent immediately understands the issue: the Keychain call must be isolated to a background actor.

// BEFORE (Buggy Implementation - Freezing @MainActor)
@MainActor
final class CheckoutViewModel: ObservableObject {
@Published var isProcessing: Bool = false

func processApplePay() {
isProcessing = true
// Synchronous Mach message to securityd blocks MainActor for 1,840ms
guard let token = KeychainWrapper.shared.readToken(key: "auth_token") else {
return
}
executePayment(with: token)
}
}

// AFTER (Appxiom MCP-Guided Fix - Actor-Isolated Background Concurrency)
actor KeychainAsyncService {
static let shared = KeychainAsyncService()

/// Reads Keychain items off the MainActor on the cooperative background thread pool
func readToken(key: String) -> String? {
return KeychainWrapper.shared.readToken(key: key)
}
}

@MainActor
final class CheckoutViewModel: ObservableObject {
@Published var isProcessing: Bool = false

func processApplePay() async {
isProcessing = true
// Non-blocking asynchronous suspension point
guard let token = await KeychainAsyncService.shared.readToken(key: "auth_token") else {
isProcessing = false
return
}
await executePayment(with: token)
isProcessing = false
}
}

The agent writes a unit test using the modern Swift Testing framework asserting that readToken runs off the main thread:

// Tests/CheckoutTests/KeychainAsyncTests.swift
import Testing
@testable import ShopAppCore

@Suite("Keychain Asynchronous Security Tests")
struct KeychainSecurityTests {
@Test("Verify Keychain query does not block MainActor")
func testKeychainQueryOffMainActor() async throws {
let isMain = await Task.detached {
let _ = await KeychainAsyncService.shared.readToken(key: "auth_token")
return Thread.isMainThread
}.value

#expect(!isMain, "Keychain read must execute off the main thread")
}
}

Resolution Time: 11 minutes.

Walkthrough 2: SwiftUI NavigationStack Retention & Memory Spike (Jetsam Kill)​

The Symptom​

Users browsing high-resolution product galleries in an e-commerce app experienced random app disappearances after navigating between 8–10 products. Crashlytics logged generic OS terminations with zero stack traces.

How Appxiom MCP Surfaced It​

Appxiom detected an iOS Memory Leak / Jetsam Warning:

  • Heap memory jumped from 112 MB to 1.42 GB in under 90 seconds.
  • OS emitted UIApplication.didReceiveMemoryWarningNotification three times before the kernel terminated the process.
  • Activity Trail: The user repeatedly pushed and popped ProductDetailView inside a NavigationStack.

The Root Cause​

The structured memory graph diagnostics provided by Appxiom MCP pointed to an escaping coordinator closure in ProductDetailView:

// BEFORE (Buggy Implementation - Strong Reference Retain Cycle)
struct ProductDetailView: View {
@StateObject var viewModel: ProductViewModel
let onAddToCart: (Product) -> Void

var body: some View {
VStack {
ImageCarousel(images: viewModel.highResImages)
Button("Add to Cart") {
// Escaping closure captures `viewModel` strongly,
// preventing NavigationStack view teardown
onAddToCart(viewModel.product)
}
}
}
}

Every time a user popped the view, the ProductViewModel and its decoded image buffers (highResImages, ~120MB per screen) remained allocated in heap memory because the navigation coordinator closure held a strong reference cycle.

The Swift Fix​

The AI agent:

  1. Injected an explicit [weak viewModel] capture list into the closure.
  2. Refactored the image pipeline to downsample thumbnails using ImageIO (CGImageSourceCreateThumbnailAtIndex) rather than decoding raw 4K buffers directly into memory.
  3. Verified via automated tests that instances of ProductViewModel successfully deallocate on onDisappear.

Resolution Time: 13 minutes.

Walkthrough 3: SwiftData / CoreData Main-Thread Faulting (ProMotion Frame Drop)​

The Symptom​

Users on 120Hz ProMotion displays (iPhone 15 Pro, iPhone 16 Pro) experienced severe UI stuttering and hitching when scrolling through an offline order history list containing 500+ items.

How Appxiom MCP Surfaced It​

Appxiom automatically detected an iOS Main-Thread Hitch & Sub-Second App Hang:

  • Hitch duration: 680ms (causing 41 dropped frames at 120fps).
  • Offending thread: com.apple.main-thread.
  • Stack trace pinpointed: NSManagedObject.value(forKey:) triggering synchronous relationship faulting during SwiftUI list cell rendering.

The SwiftData / CoreData Fix​

The agent recognized that accessing faulted relationships (order.items) inside SwiftUI's ForEach was executing synchronous SQLite reads on the main thread.

The agent refactored the fetch logic to use prefetching and background DTO projection:

// BEFORE (Buggy Implementation - Synchronous Faulting on MainActor)
struct OrderRowView: View {
let order: Order // Managed object

var body: some View {
HStack {
Text(order.orderNumber)
// Triggering synchronous relationship faulting on the main thread for each cell
Text("\(order.items.count) items")
}
}
}

// AFTER (Appxiom MCP-Guided Fix - Background DTO Projection)
struct OrderSummaryDTO: Sendable, Identifiable {
let id: UUID
let orderNumber: String
let itemCount: Int
}

actor OrderDataService {
func fetchOrderSummaries(context: ModelContext) throws -> [OrderSummaryDTO] {
var descriptor = FetchDescriptor<Order>(
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.relationshipKeyPathsForPrefetching = [\.items]

let orders = try context.fetch(descriptor)
return orders.map { OrderSummaryDTO(id: $0.id, orderNumber: $0.orderNumber, itemCount: $0.items.count) }
}
}

The hitch rate dropped to 0%, restoring buttery 120fps ProMotion scrolling.

Resolution Time: 10 minutes.

The Quantified MTTR Advantage for iOS Teams​

When iOS runtime telemetry is accessible as structured data directly inside your AI coding assistant, debugging cycles are radically transformed:

Stage of ResolutionTraditional iOS WorkflowAppxiom MCP + AI AgentSpeedup
1. Triage & Prioritization45–60 mins
• Sifting through noisy Crashlytics lists
• Deciphering vague support tickets
1 min
• Query MCP sorted by Goal Friction Impact
• Identifies the exact revenue-impacting flow
98% faster
2. Issue Reproduction2–4 hours
• Rebuilding on physical devices
• Guessing user taps and thermal states
2 mins
• Agent parses structured Activity Trail
• Exact view and gesture stack loaded
98% faster
3. Root-Cause Analysis1–2 hours
• Profiling manually in Instruments
• Symbolicating hex stack offsets
2 mins
• Demangled Swift stack points directly to the line
• Thread contention & stall duration exposed
96% faster
4. Fix & Verification60 mins
• Manual code patches
• Hoping concurrency bugs don't regress
5–8 mins
• Agent writes Swift Testing reproduction test
• Implements Swift 6 Actor fix & runs tests green
88% faster
Total MTTR5 to 8 Hours~10 to 15 Minutes~96% faster

Developer Quick-Start: Connecting Appxiom MCP to Your iOS Environment​

Connecting Appxiom MCP to your AI coding agent (Cursor, Claude Code, Antigravity) takes under two minutes.

Connect Appxiom MCP to Your AI Agent​

Connecting is effortless: as long as you are logged into your Appxiom dashboard, adding the MCP server to your AI agent requires just two simple steps without managing complicated authentication tokens:

  1. Get the MCP URL: Copy the Appxiom MCP server URL provided in the Appxiom Documentation.
  2. Add to Your AI Agent: Paste the MCP URL directly into your coding agent's MCP settings (such as in Cursor, Claude Code, or Antigravity).

Because your active Appxiom dashboard session authenticates access seamlessly, your AI assistant is instantly ready to query live production telemetry, user journeys, and thread diagnostics.

Conclusion: Zero-Guesswork iOS Engineering​

Debugging on iOS shouldn't mean guessing user steps, deciphering hexadecimal stack dumps, or waiting for delayed MetricKit reports.

By uniting comprehensive detection of critical iOS issues with the structured intelligence of Model Context Protocol (MCP), Appxiom bridges the gap between Apple's runtime reality and your editor.

Your AI coding assistants stop hallucinating, your team stops wasting 70% of its bandwidth on manual reproduction, and silent App Hangs are diagnosed, fixed, and verified in minutes.

Advanced SwiftUI Scrolling: Controlling Animations and Layouts with modern ScrollView APIs

Published: · 9 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Modern SwiftUI gives you fine-grained control over scrolling behavior, animations, and layout. If you’ve ever hacked together offset math with GeometryReader to build a snapping carousel, or struggled to animate cells as they enter the viewport, this post shows how to replace brittle workarounds with first-class APIs. We’ll cover SwiftUI ScrollView snapping, scrollTransition-driven animations, programmatic positioning with scrollPosition ID binding, and production-ready performance patterns.

Version prerequisites

  • iOS: 17.0+ (some notes for 16 included)
  • Xcode: 15+
  • Swift: 5.9+
  • Recommended: iOS 17+ to use .scrollTargetBehavior(.viewAligned), .scrollTransition, .scrollPosition(id:), .scrollTargetLayout, .containerRelativeFrame

Why these APIs matter​

  • scrollTargetBehavior(.viewAligned): built-in snapping without custom gestures or offset math.
  • ScrollTargetLayout + containerRelativeFrame: define how views participate in snapping and paging.
  • scrollPosition(id:): track and control which item is “active” in a scroll view via a simple binding.
  • scrollTransition: animate items as they move in/out of the viewport, with GPU-friendly transforms.
  • contentMargins and modern safe area APIs: reduce ad-hoc padding logic.

Together, these tools let you build accessible, high-performance carousels, feeds, and paged layouts using purely declarative SwiftUI.

We’ll implement a horizontally snapping carousel that:

  • Snaps each card into view using scrollTargetBehavior(.viewAligned)
  • Uses containerRelativeFrame to size items relative to the scroll container (one card per page)
  • Tracks and controls the current item with scrollPosition(id:)

Model and view components​

import SwiftUI

struct Card: Identifiable, Hashable {
let id = UUID()
let title: String
let color: Color
}

struct CardView: View {
let card: Card

var body: some View {
ZStack {
card.color
Text(card.title)
.font(.largeTitle).bold()
.foregroundStyle(.white)
.shadow(radius: 2)
}
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.shadow(radius: 6, y: 4)
// Height flexible; width is controlled by containerRelativeFrame below
.frame(height: 260)
.contentShape(Rectangle())
}
}
struct SnappingCarousel: View {
@State private var selection: UUID?
let items: [Card]

var body: some View {
VStack(spacing: 16) {
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(items) { item in
CardView(card: item)
.id(item.id)
// Make each card fill the scroll container (one per "page")
.containerRelativeFrame(.horizontal, count: 1, spacing: 16)
// Subtle transform as we scroll
.scrollTransition(axis: .horizontal) { content, phase in
content
.scaleEffect(phase.isIdentity ? 1.0 : 0.92)
.opacity(phase.isIdentity ? 1.0 : 0.65)
}
}
}
.scrollTargetLayout() // Required for view-aligned snapping
}
.scrollTargetBehavior(.viewAligned) // <- SwiftUI ScrollView snapping
.scrollIndicators(.hidden)
.contentMargins(.horizontal, 20, for: .scrollContent)
.scrollPosition(id: $selection) // Track which card is aligned
.onAppear { selection = items.first?.id } // Start on first card

HStack(spacing: 12) {
Button("Previous") { move(-1) }
Button("Next") { move(+1) }
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 20)
}

private func move(_ delta: Int) {
guard !items.isEmpty,
let current = selection,
let idx = items.firstIndex(where: { $0.id == current }) else { return }

let newIndex = max(0, min(items.count - 1, idx + delta))
withAnimation(.snappy) { // Smooth animate into position
selection = items[newIndex].id
}
}
}

Key points:

  • Without .scrollTargetLayout and .scrollTargetBehavior(.viewAligned), snapping won’t occur.
  • containerRelativeFrame(.horizontal, count: 1, spacing:) makes each view occupy a full “page,” including spacing. To show 1.2 cards (teaser peeking), use count: 1 but give the card an internal width smaller than the container, or adjust padding/spacing.
  • .scrollPosition(id:) binds the currently aligned ID. Setting the binding programmatically scrolls and snaps to that item.

scrollTransition SwiftUI tutorial: viewport-aware animations​

scrollTransition makes it trivial to animate views based on their position relative to the viewport. Unlike manual offset calculations, it’s layout-aware and highly performant.

Here’s a vertical feed where rows subtly fade and parallax while entering/exiting:

struct Post: Identifiable, Hashable {
let id = UUID()
let title: String
let subtitle: String
}

struct PostRow: View {
let post: Post

var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(post.title).font(.headline)
Text(post.subtitle).font(.subheadline).foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}

struct AnimatedFeed: View {
@State private var pinned: UUID? // Optional: to programmatically jump to a row
let posts: [Post]

var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(posts) { post in
PostRow(post: post)
.id(post.id)
.scrollTransition { view, phase in
view
.opacity(phase.isIdentity ? 1 : 0.85)
.scaleEffect(phase.isIdentity ? 1 : 0.98)
.offset(y: phase.isIdentity ? 0 : phase.isTop ? -6 : 6)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.scrollTargetLayout() // Enables pinch-to-zoom paging if you later apply snapping
}
// No snapping for feeds (snapping rows can feel jarring)
.scrollPosition(id: $pinned, anchor: .center) // Optional programmatic control
}
}

Notes:

  • You can specify axis in scrollTransition(axis:), but for many lists the default works.
  • phase tells you whether the view is aligned (identity) or approaching from top/bottom (or leading/trailing in horizontal).
  • Keep effects subtle for readability and accessibility. Avoid large blur radii and heavy shadows on every row.

Programmatic control with scrollPosition ID binding SwiftUI​

scrollPosition(id:) binds a Hashable ID to the nearest aligned item in the scroll view. You can:

  • Observe which item is in focus as the user scrolls
  • Set the binding to programmatically move and snap to an item
  • Optionally, choose an anchor to align the item (e.g., .top, .center)

Example (jump to a specific post from a deep link or filter):

struct FeedWithJump: View {
@State private var currentPostID: UUID?
let posts: [Post]
let deepLinkedID: UUID?

var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(posts) { post in
PostRow(post: post)
.id(post.id)
}
}
.padding(.horizontal, 16)
.scrollTargetLayout()
}
.scrollPosition(id: $currentPostID, anchor: .center)
.onAppear {
if let id = deepLinkedID, posts.contains(where: { $0.id == id }) {
withAnimation(.easeInOut) {
currentPostID = id
}
}
}
.onChange(of: currentPostID) { old, new in
// e.g., update analytics or a mini-map of the feed
// print("User focused:", new ?? UUID())
}
}
}

Common pitfalls:

  • The ID type in .id(...) must match the binding’s type exactly (e.g., UUID to UUID?).
  • Don’t forget to add .id(item.id) to each item in the scrollable content.
  • .scrollPosition updates frequently as the nearest aligned item changes; handle onChange responsibly to avoid heavy work on the main thread.

Layout control with ScrollTargetLayout and containerRelativeFrame​

  • .scrollTargetLayout() marks the container whose immediate children participate in alignment/snapping. Apply it to the HStack/LazyHStack or VStack/LazyVStack that holds your items.
  • .containerRelativeFrame(axis:count:spacing:) sizes each item relative to the scroll container’s dimension. Using count: 1 creates “pages.” Use count: 3 to show three items per page. spacing must match the container’s spacing for correct layout math.
  • Use .contentMargins(for: .scrollContent) to handle safe area and edge insets declaratively, instead of scattering paddings.

Example: show three items per page with snapping

ScrollView(.horizontal) {
LazyHStack(spacing: 12) {
ForEach(items) { item in
ItemView(item: item)
.id(item.id)
.containerRelativeFrame(.horizontal, count: 3, spacing: 12)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 16, for: .scrollContent)

This will snap to the nearest item “column,” presenting three per page.

When to use scrollTargetBehavior(.viewAligned)​

  • Great for: carousels, horizontally paged onboarding, photo browsers, horizontally scrolling category chips.
  • Use cautiously for: vertical content feeds and long lists; snapping every row can feel disruptive.
  • Avoid for: accessibility-critical flows where unexpected snapping might hinder linear navigation. If you must, provide predictable paging and clear selection state.

SwiftUI ScrollView performance in production​

  • Prefer lazy stacks for large datasets
    • Use LazyVStack/LazyHStack inside ScrollView for virtualization. Avoid eagerly rendering hundreds of child views.
  • Keep item views light
    • Cache images (e.g., via URLCache or a library). Prefer AsyncImage with a simple placeholder. Avoid repeated decoding on the main thread.
    • Minimize deep view hierarchies and expensive effects (blur, shadows, overlays). If necessary, rasterize static decorations with .drawingGroup() sparingly.
  • Stable identity and state
    • Provide stable .id values via model IDs (UUID, database IDs). Avoid using indices that can shift.
    • Use EquatableView or make your row views Equatable when appropriate to reduce recomputation.
  • Limit high-frequency updates
    • .scrollPosition can update rapidly; throttle side effects (analytics, networking). Keep them out of the render path.
  • Measure and profile
    • Instruments: Time Profiler and Allocations; SwiftUI template for body recomposition counts.
    • Test on lower-end devices. Watch for memory spikes when images prefetch.
  • Accessibility and motion
    • Provide reducedMotion-appropriate animations with .transaction or @Environment(.accessibilityReduceMotion).
    • Keep text sizes and contrasts readable if you’re scaling/opacity-fading items with scrollTransition.

Testing and debugging​

  • Snapshot the “selected” item
    • With selection bound to .scrollPosition(id:), UI tests can set selection to a known ID before taking snapshots.
  • Deep link scenarios
    • Unit-test logic mapping a deep-linked ID to your selection binding. Ensure ID exists; gracefully handle missing IDs.
  • Common compiler/runtime issues
    • “Snapping doesn’t work”: Did you add both .scrollTargetLayout() to the content stack and .scrollTargetBehavior(.viewAligned) to the ScrollView? Are item sizes resolvable (fixed frame or containerRelativeFrame)?
    • “scrollPosition binding never updates”: Ensure each item has .id with the same Hashable type as the binding; ensure the binding’s state is optional (e.g., UUID?) if no item is initially aligned.
    • “Jumpy animations when setting selection”: Wrap selection changes in withAnimation(.snappy) or a similar curve to smooth transitions.
    • “Cell animations feel heavy”: Reduce effect intensity in scrollTransition and prefer scale/opacity/translation over blur/shadows.

iOS 16 fallback

  • Use ScrollViewReader and proxy.scrollTo(id, anchor:) to programmatically jump.
  • Snap approximation: observe DragGesture end and compute the nearest index; then scrollTo. This is more work and less fluid than .viewAligned, but workable for older targets.

Architectural tips​

  • MVVM placement
    • Keep selection (scrollPosition) as view state (@State) unless it directly informs business logic. If it does, mirror it in a lightweight @Observable view model property.
  • Modularization
    • Encapsulate carousels and feeds as feature modules with their own models and views. Expose a small API: items, selection binding, and callbacks.
  • State restoration
    • Persist the selected ID (e.g., in scene storage) for UX continuity. On restore, set the binding before the scroll view appears for a seamless initial snap.

Real-world polish checklist​

  • Use .scrollIndicators(.hidden) judiciously; keep them visible for long feeds unless design calls for hiding.
  • Consider haptic feedback on snap points for carousels (UIImpactFeedbackGenerator) triggered when selection changes.
  • Support keyboard/trackpad: add commands (move left/right) that change the selection binding.
  • Ensure tappable hit targets remain accessible when scaling items with scrollTransition; keep contentShape consistent.

Key takeaways​

  • For SwiftUI ScrollView snapping, use:
    • .scrollTargetLayout() on the content stack
    • .scrollTargetBehavior(.viewAligned) on the scroll view
    • containerRelativeFrame to define paging geometry
  • Use scrollPosition ID binding SwiftUI to both observe and control the active item seamlessly.
  • Prefer scrollTransition for performant, viewport-aware animations with minimal code.
  • Mind SwiftUI ScrollView performance with lazy stacks, stable IDs, and lightweight effects.
  • Build declarative, maintainable scrolling features without geometry hacks, making your code easier to evolve and test.

Next steps

  • Convert your existing carousels to use .scrollTargetBehavior(.viewAligned) and replace offset math with containerRelativeFrame.
  • Add tasteful scrollTransition effects to enhance depth and focus.
  • Switch programmatic scrolls to .scrollPosition(id:) and measure improvements in code clarity and reliability.

Looking to track UI performance and scroll frame drops in development, testing, and production? Appxiom connects real-user app performance and crashes directly to business impact.

Mastering SwiftData: Migrating Core Data Apps in SwiftUI

Published: · 15 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

SwiftData was introduced with iOS 17 as Apple's Swift-native approach to persistence. Since then, it has continued to evolve, and iOS 26 adds important capabilities such as model inheritance, while the broader SwiftData API has continued to improve around schema migration, querying, relationships, indexing, and persistent history.

For teams maintaining a production application built with Core Data, moving to SwiftData doesn't mean throwing away the existing persistence layer and starting from scratch. The migration can be approached incrementally, allowing you to preserve existing data while gradually adopting SwiftData's APIs.

This guide explains how to migrate a legacy Core Data application to SwiftData in a modern SwiftUI codebase. We'll cover model conversion, ModelContainer, @Query, schema versioning, SchemaMigrationPlan, lightweight migrations, and the iOS 26 model inheritance capabilities.

The goal isn't simply to replace Core Data APIs. It's to modernize the persistence layer without putting existing user data at risk.

Prerequisites​

For the examples in this article, you'll need:

  • Xcode 26 or later
  • iOS 26 SDK
  • Swift and SwiftUI from the current Xcode toolchain
  • An existing application using Core Data
  • A persistent Core Data store, typically backed by SQLite

If your application supports earlier iOS versions, make sure iOS 26-specific APIs are protected with the appropriate availability checks.

Why Migrate from Core Data to SwiftData?​

Core Data remains a capable persistence framework, so migration isn't something you should do simply because SwiftData is newer.

The strongest reasons to migrate are usually related to the development experience.

SwiftData provides:

  • Swift-native model definitions
  • Declarative queries through @Query
  • Direct SwiftUI integration
  • Type-safe model relationships
  • Versioned schemas
  • Migration plans
  • Modern Swift language features
  • Less framework-specific model boilerplate

Instead of defining an NSManagedObject subclass and configuring fetch requests around it, you can define a persistent model using the @Model macro and work with it directly from Swift code.

However, if your application has a large and stable Core Data implementation, a full rewrite may introduce unnecessary risk. An incremental migration can be a better approach.

Core Data vs. SwiftData​

The APIs look different even though both frameworks solve the same fundamental problem: managing persistent application data.

Core DataSwiftData
NSManagedObject@Model
NSPersistentContainerModelContainer
NSManagedObjectContextModelContext
NSFetchRequestFetchDescriptor
@FetchRequest@Query
Mapping models/custom migrationVersionedSchema + SchemaMigrationPlan

SwiftData provides a Swift-native persistence API and integrates closely with SwiftUI. ModelContainer manages the schema and persistent storage, while ModelContext provides the environment for fetching, inserting, deleting, and saving models.

The migration therefore isn't just an API replacement. It is also a change in how your application defines and interacts with its persistence model.

Should You Migrate Everything at Once?​

Usually, no.

For a small application, a complete conversion may be manageable. For a production application with years of stored data and many Core Data entities, an incremental approach is generally easier to validate.

A practical migration can look like this:

Existing Core Data App
│
▼
Audit existing model
│
▼
Create SwiftData models
│
▼
Configure ModelContainer
│
▼
Migrate individual SwiftUI screens
│
▼
Replace @FetchRequest with @Query
│
▼
Introduce schema versioning
│
▼
Remove legacy Core Data code

Apple's current SwiftData guidance also demonstrates adopting SwiftData in existing applications and evolving schemas over multiple releases rather than treating persistence migration as a single isolated operation.

Step 1: Audit Your Existing Core Data Model​

Before creating your first SwiftData model, document the Core Data schema you're migrating.

Check:

  • Entity names
  • Attribute names
  • Attribute types
  • Optionality
  • Relationships
  • Delete rules
  • Unique constraints
  • Indexes
  • Transformable attributes
  • Custom value transformers
  • Existing migration versions

Don't immediately rename everything to make it look more Swift-like.

During the first migration, keeping entity and attribute names stable can make the transition easier to reason about.

For example, if your Core Data model contains:

Task
├── id: UUID
├── title: String
├── createdAt: Date
└── isDone: Bool

your first SwiftData model should closely represent that structure.

Step 2: Convert Core Data Entities to @Model​

SwiftData uses the @Model macro to define persistent model types.

The Core Data entity above can become:

import SwiftData

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}

The important part here is mapping the existing model, not redesigning it.

A typical mapping looks like:

Core DataSwiftData
StringString
Integer 16Int16
Integer 32Int32
Integer 64Int64
BooleanBool
DoubleDouble
DateDate
Binary DataData
TransformableAppropriate Swift/Codable type

SwiftData also supports indexes and unique constraints through Swift macros, allowing the model definition to express constraints that previously lived in Core Data's model configuration.

Step 3: Configure the ModelContainer​

Once the model has been created, SwiftData needs a ModelContainer to manage the persistent store.

A basic configuration looks like this:

import SwiftData

@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")

let configuration = ModelConfiguration(
url: storeURL
)

return try ModelContainer(
for: Task.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}

Then provide the container to your SwiftUI application:

import SwiftUI
import SwiftData

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(PersistenceController.shared)
}
}

ModelContainer is responsible for managing the application's persistent storage and schema. For an existing production application, the store configuration and migration path should be tested carefully before release.

Do not test an in-place migration only against a newly created database. Use copies of representative existing stores so that you know how real user data behaves.

When rolling out persistent store migrations in production, monitoring startup times, memory spikes, and data-related crashes is critical. You can track migration stability and catch migration failures in real-time with Appxiom.

Step 4: Replace @FetchRequest with @Query​

One of the most noticeable changes when migrating a SwiftUI application is replacing Core Data's @FetchRequest.

A Core Data implementation might look like:

@FetchRequest(
sortDescriptors: [
NSSortDescriptor(
keyPath: \Task.createdAt,
ascending: false
)
]
)
private var tasks: FetchedResults<Task>

The SwiftData version is:

@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]

A complete SwiftUI view can then use the model directly:

import SwiftUI
import SwiftData

struct ContentView: View {
@Environment(\.modelContext)
private var modelContext

@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]

var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
HStack {
Text(task.title)

Spacer()

if task.isDone {
Image(
systemName:
"checkmark.circle.fill"
)
}
}
}
.onDelete(perform: delete)
}
.navigationTitle("Tasks")
.toolbar {
Button("Add") {
addTask()
}
}
}
}

private func addTask() {
let task = Task(title: "New Task")

modelContext.insert(task)

do {
try modelContext.save()
} catch {
print("Failed to save task: \(error)")
}
}

private func delete(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}

do {
try modelContext.save()
} catch {
print("Failed to delete task: \(error)")
}
}
}

This is one of the main advantages of SwiftData for SwiftUI applications: persistent data can be queried and consumed using Swift-native types without the same amount of Core Data-specific boilerplate.

Step 5: Version Your SwiftData Schema​

The initial migration is only one part of the problem.

Your model will continue changing after you move to SwiftData.

For example, suppose version 1 contains:

import SwiftData

enum AppSchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(1, 0, 0)
}

static var models: [any PersistentModel.Type] {
[Task.self]
}

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}
}

Notice that versionIdentifier uses Schema.Version:

Schema.Version(1, 0, 0)

A later release can introduce a new property:

import SwiftData

enum AppSchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(2, 0, 0)
}

static var models: [any PersistentModel.Type] {
[Task.self]
}

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool
var notes: String?

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false,
notes: String? = nil
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
self.notes = notes
}
}
}

Keep historical schema definitions available when they are required by your migration path. Existing users may have data created under an older schema even though new installations start with the latest one.

Step 6: Create a SchemaMigrationPlan​

Once your application has multiple schema versions, define how it moves between them.

For a simple change such as adding an optional property, a lightweight migration may be sufficient:

import SwiftData

enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
AppSchemaV1.self,
AppSchemaV2.self
]
}

static var stages: [MigrationStage] {
[
.lightweight(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self
)
]
}
}

Then create the container with the migration plan:

import SwiftData

@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")

let configuration = ModelConfiguration(
url: storeURL
)

return try ModelContainer(
for: AppSchemaV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}

The migration path now looks like:

Schema V1
│
│ Lightweight migration
▼
Schema V2

As the application evolves, you can extend the chain:

Schema V1
│
▼
Schema V2
│
▼
Schema V3
│
▼
Schema V4

Apple's WWDC25 SwiftData migration example follows this same principle: historical schemas are retained and connected through migration stages, allowing an application to evolve its model while preserving existing data.

Step 7: Understand Lightweight vs. Custom Migration​

Not every schema change needs custom migration code.

Use lightweight migration when:​

  • Adding an optional property
  • Making supported schema changes that SwiftData can infer
  • Adding compatible relationships
  • Applying supported schema changes without transforming existing values

Use custom migration when:​

  • Existing values need to be transformed
  • Multiple properties need to be combined
  • One entity becomes multiple entities
  • Multiple entities become one
  • Required values need to be generated
  • Business logic determines how old records map to the new schema

For example, if an old model contains:

firstName
lastName

and the new model requires:

fullName

the application needs to determine how existing values should be combined. That's a data transformation, not simply a new optional attribute.

The important rule is:

Don't force a complex data transformation into a lightweight migration just because the code is shorter.

A migration that technically succeeds but produces incorrect data is still a failed migration.

Step 8: Take Advantage of iOS 26 Model Inheritance​

One of the most important SwiftData additions for iOS 26 is model inheritance.

Apple's WWDC25 SwiftData session demonstrates using inheritance to model different types of trips while sharing common properties.

For example:

import SwiftData

@Model
class Trip {
var destination: String
var startDate: Date
var endDate: Date

init(
destination: String,
startDate: Date,
endDate: Date
) {
self.destination = destination
self.startDate = startDate
self.endDate = endDate
}
}

A specialized model can inherit from it:

@available(iOS 26, *)
@Model
final class BusinessTrip: Trip {
var companyName: String

init(
destination: String,
startDate: Date,
endDate: Date,
companyName: String
) {
self.companyName = companyName

super.init(
destination: destination,
startDate: startDate,
endDate: endDate
)
}
}

The resulting hierarchy is:

Trip
├── BusinessTrip
└── PersonalTrip

Inheritance is useful when there is a genuine "is-a" relationship.

A BusinessTrip is a Trip.

If two models simply share a few properties without forming a natural hierarchy, composition or protocol-based design may be more appropriate. Apple recommends using inheritance deliberately rather than treating it as the default solution for shared properties.

Step 9: Migrate an Existing Schema to Inheritance​

Introducing inheritance changes the schema, so existing users still need a migration path.

Apple's WWDC25 SampleTrips example evolved through multiple schema versions and introduced inheritance in its iOS 26 schema. The migration plan then connects the previous schema to the new inheritance-based schema.

The important lesson isn't to copy the exact SampleTrips implementation.

It's to treat the inheritance change as another versioned schema transition:

Schema V2
│
▼
Schema V3
│
▼
Schema V4
│
└── New inheritance hierarchy

This keeps the migration understandable and gives you a clear way to test users upgrading from older releases.

Step 10: Use Modern SwiftData Query Features​

iOS 26 also adds sectioning support to SwiftData queries.

Suppose your model contains a category:

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var category: String
var createdAt: Date

init(
id: UUID = UUID(),
title: String,
category: String,
createdAt: Date = .now
) {
self.id = id
self.title = title
self.category = category
self.createdAt = createdAt
}
}

You can group query results using sectionBy:

@Query(
sort: \Task.createdAt,
order: .reverse,
sectionBy: \Task.category
)
private var tasks: [Task]

You can then access the sections through the projected query:

List {
ForEach(_tasks.sections) { section in
Section(section.id) {
ForEach(section) { task in
Text(task.title)
}
}
}
}

Apple added sectioning to SwiftData's query APIs as part of its newer SwiftData updates.

This can simplify SwiftUI screens that previously fetched Core Data records and manually grouped them after the fetch.

Step 11: Handle Legacy Transformable Data Carefully​

Core Data applications often contain Transformable attributes.

During migration, don't automatically replace every Transformable attribute with a new SwiftData model.

First ask:

  • Is this data a natural Swift value type?
  • Does it need to be queried?
  • Does it need sorting or filtering?
  • Is it a type controlled by the application?
  • Is it a third-party type?

For types that SwiftData can model natively, using a regular SwiftData model or supported value type is usually preferable.

For external Codable types that SwiftData cannot inspect directly, newer SwiftData releases provide Codable-based persistence through schema attributes. Apple describes this as useful for types you don't directly control.

The important distinction is that Codable persistence is not a replacement for modeling data that you actually need SwiftData to query and index.

Testing the Migration​

A migration should never be tested only with a fresh installation.

Create test stores representing different historical states:

Core Data V1
Core Data V2
SwiftData V1
SwiftData V2
SwiftData V3
Current SwiftData schema

Test upgrades such as:

V1 → Current
V2 → Current
V3 → Current

Also test:

  • Empty stores
  • Small stores
  • Large stores
  • Missing optional values
  • Existing relationships
  • Duplicate records
  • Unique constraints
  • Renamed properties
  • Newly introduced fields
  • Migration failures
  • Memory usage
  • Migration duration

A migration that works with 100 development records may behave very differently with a production store containing years of data.

The most important test isn't simply:

"Did the app launch?"

It is:

"Did the user's existing data remain correct?"

Common Migration Problems​

The store schema doesn't match​

If SwiftData cannot reconcile the persisted store with the current schema or migration plan, the container may fail to open the store.

Check:

  • Model names
  • Attribute names
  • Attribute types
  • Schema versions
  • Migration stages
  • Persistent store configuration

Renamed properties don't migrate correctly​

Renaming a property can cause problems if the migration doesn't have enough information to associate the old and new representations.

When possible, keep names stable during the initial Core Data to SwiftData migration.

Large migrations cause memory pressure​

Avoid fetching the entire database into memory.

Process records in batches instead.

The UI freezes​

Don't perform large data transformations synchronously on the main UI path.

Use an appropriate model context and process large datasets in manageable batches.

When Should You Use a Side-by-Side Migration?​

In-place migration is attractive when your Core Data schema maps closely to the SwiftData model.

A side-by-side migration can make more sense when the new schema is substantially different.

Consider it when:

  • Most entities are being redesigned
  • Relationships are changing significantly
  • Multiple entities are being merged
  • One entity becomes multiple entities
  • Legacy data needs substantial transformation

The architecture looks like:

Existing Core Data Store
│
▼
Read old records
│
▼
Transform data
│
▼
Write SwiftData
│
▼
Validate
│
▼
Switch to new store

This requires more implementation work, but it gives you complete control over how old records are transformed.

Production Migration Checklist​

Before shipping your Core Data to SwiftData migration:

  • Audit every Core Data entity
  • Document relationships and delete rules
  • Document existing schema versions
  • Create the initial @Model representations
  • Keep names stable where possible
  • Configure ModelContainer
  • Test against real representative stores
  • Replace @FetchRequest with @Query
  • Introduce VersionedSchema
  • Create a SchemaMigrationPlan
  • Test lightweight migrations
  • Test custom migrations where necessary
  • Test users upgrading across multiple releases
  • Test large stores
  • Test migration failures
  • Verify data integrity after migration
  • Test iOS 26-specific model changes separately
  • Monitor migration behavior after release

Final Takeaway​

Migrating from Core Data to SwiftData isn't about replacing one persistence API with another overnight.

The safer approach is to treat it as a controlled schema migration.

Start by mapping your existing Core Data entities to SwiftData @Model types. Keep the initial schema as close as practical to the existing data model, configure the ModelContainer, and move SwiftUI screens from @FetchRequest to @Query incrementally.

As the application evolves, use VersionedSchema and SchemaMigrationPlan to describe how your persistent model changes between releases.

And if you're targeting iOS 26, SwiftData now provides additional modeling capabilities such as class inheritance, allowing applications to represent genuine model hierarchies while continuing to evolve their schemas.

The key principle remains simple:

A migration isn't successful because the app launches. It's successful when existing users retain correct data and the application can continue evolving safely.

Safeguard Your SwiftData Migration with Appxiom​

Migrating your persistence layer shouldn't mean flying blind. Appxiom gives mobile teams full visibility into production performance, crash telemetry, and release health as you update your SwiftUI stack.

Mastering SwiftData: Architectural Best Practices for Scalable SwiftUI Apps

Published: · 13 min read
Don Peter
Cofounder and CTO, Appxiom

SwiftData finally gives SwiftUI a first‑class persistence story, but “just make it work” demos don’t answer the questions you hit in production: How do I design a clean SwiftData architecture in SwiftUI? Where should ModelContainer live? How do I do background work safely? How do I test it? This guide distills production‑oriented patterns and SwiftData best practices so your “hello world” doesn’t become a maintenance nightmare. If you’re searching for “swiftdata architecture swiftui,” this post walks you end‑to‑end with code you can ship.

Prerequisites​

  • Xcode 15.4+ (Swift 5.9+)
  • iOS 17+ (SwiftData is iOS 17+; some fixes landed in 17.2/17.4, so target the latest when possible)
  • SwiftUI + Swift Concurrency
  • Optional: Observation framework (@Observable) for MVVM

The production problem SwiftData solves (and what it doesn’t)​

SwiftData gives you:

  • Declarative models with @Model
  • A simple persistence stack (ModelContainer + ModelContext)
  • Tight SwiftUI integration via @Query and environment(.modelContext)
  • Type‑safe fetches via FetchDescriptor and #Predicate

What it doesn’t solve by itself:

  • Data layer boundaries and testability
  • Background work and thread safety beyond the main actor
  • Separation of concerns for scalable codebases (feature modules, repositories, DI)

The rest of this article shows how to build those missing pieces.

SwiftData architecture in SwiftUI: a production‑ready blueprint​

This is the baseline “swiftui data layer architecture” I recommend for most apps:

  • App layer

    • Owns a single ModelContainer configured at startup
    • Injects the container into SwiftUI via .modelContainer(…)
    • Wires up repositories and feature services (via DI)
  • Data layer (SwiftData)

    • @Model types encapsulating persistence schema
    • Repositories that speak domain language and use ModelContext internally
    • Background actors for heavy or long‑running work
  • Domain layer

    • Plain Swift types for business logic and ViewModel state
    • Optional mappers to/from @Model to avoid leaking persistence concerns
  • Presentation layer (SwiftUI)

    • MVVM with @Observable or ObservableObject
    • Simple screens can use @Query for lists
    • Mutations go through ViewModels -> repositories/services

This keeps SwiftUI reactive and simple while making your data layer testable and scalable.

Defining SwiftData models with relationships​

We’ll build a small “Projects & Tasks” feature end‑to‑end.

import SwiftData

@Model
final class Project {
var id: UUID
var name: String
var createdAt: Date

@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task]

init(id: UUID = UUID(), name: String, createdAt: Date = .now, tasks: [Task] = []) {
self.id = id
self.name = name
self.createdAt = createdAt
self.tasks = tasks
}
}

@Model
final class Task {
var title: String
var isDone: Bool
var dueDate: Date?

// Inverse is declared on Project.tasks
var project: Project?

init(title: String, isDone: Bool = false, dueDate: Date? = nil, project: Project? = nil) {
self.title = title
self.isDone = isDone
self.dueDate = dueDate
self.project = project
}
}

Notes and best practices:

  • Prefer stable, domain‑meaningful IDs (UUID) alongside SwiftData’s internal identifier (persistentModelID) if you need cross‑layer mapping or external sync.
  • Set delete rules on relationships intentionally. Here we cascade‑delete tasks when a project is deleted.
  • Keep models lean - avoid computed properties with heavy logic; put logic in services or ViewModels.

ModelContainer setup and dependency injection​

Create a single ModelContainer for your app and inject it into SwiftUI. This allows @Query and @Environment(.modelContext) to work out of the box.

import SwiftUI
import SwiftData

@main
struct ProjectsApp: App {
var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(for: [Project.self, Task.self])
}
}

If you need custom configuration (e.g., in‑memory for tests, multiple stores), build it manually and pass it into .modelContainer(_:)

@main
struct ProjectsApp: App {
private let container: ModelContainer = {
let config = ModelConfiguration() // customize if needed
return try! ModelContainer(for: [Project.self, Task.self], configurations: config)
}()

var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(container)
}
}

Tip: Keep ModelContainer at the app boundary and inject it into repositories and background actors.

Clean repositories on top of SwiftData (swiftdata clean architecture)​

Define domain DTOs to decouple UI/business logic from persistence details.

// Domain-facing DTOs
struct ProjectDTO: Identifiable, Equatable {
let id: UUID
let name: String
let createdAt: Date
}

struct TaskDTO: Identifiable, Equatable {
let id: UUID
let title: String
let isDone: Bool
let dueDate: Date?
let projectId: UUID
}

Repository protocol:

protocol ProjectsRepository {
func allProjects() throws -> [ProjectDTO]
func createProject(name: String) throws -> ProjectDTO
func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO
func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws
func deleteProjects(_ ids: [UUID]) throws
}

SwiftData implementation:

import SwiftData

struct SwiftDataProjectsRepository: ProjectsRepository {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Map functions
private func map(_ p: Project) -> ProjectDTO {
ProjectDTO(id: p.id, name: p.name, createdAt: p.createdAt)
}
private func map(_ t: Task, projectId: UUID) -> TaskDTO {
TaskDTO(id: t.project?.id == projectId ? t.project!.id : (t.project?.id ?? UUID()),
title: t.title,
isDone: t.isDone,
dueDate: t.dueDate,
projectId: t.project?.id ?? projectId)
}

func allProjects() throws -> [ProjectDTO] {
let context = ModelContext(container)
var descriptor = FetchDescriptor<Project>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 200 // production: page if needed
let projects = try context.fetch(descriptor)
return projects.map(map)
}

func createProject(name: String) throws -> ProjectDTO {
let context = ModelContext(container)
let model = Project(name: name)
context.insert(model)
try context.save()
return map(model)
}

func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO {
let context = ModelContext(container)

let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { $0.id == projectId },
fetchLimit: 1
)
guard let project = try context.fetch(pDesc).first else {
throw NSError(domain: "ProjectsRepository", code: 404, userInfo: [NSLocalizedDescriptionKey: "Project not found"])
}

let task = Task(title: title, dueDate: dueDate, project: project)
context.insert(task)
try context.save()

return TaskDTO(id: task.title.hashValue.uuid, // or better: add UUID on Task model
title: task.title,
isDone: task.isDone,
dueDate: task.dueDate,
projectId: project.id)
}

func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws {
let context = ModelContext(container)
let tDesc = FetchDescriptor<Task>(
predicate: #Predicate { ($0.project?.id) == projectId } // Example: toggle all tasks in a project
)
let tasks = try context.fetch(tDesc)
for t in tasks { t.isDone = isDone }
try context.save()
}

func deleteProjects(_ ids: [UUID]) throws {
let context = ModelContext(container)
let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { ids.contains($0.id) }
)
for p in try context.fetch(pDesc) {
context.delete(p) // cascades to tasks
}
try context.save()
}
}

// Utility to convert Int hash to UUID for demo purposes only
private extension Int { var uuid: UUID { UUID(uuidString: String(format: "%08X-0000-0000-0000-%012X", self, self)) ?? UUID() } }

Notes:

  • For production, add a real UUID to Task as well; don’t derive one from a hash (shown here to keep the example small).
  • Each repository method creates its own ModelContext (lightweight) and saves explicitly. Avoid sharing ModelContext across threads/actors.

Background work with SwiftData ModelActor concurrency​

Heavy imports, sync, and cleanup should not block the main actor. In SwiftData, create a dedicated actor for isolation. Using a custom actor is fine; Apple also provides ModelActor, which streamlines access to a ModelContainer and ModelContext. If you prefer not to rely on macros, a plain actor works well:

actor ProjectsBackgroundWorker {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Example: Import projects from a CSV on a background actor
func importProjects(fromCSV url: URL) throws -> Int {
let context = ModelContext(container)
context.undoManager = nil // reduce memory for batch work

let data = try Data(contentsOf: url)
let text = String(decoding: data, as: UTF8.self)

var count = 0
for line in text.split(separator: "\n") {
let name = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
context.insert(Project(name: name))
count += 1

// Save in batches to keep memory low
if count % 100 == 0 {
try context.save()
}
}
try context.save()
return count
}
}

Why this pattern:

  • ModelContext is not thread-safe; creating and using it inside an actor guarantees isolation.
  • Avoid passing @Model instances across actors. If you must reference a specific record, pass its persistentModelID or your own UUID, and re-fetch in the target context.

If you’re comfortable with the SwiftData macro, @ModelActor can synthesize some of this boilerplate. The underlying best practice stays the same: keep context usage confined to a single actor.

MVVM with SwiftUI and SwiftData​

You can lean on @Query for simple screens and still keep mutations behind a ViewModel.

import SwiftUI
import SwiftData
import Observation

@Observable
final class ProjectListViewModel {
private let repo: ProjectsRepository

init(repo: ProjectsRepository) { self.repo = repo }

@MainActor
func createProject(name: String) {
do { _ = try repo.createProject(name: name) }
catch { print("Create failed: \(error)") }
}

@MainActor
func deleteProjects(ids: [UUID]) {
do { try repo.deleteProjects(ids) }
catch { print("Delete failed: \(error)") }
}
}

struct ProjectListScreen: View {
@Environment(\.modelContext) private var context
// Use @Query for reactive UI; map to display models as needed
@Query(sort: \Project.createdAt, order: .reverse, animation: .snappy)
private var projects: [Project]

// DI: resolve from environment or container
private let viewModel: ProjectListViewModel

init(container: ModelContainer? = nil) {
// In a real app, use a DI container. Here we bootstrap from environment when available.
if let container {
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: container))
} else {
// Will be replaced in .onAppear when environment container is known
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: try! ModelContainer(for: [Project.self, Task.self])))
}
}

@State private var newName = ""

var body: some View {
NavigationStack {
List {
ForEach(projects, id: \.persistentModelID) { project in
NavigationLink(project.name) {
TaskListScreen(project: project)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Projects")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
} label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New project name", text: $newName)
.textFieldStyle(.roundedBorder)
Button("Add") {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
}
}
.padding()
.background(.bar)
}
}
}

private func delete(offsets: IndexSet) {
let ids = offsets.compactMap { projects[$0].id } // our domain UUID on Project
viewModel.deleteProjects(ids: ids)
}
}

struct TaskListScreen: View {
@Environment(\.modelContext) private var context
let project: Project

@Query private var tasks: [Task]
@State private var title: String = ""

init(project: Project) {
self.project = project
// Filter tasks for this project by its UUID
_tasks = Query(filter: #Predicate<Task> { $0.project?.id == project.id },
sort: [SortDescriptor(\.title)])
}

var body: some View {
List {
ForEach(tasks, id: \.persistentModelID) { task in
HStack {
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
.onTapGesture {
task.isDone.toggle()
try? context.save()
}
Text(task.title)
}
}
.onDelete(perform: delete)
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New task", text: $title)
.textFieldStyle(.roundedBorder)
Button("Add") {
guard !title.isEmpty else { return }
let t = Task(title: title, project: project)
context.insert(t)
try? context.save()
title = ""
}
}
.padding()
.background(.bar)
}
.navigationTitle(project.name)
}

private func delete(offsets: IndexSet) {
for i in offsets { context.delete(tasks[i]) }
try? context.save()
}
}

Key takeaways:

  • Use @Query for lists to get automatic UI updates and batched animations.
  • Write operations should call context.save() explicitly (don’t rely solely on autosave behavior).
  • ViewModels orchestrate intent and call repositories; views remain thin.

Unit testing SwiftData with ModelContainer​

Question: “How do you unit test SwiftData models in SwiftUI?”

Use an in‑memory ModelContainer for fast, isolated tests. Inject it into your repository or feature service.

import XCTest
import SwiftData
@testable import ProjectsApp

final class SwiftDataProjectsRepositoryTests: XCTestCase {
var container: ModelContainer!
var repo: ProjectsRepository!

override func setUpWithError() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: [Project.self, Task.self], configurations: config)
repo = SwiftDataProjectsRepository(container: container)
}

override func tearDownWithError() throws {
container = nil
repo = nil
}

func testCreateAndFetchProjects() throws {
_ = try repo.createProject(name: "Alpha")
_ = try repo.createProject(name: "Beta")

let projects = try repo.allProjects()
XCTAssertEqual(projects.count, 2)
XCTAssertEqual(projects.map(\.name).sorted(), ["Alpha", "Beta"])
}

func testAddTask() throws {
let p = try repo.createProject(name: "Alpha")
let t = try repo.addTask(to: p.id, title: "Do work", dueDate: nil)
XCTAssertEqual(t.projectId, p.id)
}
}

Notes:

  • Keep ModelContainer local to the test to avoid cross‑test coupling.
  • Use repositories in tests to validate mapping and persistence logic.
  • For UI tests, inject a pre‑seeded, in‑memory container into previews or test app targets.

SwiftData preview container setup in SwiftUI​

Question: “swiftdata preview container setup swiftui”

Create a pre‑populated, in‑memory container for previews.

struct ProjectListScreen_Previews: PreviewProvider {
static var previewContainer: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: [Project.self, Task.self], configurations: config)
let context = ModelContext(container)

let demo = Project(name: "Demo")
demo.tasks = [Task(title: "Explore SwiftData", project: demo),
Task(title: "Write blog", isDone: true, project: demo)]
context.insert(demo)
try? context.save()

return container
}()

static var previews: some View {
ProjectListScreen(container: previewContainer)
.modelContainer(previewContainer)
}
}

This keeps previews fast and realistic.

SwiftData best practices for scalability and performance​

  • Context isolation

    • Never share a ModelContext across threads or actors.
    • Create a fresh ModelContext per unit of work. It’s cheap and safer.
  • Writes and transactions

    • Call save() explicitly after mutations.
    • Batch large imports and save periodically to bound memory.
  • Don’t pass @Model across concurrency domains

    • Pass a UUID or persistentModelID between threads/actors and re‑fetch in the target context.
    • If you need to reload by id: use a predicate on your domain UUID or use the context’s model(for:) with the persistent ID.
  • Fetching and memory

    • Use predicates and sort descriptors to limit result sets.
    • Consider fetch limits or paging for very large tables.
  • Undo and background work

    • Disable undoManager on background/batch contexts to reduce memory.
  • Delete rules

    • Choose cascade/nullify intentionally to avoid orphan records or accidental data loss.
  • Schema evolution

    • Plan additive, backward‑compatible changes when possible.
    • Test migrations on real data before shipping. Keep DTOs decoupled to reduce ripple effects.
  • DI and modularization

    • Inject ModelContainer and repositories. Keep features in separate modules for long‑term maintainability.

Troubleshooting and common pitfalls​

  • Concurrency violations

    • Symptom: runtime warnings/crashes about accessing a model from the wrong actor.
    • Fix: confine all ModelContext usage to a single actor/thread. Re‑fetch models in the target context using domain IDs.
  • “No model found” or fetch returns empty

    • Ensure your @Model types are listed in the ModelContainer configuration.
    • Verify you saved the context after inserts.
  • UI not updating after writes

    • Make sure you’re saving the same store the view reads from (i.e., same ModelContainer).
    • If not using @Query, manually refresh state after background updates (e.g., re‑fetch in ViewModel).
  • Crashes on delete

    • Check relationship deleteRule and inverse. Missing or incorrect inverse can cause inconsistencies.
  • Preview data doesn’t show

    • Ensure the preview view uses .modelContainer(previewContainer), not the app’s default container.
    • Seed data before returning the container.

FAQ​

How to structure SwiftData in a clean architecture?​

  • Keep @Model types in the data layer.
  • Expose repositories with domain DTOs and business‑friendly APIs.
  • Use dependency injection to give ViewModels the repository(s).
  • Use @Query for simple, reactive lists; push all writes through repositories or services.
  • Isolate background work in a dedicated actor (or a ModelActor) and never share ModelContext across actors.

How to perform background tasks in SwiftData using ModelActor?​

  • Create an actor that owns a ModelContainer and instantiates a ModelContext per job.
  • Perform inserts/updates within the actor and call save() in batches.
  • Pass IDs between actors, not @Model instances. Re‑fetch on the background actor’s context.
  • If you prefer, use Apple’s ModelActor to synthesize context access; the concurrency rules stay the same.

How do you unit test SwiftData models in SwiftUI?​

  • Use ModelContainer with ModelConfiguration(isStoredInMemoryOnly: true) inside XCTest.
  • Inject the container into repositories/services under test.
  • Seed data in a test context and verify fetches/mutations.
  • For preview/testing UI, pass a pre‑populated in‑memory container via .modelContainer(_:)

Putting it together: why this swiftdata architecture works with SwiftUI​

This “swiftdata architecture swiftui” approach scales because:

  • SwiftUI stays reactive and simple with @Query.
  • Repositories make your data layer testable and keep persistence concerns contained.
  • Background actors provide safe, predictable concurrency for heavy work.
  • DI and DTOs keep modules independent and migrations manageable.

Key takeaways and next steps​

  • Use a single ModelContainer at the app boundary; inject it.
  • Confine ModelContext to a single actor; create one per unit of work.
  • Prefer repositories and DTOs for a clean architecture; keep @Model out of business logic.
  • Use @Query for read‑heavy views; save explicitly on writes.
  • Test with an in‑memory ModelContainer; set up a seeded preview container.

Next steps:

  • Add a UUID to Task to fully decouple DTOs from SwiftData internals.
  • Introduce a background import service using your actor and wire progress to the UI.
  • If your app grows, split features into modules and keep repositories per feature.

By following these SwiftData best practices, you get a scalable, testable, and performant SwiftUI data layer you can maintain for years - without sacrificing the developer ergonomics SwiftData brings to the table.

Eliminating @MainActor Violations: Strict Concurrency Checks and Main Thread Checker in iOS

Published: · 9 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Modern Swift Concurrency makes it easier to write safe, responsive iOS apps - but only if you respect actor isolation. The most common pitfall is touching UI from a background context, which surfaces as an @MainActor violation at compile-time or a Main Thread Checker warning at runtime. With Swift 6 strict concurrency checks in Xcode and the Main Thread Checker, you can catch these issues before they reach production.

This guide explains how to identify, fix, and prevent @MainActor violations using Swift Concurrency, Strict Concurrency Checking, and Xcode’s diagnostics. We’ll cover practical patterns for SwiftUI and UIKit, architectural guidance, and production-ready code you can drop into your app today.

Prerequisites​

  • Xcode 16+ (Swift 6 language mode available; Swift 5.10 in Swift 5 mode)
  • iOS 15+ (async/await, actors)
  • Enable Strict Concurrency Checking in Build Settings:
    • Swift 5 mode: Swift Compiler – Warnings: Strict Concurrency Checking = Complete (preferably as Errors in CI)
    • Swift 6 mode: Concurrency checking is stricter by default; expect more issues to be surfaced as errors
  • Scheme > Run > Diagnostics: enable Main Thread Checker for runtime verification

What counts as an @MainActor violation?​

  • UI frameworks (UIKit, SwiftUI, AppKit) are main-actor-isolated. Their APIs must be called from the main actor.
  • Many APIs are explicitly or implicitly annotated with @MainActor. Examples include UIApplication/shared state, some UI state mutations, and SwiftUI view updates.
  • A call to main actor-isolated method from a background context triggers a compiler error in Swift 6 (or warnings in Swift 5 with strict checking) and/or a runtime main thread checker warning in Xcode.

Typical errors and warnings you’ll see:

  • Compiler: “Call to main actor-isolated method ‘reloadData()’ in a synchronous nonisolated context”
  • Runtime: “Main Thread Checker: UI API called on a background thread”

Why the Main Actor exists​

The main actor serializes access to UI state. It’s the concurrency-safe replacement for “dispatch everything onto DispatchQueue.main.” Instead of manually juggling queues, you:

  • Isolate UI-bound types with @MainActor.
  • Hop to the main actor (await MainActor.run or Task { @MainActor in ... }) when you must touch UI from background work.
  • Let the compiler help you avoid data races and invalid UI access.

Turn on swift 6 strict concurrency checks​

To catch problems early:

  • In Swift 5 mode:

    • Build Settings > Swift Compiler – Warnings > Strict Concurrency Checking = Complete
    • Treat Warnings as Errors in CI to enforce correctness
  • In Swift 6 language mode (Xcode 16+):

    • Concurrency checks are stricter by default. Many violations become errors.
    • Expect to add annotations (@MainActor, @Sendable, nonisolated) and “await” markers where the compiler now requires them.

Tip: In Xcode, “Fix” suggestions often offer the correct remedy - either add await, mark a function or type @MainActor, or explicitly hop to the main actor.

Using the Main Thread Checker in Xcode​

  • Enable: Scheme > Run > Diagnostics > Main Thread Checker
  • Reproduce UI flows. Watch the console for “main thread checker warning xcode” messages and call stacks.
  • The checker catches runtime-only issues (e.g., in code paths the compiler can’t fully analyze or in third-party libraries without annotations).

Use both: Swift 6 strict concurrency checks at compile time, and Main Thread Checker at runtime.

How to fix @MainActor violations in Swift​

1. Isolate UI-bound types with @MainActor​

For SwiftUI or UIKit, types that own UI state or trigger UI updates should be @MainActor. This includes:

  • View models that mutate @Published state consumed by views
  • Coordinators, presenters, and controllers triggering navigation or alerts

Example: SwiftUI MVVM

// Xcode 16+, iOS 15+

protocol SearchService: Sendable {
func search(query: String) async throws -> [ResultItem]
}

struct ResultItem: Identifiable, Sendable {
let id: UUID
let title: String
}

@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var results: [ResultItem] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?

private let service: SearchService
private var currentTask: Task<Void, Never>?

init(service: SearchService) {
self.service = service
}

func search(query: String) {
currentTask?.cancel()
isLoading = true
errorMessage = nil

currentTask = Task { [service] in
do {
// This call runs off the main actor while awaited,
// and the main actor is free to process UI events.
let items = try await service.search(query: query)
// When execution resumes here, we're back on the main actor
// because the ViewModel is @MainActor.
self.results = items
} catch is CancellationError {
// Ignore
} catch {
self.errorMessage = "Search failed: \(error.localizedDescription)"
}
self.isLoading = false
}
}

deinit {
currentTask?.cancel()
}
}

Why this avoids violations:

  • All UI-observable mutations (@Published properties) occur under @MainActor.
  • Awaited work runs off-main while suspended, preventing UI hitches.
  • No explicit MainActor.run needed because the type is main-actor-isolated.

SwiftUI View:

struct SearchView: View {
@StateObject private var viewModel: SearchViewModel

init(service: SearchService) {
_viewModel = StateObject(wrappedValue: SearchViewModel(service: service))
}

var body: some View {
VStack {
// ... search field ...
if viewModel.isLoading {
ProgressView()
}
List(viewModel.results) { item in
Text(item.title)
}
if let error = viewModel.errorMessage {
Text(error).foregroundColor(.red)
}
}
.task {
// Optionally kick off an initial load
await Task.yield()
}
}
}

2. Hop to the main actor from background contexts​

When you must update UI from a non-main actor, perform an explicit hop.

Two primary tools:

  • await MainActor.run { ... }
  • Task { @MainActor in ... }

mainactor.run vs task​

  • MainActor.run
    • Suspends the current task, executes the closure on the main actor, then resumes.
    • Use when you need a result back or when performing a small, synchronous UI mutation in the middle of an async flow.
    • Example:
let processed = try await imageProcessor.process(input)
await MainActor.run {
imageView.image = processed // UI update safely on main actor
}
  • Task { @MainActor in ... }
    • Creates a new, independent task bound to the main actor (fire-and-forget).
    • Use for notifications, navigation triggers, or UI updates that don’t need to block the current task.
    • Example:
Task { @MainActor in
self.tableView.reloadData()
}

Rule of thumb:

  • Need to return a value or order UI updates in the same async flow? Use MainActor.run.
  • Fire-and-forget UI work or decouple from the current task? Use Task { @MainActor in }.

3. Annotate closures and APIs with global actors​

If you own the API surface, annotate callbacks that will run on the main actor:

struct BannerPresenter {
// Consumers know the closure runs on the main actor.
func present(message: String, completion: @MainActor @escaping () -> Void) {
Task { @MainActor in
// Present on main
// ...
completion()
}
}
}

This reduces surprises and prevents downstream @MainActor violation sites.

4. Don’t blanket @MainActor everything​

Over-annotating non-UI code with @MainActor can:

  • Introduce unnecessary serialization and contention
  • Risk responsiveness issues if heavy work runs under the main actor
  • Complicate testing

Keep data, networking, and computation off the main actor:

  • Use plain types or custom actors for stateful, concurrent components
  • Keep view models @MainActor, but services/clients should be Sendable and not main-isolated
actor ImageCache {
private var storage: [URL: Data] = [:]
func data(for url: URL) -> Data? { storage[url] }
func insert(_ data: Data, for url: URL) { storage[url] = data }
}

5. UIKit patterns​

UIKit isn’t automatically “safe” in Swift Concurrency. Explicitly switch to main when needed.

final class PhotoListViewController: UITableViewController {
private let service: PhotoService

init(service: PhotoService) {
self.service = service
super.init(style: .plain)
}

required init?(coder: NSCoder) { fatalError() }

override func viewDidLoad() {
super.viewDidLoad()

Task {
do {
let items = try await service.fetchPhotos()
await MainActor.run {
self.applySnapshot(with: items) // Diffable data source update
}
} catch {
await MainActor.run {
self.presentError(error)
}
}
}
}

@MainActor
private func applySnapshot(with items: [Photo]) {
// Safe to mutate UI state here
// ...
tableView.reloadData()
}

@MainActor
private func presentError(_ error: Error) {
let alert = UIAlertController(title: "Error",
message: error.localizedDescription,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}

6. Combine bridges​

When bridging Combine pipelines:

  • Use .receive(on:) to hop to the main run loop if you can’t migrate to async/await yet.
  • For async/await, prefer handling values in async contexts and use MainActor.run for UI updates.
// Combine: ensure downstream UI updates are on main
publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
self?.label.text = value
}
.store(in: &cancellables)

7. Core Data considerations​

  • Use a background context for heavy work (NSPrivateQueueConcurrencyType) and a main-queue context for UI.
  • Hop to main before touching NSManagedObject instances bound to the view context or updating fetched results controllers/SwiftUI.
  • Don’t mark Core Data stack types @MainActor unless they truly are UI-only abstractions.
do {
try backgroundContext.performAndWait {
// background operations
}
await MainActor.run {
try? viewContext.save()
// or update UI based on fetched objects in viewContext
}
} catch {
// handle error
}

Common compiler errors and how to resolve them​

  • Error: “Call to main actor-isolated method ‘reloadData()’ in a synchronous nonisolated context”

    • Fix: Add await MainActor.run { tableView.reloadData() } or mark the enclosing function/type with @MainActor if it’s UI-facing.
  • Error: “Expression is not Sendable” or “Capture of non-Sendable type in @Sendable closure”

    • Fix: Conform value types to Sendable; avoid capturing reference types across actors; alternatively, run the closure on @MainActor if it’s UI-only.
  • Misuse: Task.detached used to update UI

    • Fix: Prefer Task { @MainActor in ... } for UI updates; Task.detached has no parent task and no actor inheritance, increasing risk of violations.
  • Missing await on main-actor APIs in Swift 6

    • Fix: Add await when calling @MainActor functions from non-main-actor contexts, or move that code into MainActor.run/Task { @MainActor in }.

Checklist before shipping​

  • Build with swift 6 strict concurrency checks (or Swift 5 Complete checking) enabled
  • Run with Main Thread Checker; ensure zero warnings on critical flows
  • View models that mutate UI state are @MainActor
  • Services and data layers are not @MainActor and are Sendable where appropriate
  • UI updates from background work use either await MainActor.run or Task { @MainActor in } as appropriate
  • Avoid Task.detached for UI paths
  • CI treats concurrency warnings as errors

Key takeaways​

  • An @MainActor violation means you’re touching main-actor-isolated APIs from the wrong context. In Swift 6, these become hard errors.
  • Combine compile-time safety (swift 6 strict concurrency checks) with runtime safety (Main Thread Checker) to catch issues early.
  • Isolate UI-bound types with @MainActor, keep heavy work off the main actor, and hop explicitly with MainActor.run or Task { @MainActor in }.
  • Understanding mainactor.run vs task helps you choose the right tool for deterministic updates vs fire-and-forget UI actions.
  • With these patterns, you eliminate @MainActor violations and ship smoother, safer apps.

Next steps:

  • Audit your UI pathways for main-actor isolation
  • Enable strict concurrency checking in all targets (including tests)
  • Incrementally annotate and refactor; let the compiler guide you to a safer architecture

This approach will keep your code aligned with Apple’s current recommendations, avoid “call to main actor-isolated method” build errors, and prevent Main Thread Checker warnings from slipping into production.

What's New in Store for Developers with Swift 6.

Published: · Last updated: · 5 min read
Don Peter
Cofounder and CTO, Appxiom

Swift 6 introduces new and creative functionalities aimed at improving your coding experience and enabling you to develop more sturdy and effective applications.

Exploring the key features of Swift 6, from advancements in concurrency to fine-tuning functions, this blog post uncovers the top 4 highlights. Discover how these enhancements can enhance your development process and open up new opportunities for your projects.

1. Swift Concurrency Isolation​

Swift Concurrency is designed to ensure data safety by implementing a system that isolates code execution with actors. Actors in Swift are a concurrency feature introduced in Swift 5.5. They are designed to protect their state from data races and ensure that only one piece of code can access an actor's data at a time.

By confining code execution within these isolated units, Swift Concurrency minimizes the risk of conflicts arising from simultaneous access to shared data. In order to send data between these units, developers has to use Sendable types. You can read in detail about sendable types here https://developer.apple.com/documentation/swift/sendable

However, while this mechanism enhances data safety, it also introduces certain constraints on programming practices. Specifically, some commonly used patterns involve non-Sendable data, like classes or mutable structs, which refers to data that is not inherently safe to share across different contexts. This is because using non-Sendable types concurrently can lead to data races and compiler warns developers against it.

This limitation can impact how developers write their programs, as they must carefully consider the implications of sharing non-Sendable data within the context of Swift Concurrency.

// Not Sendable
class Client {
init(name: String, initialBalance: Double) { ... }
}

actor ClientStore {
var clients: [Client] = []

static let shared = ClientStore()

func addClient(_ c: Client) {
clients.append(c)
}
}

func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client) // Error! 'Client' is non-`Sendable`!
}

1.1 Introducing Isolation Regions​

Swift 6 introduces a new feature known as isolation regions, which revolutionizes the way the compiler comprehends data usage and ensures security during the transmission of non-Sendable values across isolation boundaries like actors. Isolation regions essentially equip the compiler with the ability to analyze how data is utilized, thereby enabling it to ascertain whether two data entities have the potential to influence each other and cause data race situations.

There is nothing specific for the developer to do for this capability to be activated, except upgrading to Swift 6.

2. Count and Filter​

Swift now includes a nifty feature called count(where:). This method lets you efficiently count elements in a collection that meet a specific condition. It combines the functionality of filter() (which creates a new array with matching elements) and count() (which calculates the number of elements) into a single step.

let testScores = [70, 85, 90, 68, 95]
let passingCount = testScores.count(where: { $0 >= 85 })

print("Number of tests with scores 85 or higher:", passingCount)

This not only saves you from creating unnecessary temporary arrays, but also provides a cleaner and more readable way to achieve this common task.

The beauty of count(where:) is that it's not limited to just arrays. It works with any collection type that conforms to the Sequence protocol, including sets and dictionaries. This gives you a powerful and versatile tool for working with various data structures in Swift.

3. Error Handling with Typed Throws​

Swift introduces a much-awaited feature: "typed throws." This eliminates a common frustration with error handling - the need for a general catch clause even when you've caught all specific errors.

enum RegistrationError: Error {
case notAlphaNumbericChars
}
  • Specificity: You can now declare precisely what types of errors a function can throw using throws(OneSpecificErrorType). This signals that only that specific error type can be thrown by the function.

  • Cleaner Code: Since Swift knows the exact error type, you can write more concise code. For example, if your function throws only RegistrationError, you can write throw .notAlphaNumbericChars instead of a generic error message.

do {
register()
} catch RegistrationError.notAlphaNumbericChars {
print("Please make sure password filed contains alpha numberic Characters")
}
  • Automatic Type Inference: In a do block that throws only one type of error, the error value in a general catch block automatically becomes the specific error type instead of a generic Error.

  • Improved Safety: Swift throws a compile-time error if you attempt to throw an error not listed in the throws clause.

  • Expressive Rethrows: You can write rethrows more clearly in many cases. For example, throws(any Error) is equivalent to just throws, and throws(Never) signifies a non-throwing function.

4. Internal Imports within Modules​

Imagine a large e-commerce application with a modular architecture:

  • Core Functionality: This core module handles essential functionalities like product management, shopping cart handling, and user authentication.

  • Payment Processing: This separate module deals with secure payment processing and integrates with various payment gateways.

  • Analytics & Logging: This module is responsible for tracking user interactions, logging events, and potentially utilizing third-party analytics services.

4.1 Challenge: Dependency Management​

The core application depends on both Payment Processing and Analytics & Logging modules. However, ideally, the core functionality shouldn't expose these internal dependencies to other parts of the codebase.

internal import <ModuleName>

4.2 Access Control Modifiers to the Rescue​

Swift 6.0's access control modifiers on import statements come in handy here:

  • Private Imports: The core module can privately import the Payment Processing and Analytics & Logging modules. This ensures that these dependencies are not accidentally exposed or used outside the core module.

  • Encapsulation and Security: By keeping payment processing and analytics private, the core module promotes better encapsulation and potentially strengthens security by limiting access to sensitive functionalities.

These are the top 4 new features in Swift 6.0, in my opinion.

Happy Coding and Bug Fixing.

Solving Frame Rate Issues and App Hangs in SwiftUI iOS Apps

Published: · Last updated: · 7 min read
Appxiom Team
Mobile App Performance Experts

Developing a smooth and responsive iOS app is crucial for providing a great user experience. Frame rate issues and app hangs can be frustrating for users and can lead to negative reviews and decreased app usage.

In this blog post, we will explore common causes of frame rate issues and app hangs in SwiftUI iOS apps and provide solutions and code examples to address them.

Understanding Frame Rate Issues​

Frame rate issues occur when an app struggles to render frames at the desired rate, usually 60 frames per second (FPS) on most iOS devices. When the frame rate drops, animations become choppy, and the app feels less responsive. There are several common reasons for frame rate issues:

  • Inefficient View Updates: SwiftUI's declarative nature encourages frequent view updates. If not optimized, this can lead to excessive rendering and reduced frame rates.

  • Heavy Computation on the Main Thread: Performing CPU-intensive tasks on the main thread can block the UI, making the app feel unresponsive.

  • Large Images and Assets: Loading or rendering large images or assets can consume significant memory and processing power, leading to frame rate drops.

Solving Frame Rate Issues in SwiftUI​

1. Optimize View Updates​

You can optimize view updates by:

  • Using the .onAppear and .onDisappear modifiers to load data only when necessary.

  • Implementing the .id modifier to identify views uniquely and avoid unnecessary updates.

  • Reducing the complexity of SwiftUI view hierarchies.

Example:

struct ContentView: View {
var body: some View {
Text("Optimize your views")
.onAppear {
// Load data when the view appears
loadData()
}
}
}

​

2. Offload Heavy Computation​

Move CPU-intensive tasks to background threads using DispatchQueue or Combine. Ensure that UI updates occur on the main thread.

Using DispatchQueue:

DispatchQueue.global().async {
// Perform heavy computation
let result = performHeavyComputation()

DispatchQueue.main.async {
// Update the UI on the main thread
self.resultLabel = result
}
}

Combine is a powerful framework for handling asynchronous and event-driven code in Swift. You can use Combine to perform background operations in SwiftUI seamlessly. In this example, we'll demonstrate how to use Combine to execute a background operation and update the SwiftUI view when the operation completes.

Let's say you want to fetch some data from a network API in the background and update your SwiftUI view when the data is ready. Here's a step-by-step guide:

  1. Import Combine in your SwiftUI view file:
import SwiftUI
import Combine
  1. Define a ViewModel to handle your data and background operations. Create an ObservableObject class that will hold your data and expose a publisher for notifying view updates.
class MyViewModel: ObservableObject {
@Published var data: [YourDataType] = [] // Replace YourDataType with the actual data type you're using
private var cancellables: Set<AnyCancellable> = []

func fetchData() {
// Simulate a background network request
fetchDataFromNetwork()
.receive(on: DispatchQueue.main) // Ensure updates are on the main thread
.sink { completion in
// Handle completion or errors if needed
} receiveValue: { [weak self] newData in
self?.data = newData
// Update the data when received
}
.store(in: &cancellables)
}

private func fetchDataFromNetwork() -> AnyPublisher<[YourDataType], Error> {
// Implement your network request logic here and return a Combine publisher
// For example, you can use URLSession's dataTaskPublisher
let url = URL(string: "https://your-api-url.com/data")!
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: [YourDataType].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}

Replace YourDataType with the actual type of data you're fetching from the network.

  1. Create a SwiftUI view that observes the changes in your ViewModel and triggers the background operation:
struct ContentView: View {
@ObservedObject private var viewModel = MyViewModel()

var body: some View {
VStack {
if viewModel.data.isEmpty {
Text("Loading...")
} else {
List(viewModel.data, id: \.self) { item in
// Display your data here
Text(item.name)
// Replace with your data properties
}
}
}
.onAppear {
viewModel.fetchData()
// Trigger the background operation when the view appears
}
}
}

In this SwiftUI view, the @ObservedObject property wrapper observes changes to the viewModel, and the onAppear modifier triggers the background operation by calling viewModel.fetchData() when the view appears.

Now, your SwiftUI view will fetch data from the network in the background using Combine and update the view when the data is ready, providing a smooth and responsive user experience.

3. Efficiently Manage Images and Assets​

Load images lazily and use asset catalogs for managing image resources. Resize images to appropriate dimensions to reduce memory usage.

In SwiftUI, you can load images lazily using the AsyncImage view. AsyncImage allows you to load and display images asynchronously, which is especially useful for large images or images fetched from the network. Here's how you can use AsyncImage to load images lazily in SwiftUI:

import SwiftUI

struct LazyLoadingImageView: View {
let imageURL: URL
var body: some View {
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
// Placeholder while loading (optional)
ProgressView()
case .success(let image):
// Successfully loaded image
image
.resizable()
.scaledToFit()
case .failure(_):
// Handle the failure (e.g., show an error message)
Image(systemName: "xmark.octagon")
.resizable()
.scaledToFit()
.foregroundColor(.red)
@unknown default:
// Handle other unknown states
Text("Unknown state")
}
}
}
}

In the code above:

  • AsyncImage is used to load the image asynchronously from the specified URL.

  • The closure inside AsyncImage receives a Phase parameter, which represents the current state of the image loading process.

  • In the .empty phase, you can display a placeholder (e.g., a ProgressView) to indicate that the image is being loaded.

  • In the .success phase, you can display the loaded image, making it resizable and scaling it to fit the available space.

  • In the .failure phase, you can handle the failure by displaying an error image or a message.

  • The @unknown default case is used to handle any unknown states that might be introduced in future SwiftUI versions.

To use the LazyLoadingImageView in your SwiftUI view, simply provide the URL of the image you want to load:

struct ContentView: View {
var body: some View {
LazyLoadingImageView(imageURL: URL(string: "https://example.com/image.jpg")!)
.frame(width: 200, height: 200)
}
}

Make sure to replace "https://example.com/image.jpg" with the actual URL of the image you want to load.

With AsyncImage, you can efficiently load and display images in a lazy manner, ensuring a smooth user experience, especially when dealing with large images or images from remote sources.

Addressing App Hangs​

App hangs occur when the app becomes unresponsive for 250 milli seconds or more due to various reasons, such as blocking the main thread or network requests taking too long. Here are some strategies to prevent app hangs:

1. Use Background Threads for Network Requests​

Perform network requests on background threads to avoid blocking the main thread. Combine or URLSession can be used for this purpose.

Example:

let cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: MyModel.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in }) { data in
// Process data and update UI
}

2. Implement Error Handling​

Handle errors gracefully, especially in asynchronous operations, to prevent app hangs and crashes.

do {
let result = try performRiskyOperation()
// Handle the result
} catch {
// Handle the error
}

​

3. Use Xcode Instruments and APM tools​

Use Xcode's Instruments to profile your app's performance, identify bottlenecks, and monitor memory usage. Debugging tools like LLDB can help trace and fix specific issues causing frame rate issues and app hangs.

APM tools are a very much helpful in detecting frame rate issues and App Hangs. Appxiom is such an APM tool that helps in detecting these issues and provides all relevant data points to the developer to fix the issue.

Conclusion​

Frame rate issues and app hangs can significantly impact the user experience of your SwiftUI iOS app. By optimizing view updates, offloading heavy computation, efficiently managing assets, and addressing app hangs through proper threading and error handling, you can create a smooth and responsive app that users will love.

Remember that performance optimization is an ongoing process. Regularly test your app on different devices and keep an eye on performance metrics to ensure a consistently great user experience.

Integrating and Using Firestore With SwiftUI

Published: · Last updated: · 6 min read
Appxiom Team
Mobile App Performance Experts

Firestore is a powerful NoSQL database service provided by Google Cloud that allows developers to store and sync data for their applications. When combined with SwiftUI, Apple's modern user interface framework, you can create robust and dynamic iOS applications.

In this article, we will walk you through the process of integrating Firestore with SwiftUI and demonstrate how to perform basic CRUD (Create, Read, Update, Delete) operations using Firestore in a SwiftUI application.

Prerequisites​

  • Xcode installed on your Mac.

  • Basic knowledge of Swift and SwiftUI.

  • A Google Firebase account (https://firebase.google.com/).

  • A new SwiftUI project created in Xcode.

Step 1: Set up Firebase Project​

Before you can use Firestore in your SwiftUI project, you need to create a Firebase project and configure it.

  • Go to the Firebase Console (https://console.firebase.google.com/).

  • Click "Add Project" and follow the setup instructions.

  • Once your project is created, click on it in the Firebase Console.

  • In the left sidebar, click on "Firestore."

  • Click on "Create Database" and choose "Start in test mode" for now. You can adjust security rules later.

Step 2: Add Firebase to your SwiftUI Project​

In Xcode, open your SwiftUI project, and follow these steps to integrate Firebase into your project:

  • Go to your Firebase project settings and click on the gear icon (Project Settings).

  • Scroll down and click on "iOS" under Your apps.

  • Click on the iOS icon to register your app with Firebase.

  • Follow the setup instructions and download the GoogleService-Info.plist file.

Now, add Firebase to your Xcode project:

  • Drag and drop the downloaded GoogleService-Info.plist file into your Xcode project's root folder.

  • In the file inspector, make sure it's added to your target.

Step 3: Install Firebase SDK using CocoaPods​

We'll use CocoaPods to install the Firebase SDK. If you haven't installed CocoaPods, follow the official installation guide (https://cocoapods.org/).

Open a terminal and navigate to your project directory. Run the following commands:

cd your-project-directory
pod init

This will create a Podfile in your project directory. Edit the Podfile to include Firebase Firestore by adding this line:

pod 'Firebase/Firestore'

Save the Podfile and run:

pod install

Close your Xcode project and open the .xcworkspace file that CocoaPods generated.

Step 4: Install Firebase SDK via SPM​

Swift Package Manager (SPM) is an alternative to CocoaPods tool that can be used to manage dependencies in your iOS app projects.

To install Firebase SDK using SPM, open Xcode and navigate to "File" > "Add Packages."

In the prompt that appears, enter or paste the following Firebase GitHub repository URL:

https://github.com/firebase/firebase-ios-sdk.git

Select the specific version of Firebase you want to use. For new projects, it's recommended to use the latest version available.

Next, choose the FireStore library you want to include in your app.

Once you've made your selections, Xcode will start resolving your package dependencies.

Step 5: Initialize Firestore​

In your SwiftUI project, you'll need to initialize Firestore. Open your AppDelegate.swift file and add the following import statement at the top:

import Firebase

In the application(_:didFinishLaunchingWithOptions:) method, add the following code to initialize Firestore:

FirebaseApp.configure()

Step 6: Create a Firestore Data Model​

In this example, let's assume we want to build a simple task management app. Create a Swift file for your data model, e.g., Task.swift, and define the Task structure:

import FirebaseFirestoreSwift

struct Task: Identifiable, Codable {
@DocumentID var id: String?
var title: String
var description: String
var isCompleted: Bool
}

This data model represents a task with an optional ID, title, description, and completion status.

Step 7: Create a FirestoreManager​

Next, create a Swift file for your FirestoreManager, which will encapsulate Firestore operations:

import Firebase
import FirebaseFirestore

class FirestoreManager: ObservableObject {
private var db = Firestore.firestore()

@Published var tasks: [Task] = []

func fetchTasks() {
db.collection("tasks").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("Error fetching tasks: \(error?.localizedDescription ?? "Unknown error")")
return
}

self.tasks = documents.compactMap { queryDocumentSnapshot in
return try? queryDocumentSnapshot.data(as: Task.self)
}
}
}

func addTask(task: Task) {
do {
_ = try db.collection("tasks").addDocument(from: task)
} catch {
print("Error adding task: \(error.localizedDescription)")
}
}

func updateTask(task: Task) {
if let taskID = task.id {
do {
try db.collection("tasks").document(taskID).setData(from: task)
} catch {
print("Error updating task: \(error.localizedDescription)")
}
}
}

func deleteTask(task: Task) {
if let taskID = task.id {
db.collection("tasks").document(taskID).delete { error in
if let error = error {
print("Error removing task: \(error.localizedDescription)")
}
}
}
}
}

This FirestoreManager class includes functions for fetching tasks, adding tasks, updating tasks, and deleting tasks. It also publishes the tasks array as an @Published property, which SwiftUI can observe for updates.

Step 8: Create a SwiftUI Task List​

In your SwiftUI view, create a Task list view that displays tasks and allows users to add, edit, and delete tasks. Here's an example of how you might structure the view:

import SwiftUI

struct TaskListView: View {
@ObservedObject var firestoreManager = FirestoreManager()
@State private var newTaskTitle = ""
@State private var newTaskDescription = ""
var body: some View {
NavigationView {
List {
Section(header: Text("New Task")) {
TextField("Title", text: $newTaskTitle)
TextField("Description", text: $newTaskDescription)
Button("Add Task") {
let newTask = Task(title: newTaskTitle, description: newTaskDescription, isCompleted: false)
firestoreManager.addTask(task: newTask)
newTaskTitle = ""
newTaskDescription = ""
}
}

Section(header: Text("Tasks")) {
ForEach(firestoreManager.tasks) { task in
NavigationLink(destination: TaskDetailView(task: task)) {
Text(task.title)
}
}
.onDelete { indexSet in
let taskToDelete = firestoreManager.tasks[indexSet.first!]
firestoreManager.deleteTask(task: taskToDelete)
}
}
}
.navigationBarTitle("Task List")
.onAppear {
firestoreManager.fetchTasks()
}
}
}
}

struct TaskListView_Previews: PreviewProvider {
static var previews: some View {
TaskListView()
}
}

This TaskListView displays a list of tasks, allows users to add new tasks, and provides navigation to a TaskDetailView for editing individual tasks.

Step 9: Create a Task Detail View​

Create a SwiftUI view for editing individual tasks:

import SwiftUI

struct TaskDetailView: View {
@ObservedObject var firestoreManager = FirestoreManager()
var task: Task
@State private var isEditing = false
@State private var editedTask: Task
init(task: Task) {
self.task = task
self._editedTask = State(initialValue: task)
}

var body: some View {
Form {
Section {
if isEditing {
TextField("Title", text: $editedTask.title)
TextField("Description", text: $editedTask.description)
} else {
Text(editedTask.title)
.font(.largeTitle)
Text(editedTask.description)
}
}

Section {
Toggle("Completed", isOn: $editedTask.isCompleted)
}

Section {
if isEditing {
Button("Save Changes") {
firestoreManager.updateTask(task: editedTask)
isEditing.toggle()
}
Button("Cancel") {
editedTask = task
isEditing.toggle()
}
} else {
Button("Edit") {
isEditing.toggle()
}
}
}
}
.navigationBarTitle(isEditing ? "Edit Task" : "Task Detail")
}
}

struct TaskDetailView_Previews: PreviewProvider {
static var previews: some View {
TaskDetailView(task: Task(title: "Sample Task", description: "This is a sample task.", isCompleted: false))
}
}

This TaskDetailView allows users to edit task details and toggle the completion status. Changes are saved to Firestore when the "Save Changes" button is tapped.

Conclusion​

In this article, we covered the process of integrating Firestore with SwiftUI and demonstrated basic CRUD operations using Firestore in a SwiftUI application. Firestore is a versatile database service that can be used to build dynamic and interactive iOS apps, and SwiftUI makes it easier than ever to create beautiful and responsive user interfaces. You can extend this example to include more advanced features and improve the user experience of your app.

Happy coding!

Common Mistakes Developers Make When Developing iOS Apps in SwiftUI

Published: · Last updated: · 3 min read
Appxiom Team
Mobile App Performance Experts

SwiftUI, introduced by Apple in 2019, has revolutionized the way developers create user interfaces for iOS apps. It offers a declarative syntax, real-time previews, and a host of powerful features. While SwiftUI makes app development more accessible, it's not without its pitfalls.

In this blog post, we'll explore some common mistakes developers make when developing iOS apps in SwiftUI and how to avoid them.

1. Neglecting to Learn SwiftUI Fundamentals​

Mistake: Many developers rush into SwiftUI without adequately learning its fundamental concepts. SwiftUI requires a shift in mindset compared to UIKit, and neglecting to understand its core principles can lead to confusion and frustration.

Solution: Start with Apple's official SwiftUI tutorials and documentation. Take the time to understand concepts like Views, State, Binding, and ViewModifiers. Investing in a solid foundation will pay off in the long run.

struct ContentView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Counter: \(count)")
Button("Increment") {
count += 1
}
}
}
}

2. Using UIKit Elements in SwiftUI Views​

Mistake: Mixing UIKit elements (e.g., UIWebView, UILabel) with SwiftUI views can lead to layout issues and hinder the responsiveness of your app.

Solution: Whenever possible, use SwiftUI-native components. If you need to integrate UIKit elements, encapsulate them in UIViewRepresentable or UIViewControllerRepresentable wrappers to maintain SwiftUI compatibility.

import SwiftUI
import UIKit

struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> UIWebView {
let webView = UIWebView()
webView.loadRequest(URLRequest(url: url))
return webView
}

func updateUIView(_ uiView: UIWebView, context: Context) {
// Handle updates if needed
}
}

3. Overusing @State and Mutable State​

Mistake: Using @State for every piece of data can lead to a tangled web of mutable state, making it challenging to track and manage updates.

Solution: Be selective when using @State. Reserve it for view-specific state that should persist across view updates. For temporary or global data, consider using @StateObject, @ObservedObject, or @EnvironmentObject, depending on the scope of the data.

struct ContentView: View {
@State private var count = 0
@StateObject private var userData = UserData()

var body: some View {
VStack {
Text("Counter: \(count)")
Button("Increment") {
count += 1
}
// Use userData here
}
}
}

4. Ignoring Layout and Performance Optimization​

Mistake: SwiftUI abstracts many layout details, but ignoring them completely can result in poor performance and inconsistent user experiences.

Solution: Learn how SwiftUI handles layout and rendering by using tools like the frame modifier, GeometryReader, and ScrollViewReader. Optimize performance by using List for large datasets and paying attention to the use of .onAppear and .onDisappear modifiers.

List(items) { item in
Text(item.name)
.onAppear {
// Load additional data
// or perform actions when the item appears
}
}

5. Not Handling Error States and Edge Cases​

Mistake: Failing to anticipate error states, empty data scenarios, or edge cases can lead to crashes or confusing user experiences.

Solution: Always consider possible failure points in your app and handle them gracefully with error views, empty state placeholders, or informative alerts.

if let data = fetchData() {
// Display data
} else {
// Show error view or alert
}

Conclusion​

SwiftUI offers a powerful and modern way to build iOS apps, but like any technology, it comes with its share of possibilities to make common mistakes. By taking the time to understand SwiftUI's fundamentals, using native components, managing state wisely, optimizing layout and performance, and handling edge cases, you can avoid these pitfalls and create robust and responsive iOS apps that delight your users.

Remember, practice and continuous learning are key to mastering SwiftUI development.

Integrating and Using MapKit in SwiftUI iOS Apps

Published: · Last updated: · 4 min read
Appxiom Team
Mobile App Performance Experts

In the ever-evolving landscape of mobile app development, creating engaging and interactive experiences for users is essential. One powerful tool for achieving this is MapKit, Apple's framework for embedding maps and location services into your iOS applications.

In this blog post, we'll explore how to integrate and use MapKit in SwiftUI-based iOS apps to create dynamic and location-aware interfaces.

Prerequisites​

Before we dive into the integration process, make sure you have the following set up:

  • Xcode: Ensure you have the latest version of Xcode installed on your Mac.

  • Development Environment: Basic familiarity with SwiftUI and iOS app development concepts is assumed.

Integrating MapKit into SwiftUI​

To get started, follow these steps to integrate MapKit into your SwiftUI app:

Step 1: Create a New SwiftUI Project​

Open Xcode and create a new SwiftUI project. Give it a meaningful name and select appropriate settings for your project.

Step 2: Import MapKit​

In your project navigator, locate the ContentView.swift file and open it. Import the MapKit framework at the top of the file:

import SwiftUI
import MapKit

Step 3: Create Map View​

Replace the existing content of ContentView with a basic MapView that displays a map. Define a new struct called MapView:

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView()
}

func updateUIView(_ uiView: MKMapView, context: Context) {
// Update the view if needed
}
}

​

Step 4: Use the MapView in ContentView​

Replace the Text("Hello, world!") line in ContentView with your new MapView:

struct ContentView: View {
var body: some View {
MapView()
}
}

​

Step 5: Permissions and Privacy​

MapKit requires access to the user's location. Open the Info.plist file and add the following key to request location access:

<key>NSLocationWhenInUseUsageDescription</key><string>We need your location to display nearby points of interest.</string>

​

Step 6: Displaying User Location​

To display the user's location on the map, you'll need to add a few more lines to the MapView struct:

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.showsUserLocation = true // Display user's locationreturn mapView
}

func updateUIView(_ uiView: MKMapView, context: Context) {
// Update the view if needed
}
}

​

Customizing the MapView​

Now that you have a basic map view set up, you can start customizing it further to enhance the user experience.

Adding Annotations​

Annotations are points of interest you can add to the map. For instance, to add a pin at a specific coordinate, update the makeUIView function in the MapView struct:

func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()

let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
annotation.title = "San Francisco"
mapView.addAnnotation(annotation)

mapView.showsUserLocation = truereturn mapView
}

​

Changing Map Region​

By default, the map shows a specific region. You can customize this to focus on a particular area using the setRegion method:

func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()

let region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
mapView.setRegion(region, animated: true)

let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
annotation.title = "San Francisco"
mapView.addAnnotation(annotation)

mapView.showsUserLocation = truereturn mapView
}

​

Responding to Annotations​

You can provide interactivity to the annotations by implementing the MKMapViewDelegate methods. For instance, to show a callout when an annotation is tapped:

func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator

// Rest of the code remains the same// ...
}

class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard annotation is MKPointAnnotation else { return nil }

let identifier = "Annotation"var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)

if annotationView == nil {
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
annotationView?.canShowCallout = true
annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
} else {
annotationView?.annotation = annotation
}

return annotationView
}

// Other delegate methods can be implemented here
}

Remember to update the MapView struct to use this coordinator:

func makeCoordinator() -> Coordinator {
Coordinator()
}

​

Conclusion​

In this blog post, we explored the process of integrating and using MapKit in SwiftUI-based iOS apps. We covered the basics of creating a map view, displaying user location, adding annotations, customizing the map's appearance, and adding interactivity to annotations. With MapKit, you have the tools to create engaging and location-aware user experiences in your apps.

Feel free to further explore MapKit's capabilities and experiment with more advanced features to take your app to the next level.

Introduction to Using TipKit in SwiftUI Apps

Published: · Last updated: · 4 min read
Don Peter
Cofounder and CTO, Appxiom

TipKit is a new beta framework introduced in iOS 17 that helps developers present tips and instructions to users in their apps. With TipKit, you can easily create and manage tips, set rules for when and how they are displayed.

In this blog post, we will walk you through the steps of getting started with TipKit in your SwiftUI apps.

Steps to using TipKit​

1. Creating a Tip​

To use TipKit, you first need to import the TipKit framework into your project, along with the SwiftUI framework. Then, you can create a new Tip object and specify its content, title, and other properties.

import SwiftUI
import TipKit


struct FavoriteBookTip: Tip {

var title: Text {
Text("Add as Favorite book")
}


var message: Text? {
Text("Click on the fav icon to add the book to favourites")
}

}

For a tip to be valid, it is mandatory to set its title.

2. Adding Rules​

Next step is to add rules that must be met in order for the tip to be displayed. The rules property is an array of Predicate objects, each of which specifies a condition that must be met.

import SwiftUI
import TipKit


struct FavoriteBookTip: Tip {

var title: Text {
Text("Add as Favorite book")
}


var message: Text? {
Text("Click on the fav icon to add the book to favourites")
}

var rules: Predicate<RuleInput...> {
#Rule(Self.$isLoggedIn) { $0 == true }
}
}

In this case, the only rule is that the isLoggedIn property must be equal to true. This means that the tip will only be displayed if the user is logged in.

The #Rule syntax is used to create rules. In this case, the #Rule tag is used to create a rule that is based on the Self.$isLoggedIn property. The Self keyword refers to the current view, and the $isLoggedIn property is a property that gets the current user's login status.

3. Adding Tip to SwiftUI view​

import SwiftUI
import TipKit


struct FavoriteBookTip: Tip {


var title: Text {
Text("Add as Favorite book")
}


var message: Text? {
Text("Click on the fav icon to add the book to favourites")
}

var rules: Predicate<RuleInput...> {
#Rule(Self.$isLoggedIn) { $0 == true }
}
}


@main
struct BookTips: App {
var bookTip = FavoriteBookTip()


var body: some Scene {
WindowGroup {
VStack {
TipView(bookTip, arrowEdge: .bottom)

Image(systemName: "fav_icon")
.imageScale(.large)
Spacer()
}
}
}
}

TipView is a user interface element that represents an inline tip that is provided by TipKit. It displays a tip with an arrow that points to the bottom of the screen. The arrowEdge parameter specifies the edge of the screen where the arrow should point.

In this case, the arrowEdge parameter is set to .bottom, so the arrow will point to the bottom of the screen. TipView takes FavoriteBookTip object as its argument and displays the tip with an arrow that points to the bottom of the screen.

​

4. Configuring Tip using TipsCenter​

Once you have created a tip, you can configure TipsCenter.

TipsCenter is a key part of TipKit that provides several essential features. It allows tips and their associated events to persist between app launches, making it easier to test tips. A default shared instance of TipsCenter is provided, which is what I have added here.

TipsCenter.shared.configure(
displayFrequency: .daily
)

The displayFrequency property specifies how often the tip should be displayed. In this case, the tip will be displayed once per day.

Once you have created your tip and configured TipsCenter, you can display it using the following code when testing.

TipsCenter.shared.showTips([bookTip])

Use-cases of TipKit​

Here are a few examples of how you can use TipKit in your SwiftUI or UIKit based apps:

  • Display a tip when a user opens your app for the first time.

  • Show a tip when a user performs a specific action, such as taking a photo or adding a contact.

  • Give users tips on how to use your app's features.

  • Provide instructions on how to troubleshoot common problems.

​

Conclusion​

TipKit is a powerful new framework that can help you improve the user experience of your SwiftUI or UIKit based apps. By using TipKit, you can easily create and manage tips, set rules for when and how they are displayed, and track their effectiveness.

To make sure your tips are effective, keep them short, instructional, and actionable.

Introduction to State Management in SwiftUI: @State, @StateObject and @ObservedObject

Published: · Last updated: · 6 min read
Don Peter
Cofounder and CTO, Appxiom

SwiftUI is a powerful framework for building user interfaces for Apple devices. However, one of the challenges of using SwiftUI is managing state. State is the data that changes over time in your app, such as the current user's location or the contents of a shopping cart.

Using @State property wrapper in SwiftUI​

There are a few different ways to manage state in SwiftUI. The simplest way is to use the @State property wrapper. The @State property wrapper allows you to store a value that can be changed within a view. When the value changes, SwiftUI will automatically update the view.

For example, let's say we have a view that displays a counter. We can use the @State property wrapper to store the current value of the counter. When the user taps a button, we can increment the counter value and then update the view.

struct CounterView: View {
@State private var counter = 0

var body: some View {
Button("Increment") {
counter += 1
}
Text("\(counter)")
}
}

The @State property wrapper is a great way to manage simple state in SwiftUI.

However, some of the limitations of using @State are,

  • @State properties can only be used in structs. This means that you can't use @State properties in classes or enums.

  • @State properties can't be used to store complex objects. This means that you can't store objects that contain functions, closures, or other complex types in a @State property.

  • @State properties can't be changed from outside the view. This means that you can't change the value of a @State property from another view or from code that isn't part of the view hierarchy.

Using @StateObject and @ObservedObject​

The code below shows how to use the @StateObject and @ObservedObject property wrappers to manage state in SwiftUI.

The GameProgress class is an ObservableObject class. This means that it conforms to the ObservableObject protocol, which allows it to be observed by other views. The points property in the GameProgress class is marked with the @Published property wrapper.

This means that any changes to the value of the points property will be automatically published to any views that are observing it.

The ButtonView struct is a view that observes the progress property. The progress property is marked with the @ObservedObject property wrapper, which tells SwiftUI that the view should observe the value of the property and update itself whenever the value changes. The ButtonView struct has a button that increments the value of the points property. When the button is tapped, the points property is incremented and the InnerView struct is updated to reflect the change.

The ContentView struct is the main view of the app. It has a progress property that is an instance of the GameProgress class. The progress property is marked with the @StateObject property wrapper, which tells SwiftUI that the property is owned by the ContentView view. The ContentView struct has a VStack that contains two views: a Text view that displays the current points, and an ButtonView view that allows the user to increment the points.

class GameProgress: ObservableObject {
@Published var points = 0
}

struct ButtonView: View {
@ObservedObject var progress: GameProgress

var body: some View {
Button("Increase Points") {
progress.points += 1
}
}
}

struct ContentView: View {
@StateObject var progress = GameProgress()

var body: some View {
VStack {
Text("Your points are \(progress.points)")
ButtonView(progress: progress)
}
}
}

Here are some key takeaways from this code:

  • The @StateObject property wrapper is used to create an object that can be observed by other views.

  • The @Published property wrapper is used to mark a property in an ObservableObject class as being observable.

  • The @ObservedObject property wrapper is used to observe a property in an ObservableObject class from another view.

  • When the value of a property that is marked with the @Published property wrapper changes, the views that are observing the property will be updated automatically.

This is a simple example of how to use the @StateObject and @ObservedObject property wrappers to manage state in SwiftUI. In a more complex app, the GameProgress class would likely be responsible for managing more than just the points. It might also be responsible for fetching data from a server or interacting with other parts of the app.

Using @EnvironmentObject​

final class MyTheme: ObservableObject {
@Published var mainColor: Color = .purple
}

struct ThemeApp: App {
@StateObject var myTheme = MyTheme()

var body: some Scene {
WindowGroup {
ThemesListView()
.environmentObject(myTheme) // Make the theme available through the environment.
}
}
}

And the ThemesListView struct will be,

struct ThemesListView: View {

@EnvironmentObject var myTheme: Theme

Text("Text Title")
.backgroundColor(myTheme.mainColor)

}

The code is for a SwiftUI app that uses an environment object to share a theme between views. The theme object is a MyTheme class that conforms to the ObservableObject protocol. This means that the theme object can be observed by other views.

The ThemeApp struct is the main entry point for the app. It creates a myTheme property that is an instance of the MyTheme class. The myTheme property is marked with the @StateObject property wrapper, which means that it is owned by the ThemeApp struct.

The ThemeApp struct also has a body property that returns a WindowGroup. The WindowGroup contains an ThemesListView view. The ThemesListView view is a view that displays a list of themes.

The ThemesListView view uses the environmentObject modifier to access the myTheme property. This modifier tells SwiftUI to look for the myTheme property in the environment of the ThemesListView view. If the myTheme property is not found in the environment, then a new instance of the MyTheme class will be created.

The ThemesListView view uses the myTheme.mainColor property to set the color of the list items. This means that the color of the list items will be updated automatically whenever the mainColor property of the myTheme object changes.

Using an environment object is a simple and elegant solution. We only have to create the theme object once, and it will be available to all child views automatically. This makes our code easier to read and maintain.

Conclusion​

In this blog post, we have explored three different ways to manage state in SwiftUI. We have seen how to use the @State property wrapper to manage simple state, how to use the @StateObject and @ObservedObject property wrappers to manage complex state, and how to use environment objects to share state between views.

The best approach to use will depend on the specific needs of your app.