Revision notes · Bloc/Cubit → Observation

FlutterSwiftUI

A working Rosetta Stone for moving a Bloc/Cubit brain into modern SwiftUI — file layout, property wrappers, threading, and one complete production-style reference screen.

Target: iOS 17+ · @Observable Pattern: MVVM (ViewModel = Cubit) For: AppGenie iOS build Verified: Aug 2026
◈ Fact-check pass (Aug 2026) — what's drifted since these notes were first written

Everything in this document targets iOS 17+ / stable @Observable — that API surface is unchanged and safe to build on today. A few things moved around it since WWDC 2026 (June) that are worth knowing about before you publish or ship against them:

  • App Store submissions now require Xcode 26+ / iOS 26 SDK. Apple enforced this as a hard minimum starting April 28, 2026 — builds from older Xcode versions are rejected outright. If you're targeting the App Store today, build with Xcode 26 or later regardless of your app's deployment target.
  • iOS 26 shipped a system-wide redesign ("Liquid Glass"). Standard SwiftUI controls adopt it automatically with no code changes, but custom chrome (toolbars, tab bars, sheets) is worth a visual pass on iOS 26 before release.
  • SwiftData gained new APIs at WWDC26 — sectioned @Query(sectionBy:), @Attribute(.codable) for third-party types, and ResultsObserver/HistoryObserver for observing changes outside a View. These ship with iOS 27, which was still in public beta as of this writing — the @Query example in the Local Storage section below uses the stable iOS 17+ syntax on purpose.
  • Swift Testing (@Test / #expect) is now the default recommendation for new unit tests as of 2026, not just an alternative — confirmed current. XCTest remains required specifically for UI tests (XCUITest) and performance tests, which Swift Testing still doesn't cover.
  • String Catalogs (.xcstrings) are confirmed as the current standard for localization, unchanged since Xcode 15 — this notes set's Localization section reflects that.

Nothing above changes any of the code in this document — it's all still accurate. Treat this box as the "what to double-check before publishing" list rather than a correction log.

01 — Foundations

Mental model cheatsheet

If you understand Flutter's widget tree and reactivity, you already understand ~80% of SwiftUI. This is the direct term-for-term dictionary.

FlutterSwiftUI
Concept Flutter (Dart) SwiftUI (Swift)
Foundational building blockWidgetView (struct)
Render methodWidget build(context)var body: some View
Vertical stackColumn(children:[...])VStack { ... }
Horizontal stackRow(children:[...])HStack { ... }
Z-axis overlayStack(children:[...])ZStack { ... }
Recycled scroll listListView.builder()List { ForEach(...) }
Local UI stateStatefulWidget + setState()@State private var x
Extracted logic layerCubit<State>@Observable class ViewModel
Scoped providerBlocProvider(create:...)@State var model = ViewModel()
Deep tree accessBlocProvider.of<T>(context)@Environment(T.self) var model
Async executionFuture<T> / async-awaitTask { await ... }
Screen navigationNavigator.push() / GoRouterNavigationStack + NavigationLink
02 — Layout

Project structure

lib/ becomes conceptual "Groups" in Xcode. This is the standard, production-ready MVVM mapping.

Flutter — lib/
lib/
├── main.dart              # entry point
├── models/
│   └── todo_item.dart
├── cubits/                # logic layer
│   └── todo_cubit.dart
├── screens/
│   ├── profile_screen.dart
│   └── detail_screen.dart
└── assets/                # via pubspec.yaml
Xcode — YourAppName/
YourAppName/
├── YourAppNameApp.swift   # entry point
├── Models/
│   └── TodoItem.swift
├── ViewModels/            # logic layer
│   └── TodoViewModel.swift
├── Views/
│   ├── ProfileView.swift
│   └── DetailView.swift
└── Resources/
    └── Assets.xcassets
Swift Package Manager replaces pubspec.yaml. Add dependencies via File → Add Package Dependencies in Xcode — no text file to hand-edit.
03 — Getting started

New project creation

No CLI equivalent to memorize — Xcode's New Project wizard replaces flutter create.

FlutterSwiftUI
Flutter — terminal
flutter create my_app
cd my_app
flutter pub get
flutter run

# targeting a device
flutter devices
flutter run -d "iPhone 15"
Xcode — GUI wizard
File → New → Project
→ iOS → App
→ Interface: SwiftUI
→ Language: Swift
→ Storage: None / SwiftData
⌘R to run on Simulator
SettingFlutterSwiftUI (Xcode wizard)
App identifierapplicationId in build.gradle / Info.plist"Bundle Identifier" field at project creation
Min OS versionminSdkVersion / iOS deployment target in Podfile"Minimum Deployments" dropdown
Run on simulatorflutter run -d⌘R with a Simulator selected in the toolbar
Hot reloadAutomatic on save (r in terminal)SwiftUI #Preview canvas updates live; full rebuild for logic changes
App entry filelib/main.dartYourAppNameApp.swift — marked @main
YourAppNameApp.swift — the @main entry point
import SwiftUI

@main
struct YourAppNameApp: App {
    var body: some Scene {
        WindowGroup {
            ProfileView()   // like runApp(MyApp()) in main.dart
        }
    }
}
Explanation

@main marks this struct as the program's entry point — the direct equivalent of Dart's void main() => runApp(MyApp()). Conforming to App requires a body of type some Scene (not some View — a Scene describes a window, not a widget).

WindowGroup is the scene that hosts your window/UI hierarchy, roughly playing the role of MaterialApp/CupertinoApp at the root. ProfileView() inside it is your root screen, exactly like the home: widget you'd hand to MaterialApp.

No pubspec.yaml equivalent file. Bundle ID, version, permissions, and display name are configured through Xcode's project settings panes (General / Signing & Capabilities) and end up written into Info.plist — you rarely hand-edit that file directly.
04 — Dependencies

Packages & dependencies

pub.dev has one clear winner. Swift has three coexisting systems — know which one a library expects.

ConceptFlutterSwiftUI / Xcode
Package registrypub.devSwift Package Manager (SPM) — GitHub URLs, no central index
Manifest filepubspec.yamlPackage.swift / hidden Package.resolved lockfile
Install commandflutter pub getFile → Add Package Dependencies (paste repo URL)
Legacy alternativen/aCocoaPods — Podfile + pod install, still common in older/hybrid projects
Version pinning^1.2.0 in pubspec.yaml"Up to Next Major Version" rule set per-package in Xcode
Common HTTP packagehttp or dioUsually none needed — URLSession is built in
JSON parsing packagejson_serializableBuilt in — Codable protocol, no codegen step
pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0
  provider: ^6.1.0
  cached_network_image: ^3.3.0

dev_dependencies:
  flutter_test:
    sdk: flutter
Xcode → Add Package Dependencies
Paste repo URL, e.g.:

https://github.com/kean/Nuke
https://github.com/apple/swift-collections

Xcode resolves the version,
writes Package.resolved,
and links the target for you —
no manifest to hand-edit.
Most day-one Flutter dependencies (http, JSON codegen, state management) have no SwiftUI equivalent to installURLSession, Codable, and @Observable ship in the standard library.
05 — Reactivity

Property wrappers

SwiftUI coordinates state through dedicated wrappers instead of manual emit() / notifyListeners() calls.

@State

Local, owned value

Allocates a private, mutable memory cell owned by the view. Use for screen-isolated primitives — booleans, counters, text buffers. Always mark it private.

Flutter: a variable inside a StatefulWidget, mutated via setState().
@Binding

Reference back to a parent

Passes a direct read/write link to a parent's @State. Use it to build reusable leaf controls like custom switches or field inputs.

Flutter: passing a value together with an onChanged: (val) {} callback.
@Observable

The Cubit itself

A macro that upgrades a plain class into a reactive broadcaster. SwiftUI observes property reads and redraws only the views that touched a changed value.

Flutter: your Cubit<State> class — but no manual emit() required.
@Bindable

Two-way link into a class

Lets a subview generate $-prefixed two-way bindings directly to fields on an injected @Observable instance it doesn't own.

Flutter: passing an existing Cubit reference into a child widget so it can mutate fields directly.
Group

Invisible structural wrapper

Zero layout impact. Lets you attach modifiers to switch/if blocks, and bypasses the 10-child ViewBuilder compiler limit.

Flutter: returning an un-styled list of widgets without changing geometry.
@MainActor

Forces main-thread execution

Swift is multi-threaded by default — await can hop to background threads. Put this on ViewModels so mutations always land safely back on the UI thread.

Flutter: not needed — Dart's single-threaded event loop hides this for you.
Ownership rule of thumb@State private var model = ViewModel() creates and owns the instance. @Bindable var model: ViewModel only accepts a passed-in reference so it can mutate it.

Worked examples

@State — owns a primitive, redraws on setState-style mutation
ContentView.swift
struct ContentView: View {
    // Always mark it 'private' — it's screen-owned
    @State private var count = 0
    @State private var isPremiumUser = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Score: \(count)")
                .font(.largeTitle)

            Text(isPremiumUser ? "Premium Account" : "Standard Account")
                .foregroundColor(isPremiumUser ? .green : .gray)

            Button("Score point") {
                count += 1              // mutate directly, no setState()
            }
            .buttonStyle(.borderedProminent)

            Button("Toggle Membership") {
                isPremiumUser.toggle()
            }
            .buttonStyle(.bordered)
        }
        .padding()
    }
}
Explanation

@State private var count = 0 allocates a private, view-owned mutable cell — this is the direct analog of a field inside a StatefulWidget's State class. The private keyword isn't cosmetic: SwiftUI expects @State to be exclusively owned by the view that declares it, same as you'd never read another widget's private State fields.

The key difference from Flutter is in the button closures: count += 1 and isPremiumUser.toggle() mutate the value directly — there's no setState(() { ... }) wrapper. SwiftUI's property-wrapper machinery detects the write to a @State var automatically and schedules a re-render of exactly the parts of body that read it, so you get fine-grained rebuilds without ever calling something equivalent to notifyListeners().

@Binding — a child mutates a value it doesn't own
ParentView.swift + LightSwitchComponent.swift
// PARENT — owns the source of truth
struct ParentView: View {
    @State private var isLightOn = false

    var body: some View {
        VStack {
            Circle()
                .fill(isLightOn ? .yellow : .gray)
                .frame(width: 50, height: 50)

            // '$' passes a live reference down, not a copy
            LightSwitchComponent(isOn: $isLightOn)
        }
    }
}

// CHILD — doesn't own the data, just edits it for the parent
struct LightSwitchComponent: View {
    @Binding var isOn: Bool

    var body: some View {
        Button(isOn ? "Turn Off" : "Turn On") {
            isOn.toggle()   // this updates the PARENT's circle color
        }
    }
}
Explanation

This is the classic "controlled child" pattern. ParentView owns the real value with @State private var isLightOn = false — same as any StatefulWidget holding a boolean. When it hands that value to the child, it doesn't pass isLightOn directly; it passes $isLightOn. The $ prefix on any @State property produces a Binding<Bool> — a live, two-way reference, not a copy.

LightSwitchComponent declares @Binding var isOn: Bool, meaning "I don't own this value, but I can read and write it." When the child does isOn.toggle(), that mutation is written straight back into the parent's isLightOn, and the parent's Circle re-renders. In Flutter terms this is exactly passing a value down alongside an onChanged: (val) { setState(() => isLightOn = val); } callback — except @Binding collapses "the value" and "the callback that writes it" into a single object.

Flutter equivalent: passing a value down alongside an onChanged: (val) {} callback into a custom widget's constructor.

@Observable + @MainActor — the Cubit, thread-safe by default
TodoViewModel.swift
// @MainActor forces every mutation back onto the UI thread,
// even after an 'await' hops onto a background worker thread.
@MainActor
@Observable class TodoViewModel {
    var tasks: [TodoItem] = []

    func loadRemoteTasks() async {
        let fetchedData = await NetworkService.fetchData()  // background thread

        // Swift jumps back to Main automatically before this write —
        // no manual emit() or notifyListeners() needed.
        self.tasks = fetchedData
    }
}
Explanation

The comment at the top says it all: @MainActor is Swift's compiler-enforced guarantee that every property mutation on this class happens back on the UI thread, even after an await has hopped onto a background worker. Dart doesn't need this — its single-threaded event loop makes this a non-issue — but Swift's structured concurrency genuinely can run await continuations off the main thread, so ViewModels that touch UI state are annotated @MainActor as standard practice.

@Observable class TodoViewModel is your Cubit: var tasks: [TodoItem] is the state, and loadRemoteTasks() is a Cubit method. Notice there's no emit() call and no separate TodoState class — the macro instruments every stored property so that any SwiftUI view reading viewModel.tasks automatically subscribes to just that property, and self.tasks = fetchedData is the entire "emit new state" step.

Flutter equivalent: Dart's single-threaded event loop hides this problem entirely — @MainActor is Swift's explicit guardrail against threading crashes.

@Bindable — pattern 1: parent creates, child mutates

@State on the parent creates and owns the instance. @Bindable on the child accepts that same instance so it can generate two-way $ bindings back to it.

ParentDashboardView.swift + SettingsScreen.swift
struct ParentDashboardView: View {
    // 1. @State CREATES the object here — this view owns it
    @State private var globalCubit = ProfileViewModel()

    var body: some View {
        VStack {
            Text("Logged in as: \(globalCubit.username)")

            // 2. Pass the created instance down into the
            //    child view's constructor parameter
            SettingsScreen(viewModel: globalCubit)
        }
    }
}

struct SettingsScreen: View {
    // 3. @Bindable ACCEPTS the passed object here so you
    //    can write back to its properties from this screen
    @Bindable var viewModel: ProfileViewModel

    var body: some View {
        // 4. This directly mutates the string back in the
        //    PARENT view's memory — true two-way binding
        TextField("Change username", text: $viewModel.username)
    }
}
Explanation

This is the standard "who creates it, who edits it" split. ParentDashboardView declares @State private var globalCubit = ProfileViewModel() — the @State wrapper here is doing the same job as BlocProvider(create: (_) => ProfileCubit()): it constructs the instance and owns its lifetime, keeping it alive across re-renders of this view.

That instance is then passed down as a plain constructor argument: SettingsScreen(viewModel: globalCubit). Inside the child, @Bindable var viewModel: ProfileViewModel unlocks the ability to write $viewModel.username — a live two-way binding straight into the parent-owned object's property, which is what lets TextField("Change username", text: $viewModel.username) mutate the shared instance directly. The Flutter equivalent is handing an existing Cubit reference into a child widget's constructor so a controller/field inside it can call methods on that same Cubit instance.

Flutter equivalent: passing an existing Cubit instance down into a child widget's constructor so a text controller can mutate its fields directly.

@Bindable — pattern 2: binding straight into a Form

The most common shape in practice — a settings screen with several fields, all bound to one @Observable model.

ProfileViewModel.swift + SettingsScreen.swift
@Observable class ProfileViewModel {
    var username: String = "John"
    var notificationsEnabled: Bool = true
}

struct SettingsScreen: View {
    // Allows this screen to create direct '$' binding
    // properties to the model's fields
    @Bindable var viewModel: ProfileViewModel

    var body: some View {
        Form {
            // Without @Bindable above, writing $viewModel
            // here would fail to compile
            TextField("Edit Name", text: $viewModel.username)
            Toggle("Alerts", isOn: $viewModel.notificationsEnabled)
        }
    }
}
Explanation

Same @Bindable mechanism as the previous card, but this is the shape you'll actually reach for most often: a settings-style screen with several fields bound straight to one @Observable model, no intermediate local @State copies to keep in sync.

ProfileViewModel is a plain reactive class (your Cubit) with two properties. SettingsScreen receives it as @Bindable var viewModel, which is what makes $viewModel.username and $viewModel.notificationsEnabled legal inside the Form. Without @Bindable, the compiler rejects the $ prefix entirely — this is a compile-time guardrail, not a runtime warning, so a missing @Bindable shows up immediately rather than as a silent "my toggle doesn't update the model" bug the way a missed notifyListeners() call might in Flutter.

Compiler rule — a class passed into a subview is read-only for UI purposes by default. The moment a TextField or Toggle inside that subview needs to write back to it, the property must be marked @Bindable, or the build fails.
07 — Data

Networking & HTTP

URLSession + Codable replace http/dio + json_serializable — both are built into the standard library, no package install required.

FlutterSwiftUI
ConceptFlutterSwift
HTTP clienthttp / Dio packageURLSession — built in, no import needed
JSON model mappinghand-written fromJson/toJson or codegenCodable protocol — auto-synthesized, zero codegen
Decode a responsejsonDecode(res.body)JSONDecoder().decode(Type.self, from: data)
Error handlingtry/catch around http.get()do/catch around try await URLSession...
Loading images from URLImage.network() / cached_network_imageAsyncImage(url:) — built in, no caching by default
Interceptors / auth headersDio interceptorsURLRequest.setValue(_:forHTTPHeaderField:) per call, or a wrapping service

Worked examples

Flutter — http package
final res = await http.get(
  Uri.parse('https://api.example.com/user'),
);
if (res.statusCode == 200) {
  final json = jsonDecode(res.body);
  return User.fromJson(json);
} else {
  throw Exception('Failed to load');
}
SwiftUI — URLSession + Codable
let url = URL(string: "https://api.example.com/user")!
let (data, response) = try await URLSession.shared.data(from: url)

guard let http = response as? HTTPURLResponse,
      http.statusCode == 200 else {
    throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
Full example — Codable model + a NetworkService your ViewModel calls
User.swift — the model
// Codable = Decodable + Encodable, synthesized automatically
// as long as every property type is itself Codable.
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}
Explanation

struct User: Codable, Identifiable — conforming to Codable (which is really Decodable + Encodable combined) is all it takes to get JSON parsing for free, as long as every stored property is itself a Codable type (here: two Strings and an Int, all of which qualify automatically). The compiler synthesizes the parsing/serialization code at build time.

This replaces two separate things you'd normally reach for in Flutter: hand-writing User.fromJson(Map) / toJson() methods yourself, or running json_serializable + build_runner to generate a user.g.dart file. Here there's no generated file and no build step — Identifiable additionally gives the struct the id requirement that List/ForEach need to diff rows efficiently, similar to supplying a key: in a Flutter ListView.builder.

NetworkService.swift — a plain async function, no DI framework required
enum NetworkError: Error { case badResponse, decodingFailed }

struct NetworkService {
    static func fetchUser(id: Int) async throws -> User {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let http = response as? HTTPURLResponse,
              (200...299).contains(http.statusCode) else {
            throw NetworkError.badResponse
        }

        do {
            return try JSONDecoder().decode(User.self, from: data)
        } catch {
            throw NetworkError.decodingFailed
        }
    }
}
Explanation

enum NetworkError: Error is a lightweight custom error type — think of it like a small sealed class of failure reasons you'd throw from a Dart repository method. NetworkService is a plain struct with one static async function; there's no DI container or service locator involved, it's called directly as NetworkService.fetchUser(id:), similar to a static utility method or a top-level function in Dart.

try await URLSession.shared.data(from: url) is the built-in HTTP client — no http or dio package needed, and it's the direct equivalent of await http.get(uri). The guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { throw ... } block is a range-check on the status code (matching if (res.statusCode == 200), but tolerating the whole 2xx family) that throws instead of falling into an else branch. JSONDecoder().decode(User.self, from: data) is the jsonDecode() + User.fromJson() combo collapsed into one call, thanks to Codable.

UserViewModel.swift — calling it from your Cubit-equivalent
@MainActor
@Observable class UserViewModel {
    var user: User?
    var loadingState: FetchState = .idle

    func loadUser(id: Int) async {
        loadingState = .loading
        do {
            user = try await NetworkService.fetchUser(id: id)
            loadingState = .success
        } catch {
            loadingState = .error(message: "Couldn't load user profile.")
        }
    }
}
Explanation

This ViewModel is exactly your Cubit calling a repository method. var user: User? and var loadingState: FetchState together are the state — there's no separate immutable state class to construct and emit(); you assign these properties directly and @Observable notifies any view reading them.

loadingState = .loading then a do/catch around the network call mirrors emitting a loading state before an async call and a success/failure state after — the same three-phase dance you'd write with a Cubit's emit(state.copyWith(status: Loading)) pattern, just without the copyWith boilerplate since these are plain mutable vars on a reference-typed class.

No fromJson boilerplate. As long as a struct's properties are all Codable types (String, Int, Bool, arrays, other Codable structs...), conforming to Codable generates the parsing code for you — no build_runner, no generated .g.dart files.
08 — Persistence

Local storage

Three separate Flutter packages map to three built-in Apple frameworks — pick by data shape, not habit.

FlutterSwiftUI
Data shapeFlutterSwiftUI / Swift
Small key-value flagsSharedPreferencesUserDefaults.standard
Structured local databasesqflite / Hive / IsarSwiftData (iOS 17+) or Core Data (older targets)
Secrets / tokensflutter_secure_storageKeychain via Security framework
Files / cache directorypath_provider + dart:ioFileManager.default + URL.documentsDirectory
Reactive persisted stateHive box listener + ValueListenableBuilder@Query property wrapper (SwiftData) — auto-refreshes the view

Worked examples

Flutter — SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarded', true);
final seen = prefs.getBool('onboarded') ?? false;
SwiftUI — UserDefaults / @AppStorage
// direct API
UserDefaults.standard.set(true, forKey: "onboarded")
let seen = UserDefaults.standard.bool(forKey: "onboarded")

// or, INSIDE a View — auto-persisting @State
@AppStorage("onboarded") var onboarded = false
SwiftData — a persisted, queryable model (Hive/Isar equivalent)
Note.swift — the persisted model
import SwiftData

@Model
class Note {
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String) {
        self.title = title
        self.body = body
        self.createdAt = .now
    }
}
Explanation

import SwiftData plus the @Model macro on an otherwise ordinary class is what turns Note into a persisted, queryable database entity — this is the direct sibling of annotating a class for Hive (@HiveType) or Isar (@collection). No manual adapter registration or generated .g.dart file is required; the macro does the work at compile time.

The init is a completely normal Swift initializer — nothing SwiftData-specific about it. SwiftData is the framework recommended for iOS 17+ targets; on older deployment targets you'd reach for Core Data instead, which needs considerably more setup (an .xcdatamodeld file, generated NSManagedObject subclasses).

NotesListView.swift — @Query auto-refreshes on any change
struct NotesListView: View {
    // Like a Hive box listener, but the view redraws itself —
    // no manual ValueListenableBuilder wiring
    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
    @Environment(\.modelContext) private var context

    var body: some View {
        List(notes) { note in Text(note.title) }
            .toolbar {
                Button("Add") {
                    context.insert(Note(title: "Untitled", body: ""))
                }
            }
    }
}
Explanation

@Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note] is the standout line here: it's a property wrapper that both fetches and subscribes to the persisted store. Any insert, update, or delete to Note anywhere in the app causes this array — and therefore this view — to refresh automatically.

Think of it as a Hive box change-listener that's already wired up for you: normally you'd pair a box listener with a ValueListenableBuilder to get the UI to redraw on changes; @Query collapses both steps into one declaration. @Environment(\.modelContext) private var context is the handle used to write — context.insert(Note(...)) is the "add a record" call, comparable to box.add(note), and because the view is already subscribed via @Query, the list updates without any extra code on the write side.

@AppStorage behaves like @State that happens to survive app restarts — great for flags and small settings, not for lists of records. Reach for SwiftData once you're modeling real entities.
09 — Motion

Animations & gestures

SwiftUI animates value changes, not widget swaps — you wrap the mutation, not the container.

FlutterSwiftUI
ConceptFlutterSwiftUI
Implicit animationAnimatedContainer.animation(_:value:) modifier
Explicit / triggered animationAnimationController + TweenwithAnimation { ... } around a state mutation
Shared element transitionHero widget.matchedGeometryEffect(id:in:)
Tap gestureGestureDetector(onTap:).onTapGesture { }
Drag gestureGestureDetector(onPanUpdate:)DragGesture() + .gesture()
Physics-based springSpringSimulation.spring(response:dampingFraction:) — built into the curve type

Worked examples

Implicit vs explicit — the two animation styles
Implicit — like wrapping in AnimatedContainer
struct BadgeView: View {
    @State private var isActive = false

    var body: some View {
        Circle()
            .fill(isActive ? .green : .gray)
            .frame(width: isActive ? 60 : 40, height: isActive ? 60 : 40)
            // ANY change to a value this modifier watches gets animated
            .animation(.easeInOut(duration: 0.3), value: isActive)
            .onTapGesture { isActive.toggle() }
    }
}
Explanation

This is SwiftUI's implicit animation style — you don't choose an animation widget, you animate a value change. .frame(width: isActive ? 60 : 40, ...) computes a different value depending on state, and the single line .animation(.easeInOut(duration: 0.3), value: isActive) tells SwiftUI: "whenever isActive changes, animate every property upstream of this modifier that depends on it" — here, both the fill color and the frame size animate together automatically.

The nearest Flutter equivalent is wrapping the same subtree in an AnimatedContainer and letting it interpolate size/color changes for you — except in SwiftUI this works with any value-driven modifier (color, size, offset, corner radius, opacity...), not just the specific properties one particular animated widget exposes.

Explicit — like manually driving an AnimationController
Button("Add to cart") {
    // only the mutation inside this closure animates —
    // nothing else on screen is affected
    withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
        cart.items.append(product)
    }
}
Explanation

This is the explicit counterpart to the implicit example above — used when you want to animate one specific state mutation without touching a persistent modifier on the view. withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { cart.items.append(product) } wraps just the mutation; only the UI changes that result from cart.items.append(product) get animated, nothing else on screen is affected.

The Flutter analog is manually driving an AnimationController (.forward()) tied to a Tween — except here there's no controller object to create, dispose, or wire to a TickerProvider; withAnimation is a scoped, one-shot block, and the .spring(response:dampingFraction:) curve gives you physics-based motion (bounce and settle time) without hand-configuring a SpringSimulation.

Gestures — tap, long-press, and drag
Composable gesture recognizers
struct DraggableCard: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue.opacity(0.2))
            .frame(width: 160, height: 100)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in offset = value.translation }
                    .onEnded { _ in withAnimation(.spring) { offset = .zero } }
            )
            .onTapGesture { print("tapped") }
            .onLongPressGesture { print("long pressed") }
    }
}
Explanation

DragGesture() is a composable gesture recognizer, attached via .gesture(...) — this maps to GestureDetector(onPanUpdate:, onPanEnd:) in Flutter. .onChanged { value in offset = value.translation } fires continuously as the finger moves and writes straight into @State private var offset: CGSize, which is read back by .offset(offset) on the same view — a live feedback loop, similar to updating an offset inside setState() on every pan-update callback.

.onEnded { _ in withAnimation(.spring) { offset = .zero } } snaps the card back to its origin with spring physics once the drag ends — the explicit-animation pattern from the card above, applied here to a single reset. .onTapGesture and .onLongPressGesture chain onto the same view for the equivalent of onTap/onLongPress — SwiftUI lets you stack multiple gesture recognizers on one view without an explicit GestureDetector wrapper widget.

Mental shift: in Flutter you choose an animation widget. In SwiftUI you animate a value — the same modifier syntax works whether it's a color, a size, an offset, or a corner radius.
10 — Input

Forms & validation

No Form widget package to reach for — Form is a built-in container, and focus/keyboard control is its own property wrapper.

FlutterSwiftUI
ConceptFlutterSwiftUI
Form containerForm + GlobalKey<FormState>Form { } — grouped, styled sections built in
Field validationTextFormField(validator:)No built-in validator — check on submit or with .onChange(of:)
Keyboard typekeyboardType: TextInputType.emailAddress.keyboardType(.emailAddress)
Focus controlFocusNode + FocusScope@FocusState property wrapper
Secure fieldobscureText: trueSecureField("Password", text:)
Submit on return keyonFieldSubmitted:.onSubmit { }
A login form — focus chaining + inline validation
LoginView.swift
struct LoginView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var errorMessage: String?

    // like tracking which FocusNode currently has focus
    enum Field { case email, password }
    @FocusState private var focusedField: Field?

    var body: some View {
        Form {
            Section {
                TextField("Email", text: $email)
                    .keyboardType(.emailAddress)
                    .textInputAutocapitalization(.never)
                    .focused($focusedField, equals: .email)
                    .onSubmit { focusedField = .password }   // return key -> next field

                SecureField("Password", text: $password)
                    .focused($focusedField, equals: .password)
                    .onSubmit { submit() }
            }

            if let errorMessage {
                Text(errorMessage).foregroundStyle(.red).font(.caption)
            }

            Button("Sign In", action: submit)
        }
        .onAppear { focusedField = .email }   // auto-focus first field, like autofocus: true
    }

    private func submit() {
        guard email.contains("@") else {
            errorMessage = "Enter a valid email address"
            return
        }
        guard password.count >= 8 else {
            errorMessage = "Password must be at least 8 characters"
            return
        }
        errorMessage = nil
        // proceed with sign-in
    }
}
Explanation

Form { Section { ... } } is a built-in, pre-styled grouped list container — no separate package, unlike reaching for a custom ListView layout to mimic native settings-screen styling in Flutter. enum Field { case email, password } paired with @FocusState private var focusedField: Field? tracks which field currently has keyboard focus, the direct equivalent of a FocusNode per field plus a FocusScope to coordinate them.

.focused($focusedField, equals: .email) binds a specific field to that focus state, and .onSubmit { focusedField = .password } chains focus forward on the return key — like calling FocusScope.of(context).requestFocus(passwordNode) from a field's onFieldSubmitted. Crucially there's no validator: callback on TextField: validation is manual, done here inside submit() with plain guard statements that set errorMessage — the same logic a TextFormField(validator:) would run per-field automatically, just written by hand and checked at submit time instead.

No validator: callback. Where TextFormField validates per-field automatically, SwiftUI expects you to check values yourself — usually on submit, as shown above, or reactively with .onChange(of: email) if you want live inline errors as the person types.
11 — Design system

Theming & dark mode

No central ThemeData object — colors live in the Asset Catalog and adapt automatically per color scheme.

FlutterSwiftUI
ConceptFlutterSwiftUI
App-wide theme objectThemeData on MaterialAppNo single object — colors/fonts are referenced per-view or via Assets
Light/dark aware colorTheme.of(context).colorScheme.primaryNamed color set in Assets.xcassets ("Any/Dark" variants)
Read current modeMediaQuery.platformBrightnessOf(context)@Environment(\.colorScheme) var scheme
Force a modeThemeMode.dark on MaterialApp.preferredColorScheme(.dark)
Text style scaleTextTheme.font(.title), .headline — semantic Dynamic Type styles
Reusable custom styleExtension methods on BuildContextCustom ViewModifier + a .myStyle() extension on View

Worked examples

Reacting to dark mode without hardcoding colors
Reading the environment's color scheme
struct CardView: View {
    @Environment(\.colorScheme) private var scheme

    var body: some View {
        Text("Weekly summary")
            .padding()
            // "BrandSurface" is defined once in Assets.xcassets
            // with separate Any Appearance / Dark values —
            // this line never needs an if/else for dark mode
            .background(Color("BrandSurface"))
            .foregroundStyle(scheme == .dark ? .white : .black)
            .cornerRadius(14)
    }
}
Explanation

@Environment(\.colorScheme) private var scheme reads the current system appearance — this is the property-wrapper equivalent of calling MediaQuery.platformBrightnessOf(context), but resolved automatically and kept live without you subscribing to anything.

The more important line, though, is .background(Color("BrandSurface")): that color is defined once in the Asset Catalog with separate "Any Appearance" and "Dark" values, so this single call already adapts to dark mode with zero branching — closer to referencing Theme.of(context).colorScheme.surface than to hardcoding a color. scheme == .dark ? .white : .black shows the escape hatch for the rarer case where you genuinely need to branch on the mode explicitly rather than letting an asset-catalog color do it for you.

Reusable style — the ViewModifier pattern
A shared "card" look, applied like a BuildContext extension
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(16)
            .background(Color("BrandSurface"))
            .clipShape(RoundedRectangle(cornerRadius: 14))
            .shadow(radius: 4, y: 2)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

// usage anywhere:
Text("Total balance").cardStyle()
Explanation

ViewModifier is SwiftUI's mechanism for packaging a chain of modifiers (padding, background, clip shape, shadow) into one reusable, named unit — CardStyle here bundles exactly that "card" look. The extension View { func cardStyle() -> some View { modifier(CardStyle()) } } block is what makes Text("Total balance").cardStyle() read like a first-class modifier at the call site.

The closest Flutter idiom is an extension method on BuildContext or a small reusable wrapper widget that applies a consistent decoration — except here it composes directly into the modifier chain (.padding().background().cardStyle()) rather than nesting one more widget around your content. Reach for a ViewModifier any time you catch yourself repeating the same 3–4 modifiers across several views.

Define colors once, in Assets.xcassets. Give each color set both an "Any Appearance" and a "Dark" value there, then reference it by name (Color("BrandSurface")) everywhere — no if (isDark) branching scattered through the UI.
Verified Aug 2026: iOS 26 introduced a system-wide visual refresh ("Liquid Glass"). Standard SwiftUI components (buttons, sheets, toolbars, tab bars) adopt it automatically with zero code changes — worth a quick visual check on iOS 26 if you have heavily customized chrome, but nothing above needs rewriting for it.
12 — i18n

Localization

String Catalogs replace intl + .arb files — one JSON-backed .xcstrings file with a dedicated Xcode editor, no codegen step.

FlutterSwiftUI
ConceptFlutterSwiftUI / Swift
Translation packageintl + flutter gen-l10nBuilt in — no package, no codegen
Translation fileapp_en.arb, app_es.arb (one per locale)Localizable.xcstrings — one file, all locales, in Xcode's editor UI
Usage in a widgetAppLocalizations.of(context)!.greetingText("greeting") — SwiftUI resolves it implicitly
Usage outside a ViewSame accessor via contextString(localized: "greeting")
Pluralization rulesICU plural syntax in the .arb fileBuilt into the String Catalog editor per-key, per-locale
Auto-extraction from codeManual — you add every key to the .arb fileAutomatic — Xcode scans Text/Button/etc. on every build
Adding a String Catalog and using it
Flutter — one .arb per locale
lib/l10n/
  app_en.arb
  app_es.arb

// app_en.arb
{
  "greeting": "Hello, {name}!",
  "@greeting": {
    "placeholders": { "name": {} }
  }
}

// usage
Text(AppLocalizations.of(context)!
  .greeting(userName))
SwiftUI — one .xcstrings, all locales
Resources/
  Localizable.xcstrings   // JSON, all languages

// SwiftUI resolves the key automatically —
// no lookup call needed inside a View
Text("greeting \(userName)")

// outside a View (e.g. in a ViewModel)
let msg = String(localized: "greeting \(userName)")

Add languages from the "+" button inside the .xcstrings editor in Xcode — every key already extracted from your Text/Button views shows up ready for translation, no manual key registration.

Opting a string out of translation: use Text(verbatim: "SKU-4821") for values that should never be sent to translators — usernames, codes, raw data — the equivalent of simply not wrapping a string in AppLocalizations.
13 — Inclusive design

Accessibility

SwiftUI infers a lot from your layout already — most work is fixing a handful of ambiguous cases, not annotating every view.

FlutterSwiftUI
ConceptFlutterSwiftUI
Screen reader labelSemantics(label: '...').accessibilityLabel("...")
Extra context / hintSemantics(hint: '...').accessibilityHint("...")
Group children into one stopMergeSemantics().accessibilityElement(children: .combine)
Hide decorative contentExcludeSemantics().accessibilityHidden(true)
Dynamic Type / text scalingManual — scale with MediaQuery.textScaleFactorAutomatic for semantic fonts (.body, .title, etc.)
Announce a live changeSemanticsService.announce()AccessibilityNotification.Announcement
Test with a screen readerTalkBack (Android) / VoiceOver (iOS simulator)VoiceOver — ⌘F5 on the simulator/device
Merging a custom row into one VoiceOver stop
OrderRow.swift
struct OrderRow: View {
    let order: Order

    var body: some View {
        HStack {
            Text(order.title)
            Spacer()
            Text(order.status).foregroundStyle(.secondary)
        }
        // without this, VoiceOver reads the title and status
        // as two separate stops — merge them into one
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(order.title), \(order.status)")
        .accessibilityHint("Double tap to view order details")
    }
}
Explanation

Without any accessibility modifiers, VoiceOver would read this HStack's two Text views as two separate stops when swiping through the screen — annoying for anyone navigating by screen reader. .accessibilityElement(children: .combine) merges the whole row into a single stop, the direct equivalent of Flutter's MergeSemantics() wrapping the same row.

.accessibilityLabel(...) and .accessibilityHint(...) then explicitly control what gets announced and what extra context follows — matching Semantics(label: '...', hint: '...') in Flutter. The hint in particular ("Double tap to view order details") tells VoiceOver users the row is interactive, since a merged custom row otherwise gives no indication it's tappable the way a native list cell would.

Icon-only buttons are the most common miss. A Button containing only an SF Symbol has no readable label by default — always pair it with .accessibilityLabel("Delete") or VoiceOver announces nothing useful.
14 — System access

Permissions & push notifications

No runtime permission-request package — usage strings in Info.plist plus a per-framework request call. Push notifications follow the same pattern, then hand off to APNs.

FlutterSwiftUI
PermissionFlutterSwiftUI / Swift
Request flowpermission_handler packagePer-framework request call — no unified package
CameraPermission.camera.request()AVCaptureDevice.requestAccess(for: .video)
Photo libraryPermission.photos.request()PHPhotoLibrary.requestAuthorization(for:)
LocationPermission.location.request()CLLocationManager().requestWhenInUseAuthorization()
Push notificationsfirebase_messaging requestUNUserNotificationCenter.requestAuthorization(options:)
Declare the "why" textAndroidManifest.xml + Info.plist usage stringsInfo.plist keys only — e.g. NSCameraUsageDescription
Camera permission — the full request + check flow
CameraPermission.swift
import AVFoundation

enum CameraPermission {
    static func request() async -> Bool {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            return true
        case .notDetermined:
            return await AVCaptureDevice.requestAccess(for: .video)
        case .denied, .restricted:
            return false
        @unknown default:
            return false
        }
    }
}
Explanation

enum CameraPermission { static func request() async -> Bool { ... } } is a tiny namespaced utility — comparable to how you might wrap permission_handler's Permission.camera calls in a small helper class of your own. There's no unified cross-permission package in SwiftUI/Swift; each system framework (here, AVFoundation) has its own request API.

The switch AVCaptureDevice.authorizationStatus(for: .video) covers every possible state in one exhaustive statement — .authorized returns immediately, .notDetermined triggers the actual system prompt via await AVCaptureDevice.requestAccess(for: .video), and .denied/.restricted both resolve to false without showing anything (iOS won't re-prompt once denied — that matches permission_handler's behavior of only showing a dialog when status is genuinely undetermined). @unknown default future-proofs the switch against Apple adding a new case in a future SDK.

Push notifications, end to end

StepFlutterSwift
Register for remote notificationsFirebaseMessaging.instance.requestPermission()UIApplication.shared.registerForRemoteNotifications()
Get the device tokenFirebaseMessaging.instance.getToken()didRegisterForRemoteNotificationsWithDeviceToken delegate callback
Handle a tap on the notificationFirebaseMessaging.onMessageOpenedAppUNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:)
Foreground presentationHandled automatically by the pluginwillPresent delegate method — you choose banner/sound/badge explicitly
Backend deliveryFirebase Cloud Messaging (cross-platform)Direct APNs, or FCM if you're already using it for Android too
AppDelegate bridge — request permission, register, capture the token
PushNotificationManager.swift
import UIKit
import UserNotifications

@MainActor
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
    static let shared = PushNotificationManager()

    func requestAuthorization() async -> Bool {
        let center = UNUserNotificationCenter.current()
        center.delegate = self

        do {
            let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
            if granted {
                // must happen on the main thread — like firebase_messaging does internally
                UIApplication.shared.registerForRemoteNotifications()
            }
            return granted
        } catch {
            return false
        }
    }

    // called by AppDelegate once APNs hands back a token
    func didReceiveDeviceToken(_ deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        // send `token` to your backend, same as FirebaseMessaging.instance.getToken()
        print("APNs device token: \(token)")
    }

    // notification arrives while the app is in the FOREGROUND
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        [.banner, .sound, .badge]   // explicit — Flutter's plugin does this for you
    }

    // user TAPPED the notification (foreground, background, or terminated launch)
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        // route to the relevant screen, e.g. push.append(...) on your NavigationPath
        print("Notification tapped with payload: \(userInfo)")
    }
}
Explanation

final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate is a delegate-conforming singleton (static let shared) — the shape you'd use in place of a package like firebase_messaging, since push here talks directly to Apple's frameworks. @MainActor on the whole class keeps every callback safely on the UI thread.

requestAuthorization() is the permission-request step (FirebaseMessaging.instance.requestPermission()'s equivalent), and once granted it calls UIApplication.shared.registerForRemoteNotifications() to actually register with APNs. didReceiveDeviceToken(_:) is where you'd send the token to your backend, same job as FirebaseMessaging.instance.getToken(). The two delegate methods at the bottom are the part with no free equivalent: willPresent decides how a notification looks while the app is already open (Flutter plugins usually pick sane defaults for you; here you list [.banner, .sound, .badge] explicitly), and didReceive(response:) fires when the user taps it — your hook for routing to the relevant screen, comparable to FirebaseMessaging.onMessageOpenedApp.

YourAppNameApp.swift — wiring it in via AppDelegate adaptor
import SwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        PushNotificationManager.shared.didReceiveDeviceToken(deviceToken)
    }
}

@main
struct YourAppNameApp: App {
    // SwiftUI's App protocol has no native push-token delegate,
    // so it bridges to UIKit's AppDelegate for this one thing
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ProfileView()
                .task { _ = await PushNotificationManager.shared.requestAuthorization() }
        }
    }
}
Explanation

SwiftUI's App protocol has no built-in delegate method for "APNs handed you a device token" — that particular callback still only exists on the older UIKit AppDelegate API. @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate is the sanctioned bridge: it lets a small UIKit-style AppDelegate class run alongside your SwiftUI app just for the handful of lifecycle callbacks SwiftUI doesn't expose yet.

Think of this the same way you'd think about writing a small native platform-channel plugin in Flutter when a capability isn't wrapped by the framework layer — you drop one level down to the native API, then bridge the result back up. Here, didRegisterForRemoteNotificationsWithDeviceToken fires on the AppDelegate and forwards straight into PushNotificationManager.shared.didReceiveDeviceToken(deviceToken), keeping the actual push logic in one place. .task { ... requestAuthorization() } on the root scene kicks the whole flow off on launch.

Missing the Info.plist key = instant crash. Every permission needs a matching NS...UsageDescription string in Info.plist — if it's missing, iOS terminates the app the moment you request that permission, with no dialog shown at all.
Push notifications need real capabilities, not just code. Enable "Push Notifications" and "Background Modes → Remote notifications" under Signing & Capabilities in Xcode, and push testing only works on a physical device — the Simulator can't receive real APNs pushes (it can simulate local ones via a drag-and-drop .apns file).
15 — Foreground/background

App lifecycle

One environment value replaces the observer pattern — no subscribe/unsubscribe dance.

FlutterSwiftUI
ConceptFlutterSwiftUI
Observe app state changesWidgetsBindingObserver + didChangeAppLifecycleState@Environment(\.scenePhase)
States availableresumed / inactive / paused / detached.active / .inactive / .background
React to a transitionOverride the observer method, check the enum.onChange(of: scenePhase) { old, new in }
Background task (finish work after backgrounding)flutter_background_service / native platform channelBGTaskScheduler — register + schedule a BGAppRefreshTask
React to memory warningsdidHaveMemoryPressureUIApplication.didReceiveMemoryWarningNotification
Pausing work when the app backgrounds
RootView.swift
struct RootView: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var viewModel = SyncViewModel()

    var body: some View {
        ProfileView()
            .onChange(of: scenePhase) { oldPhase, newPhase in
                switch newPhase {
                case .active:
                    Task { await viewModel.resumeSync() }
                case .background:
                    viewModel.pauseSync()
                    viewModel.persistDraftState()
                case .inactive:
                    break   // brief transition state — e.g. control center pulled down
                @unknown default:
                    break
                }
            }
    }
}
Explanation

@Environment(\.scenePhase) private var scenePhase is a single environment value that replaces the observer-registration dance of WidgetsBindingObserver + overriding didChangeAppLifecycleState — no subscribe/unsubscribe pair to remember, and nothing to clean up in dispose().

.onChange(of: scenePhase) { oldPhase, newPhase in switch newPhase { ... } } fires on every transition with both the old and new value handed to you. The three cases map cleanly onto Flutter's states: .activeresumed (resume syncing here), .backgroundpaused (pause work and persist a draft, since the process may be suspended or killed at any point after this), and .inactive ≈ the brief transitional state (e.g. Control Center being pulled down) that usually needs no action, hence the empty break.

No separate "detached" state. SwiftUI's scenePhase only has three cases — when the app is force-quit or killed by the system, there's no callback at all, exactly like Flutter's detached state gives you almost nothing actionable either.
16 — Architecture

Dependency injection

No DI container package required for most apps — @Environment plus plain initializer injection covers the common cases.

FlutterSwiftUI
ConceptFlutterSwiftUI
DI container packageget_it / riverpod / providerUsually none — @Environment + initializers
App-wide singleton serviceGetIt.instance.registerSingleton()A plain static let shared, or injected via .environment()
Inject into a screenConstructor param, or context.read<T>()Constructor param, or @Environment(T.self)
Swap real vs. mock for testsRegister a fake in GetIt before the testPass a protocol-typed mock into the initializer directly
Scoped to one feature onlyProxyProvider further down the tree.environment() attached at a lower view, not the root
Protocol-based service + environment injection (the testable pattern)
UserServiceProtocol.swift — the abstraction, like an abstract repository
protocol UserServiceProtocol {
    func fetchUser(id: Int) async throws -> User
}

struct LiveUserService: UserServiceProtocol {
    func fetchUser(id: Int) async throws -> User {
        try await NetworkService.fetchUser(id: id)
    }
}

struct MockUserService: UserServiceProtocol {
    func fetchUser(id: Int) async throws -> User {
        User(id: id, name: "Test User", email: "test@example.com")
    }
}
Explanation

protocol UserServiceProtocol { func fetchUser(id: Int) async throws -> User } is Swift's interface mechanism — the direct sibling of an abstract Dart class (or a repository interface) that a Cubit depends on rather than a concrete implementation. async throws in the signature means any conforming type's implementation must also be async and can throw.

LiveUserService and MockUserService are two interchangeable concrete conformances: the real one calls NetworkService.fetchUser(id:) from the networking section above, and the fake one returns a canned User instantly with no network call at all — exactly the real-vs-fake repository pair you'd hand into a Cubit's constructor to make it unit-testable without hitting the network.

UserViewModel.swift — depends on the protocol, not the concrete type
@MainActor
@Observable class UserViewModel {
    private let userService: UserServiceProtocol
    var user: User?

    // constructor injection — same idea as passing a repository
    // into a Cubit's constructor in Flutter
    init(userService: UserServiceProtocol = LiveUserService()) {
        self.userService = userService
    }

    func load(id: Int) async {
        user = try? await userService.fetchUser(id: id)
    }
}

// production
let vm = UserViewModel()

// test — swap the dependency, no container needed
let testVM = UserViewModel(userService: MockUserService())
Explanation

This ViewModel depends on the UserServiceProtocol abstraction from the card above, not on LiveUserService directly — private let userService: UserServiceProtocol is typed as the protocol. init(userService: UserServiceProtocol = LiveUserService()) is constructor injection with a default argument: production code calls UserViewModel() and silently gets the real service, while a test calls UserViewModel(userService: MockUserService()) to swap it — the same idea as passing a repository into a Cubit's constructor, defaulted to the real implementation.

Notice there's no DI container anywhere in this snippet — no get_it/riverpod/provider equivalent needed for this pattern to work. @Environment exists for genuinely app-wide dependencies (a logged-in session, a theme), but for a single service like this, plain constructor injection is simpler to trace and easier to test.

Reach for @Environment only for things every screen might need — a logged-in user session, a theme, an analytics client. For everything else, plain constructor injection (as above) is simpler to trace and easier to test than routing it through the environment.
17 — Quality

Testing

Apple's newer Testing framework (Swift 6) reads a lot like flutter_testXCTest is still the safe default for wider tooling support.

FlutterSwiftUI
ConceptFlutterSwift
Unit test targettest/ + flutter_testYourAppNameTests/ + XCTest or Testing
Assertionexpect(value, equals(x))XCTAssertEqual(value, x) / #expect(value == x)
Async testtest('...', () async { await ... })func test...() async throws { ... }
UI / widget testtestWidgets() + WidgetTesterXCUITest — separate YourAppNameUITests target
Run testsflutter test⌘U in Xcode, or xcodebuild test

Worked example

Flutter — flutter_test
test('addNewTask adds a task', () {
  final cubit = TodoCubit();
  cubit.taskInputBuffer = 'Buy milk';
  cubit.addNewTask();
  expect(cubit.state.tasks.length, 1);
});
Swift — the new Testing framework
import Testing
@testable import YourAppName

@Test func addNewTaskAddsATask() {
    let vm = TodoViewModel()
    vm.taskInputBuffer = "Buy milk"
    vm.addNewTask()
    #expect(vm.tasks.count == 1)
}
@MainActor ViewModels need an async test. Since your ViewModel is pinned to the main actor, calling its methods from a test may require marking the test function async and awaiting the call — the compiler will tell you exactly where.
18 — Shipping

Release & deployment

This is the section furthest from Flutter's CI/CD model — no fastlane-only path, and Apple's tooling/timeline requirements shift more often than the language does.

FlutterSwiftUI
Verified Aug 2026 — current hard requirement: since April 28, 2026, Apple rejects any App Store build not compiled with Xcode 26 or later using the iOS 26 SDK — regardless of your app's actual deployment target. If you're building against an older Xcode toolchain in CI, upload will fail outright, not just get flagged in review.
ConceptFlutterSwiftUI / Xcode
Build for releaseflutter build ipaProduct → Archive in Xcode, or xcodebuild archive
SigningFastlane match, or manual provisioning profilesAutomatic signing in Xcode, or Fastlane match — same tool works for both
Beta distributionFirebase App Distribution / TestFlight (via Codemagic etc.)TestFlight — builds expire after 90 days
CI/CDCodemagic, GitHub Actions + custom runnersXcode Cloud (native, 25 free compute hrs/mo) or Fastlane + GitHub Actions on a macOS runner
Store listing configGoogle Play Console + App Store Connect (two consoles)App Store Connect only
Privacy declarationsPlay Data Safety formApp Privacy "nutrition label" + a Privacy Manifest per third-party SDK
Review turnaroundGoogle Play — hours to a few daysApple App Review — ~90% within 24 hours as of 2026, longer for complex/flagged apps

The submission checklist

  1. 01
    Enroll in the Apple Developer Program$99/year. Individual accounts use your legal name; organization accounts need a D-U-N-S number and show a company name on the Store.
  2. 02
    Register a Bundle ID and create the App RecordIn App Store Connect — this is the one-time setup step that minor updates skip.
  3. 03
    Build with the current required Xcode/SDKXcode 26+ / iOS 26 SDK as of this writing (see the verified box above) — check developer.apple.com/news for the current baseline before every release, since it moves roughly once a year.
  4. 04
    Archive and upload the same build to TestFlight firstTest the exact binary you intend to ship, not a separate debug build — this matches uploading a signed release APK/AAB to Play's internal track before promoting it.
  5. 05
    Declare API usage in your Privacy ManifestAny "required reason" API your app or its third-party SDKs touch (file timestamps, UserDefaults, disk space, etc.) needs an approved reason code — a common, fully automatic rejection cause if skipped.
  6. 06
    Fill in App Privacy details and the age rating questionnaireBoth are checked against your actual app behavior during review, not just taken at face value.
  7. 07
    Submit for reviewMost builds clear within 24 hours; expedited review exists for critical fixes but isn't guaranteed.
Fastlane — the one CI/CD tool that works the same for both stacks

If your AppGenie pipeline already runs Fastlane for Android builds, the iOS half of that setup carries over almost unchanged — match for signing, pilot for TestFlight, deliver for App Store Connect metadata.

fastlane/Fastfile
lane :beta do
  match(type: "appstore")                 # pulls signing certs from your shared repo
  build_app(scheme: "YourAppName")        # archives with the project's current Xcode
  upload_to_testflight(skip_waiting_for_build_processing: true)
end

lane :release do
  match(type: "appstore")
  build_app(scheme: "YourAppName")
  upload_to_app_store(
    submit_for_review: true,
    automatic_release: false              # you flip the switch manually after review
  )
end
Explanation

This is the one tool in the whole document that's identical on both sides — if your AppGenie pipeline already runs Fastlane for the Android/Flutter build, this Fastfile slots in as the iOS half of the same setup, using the same Ruby-ish DSL and step functions (match, build_app, upload_to_testflight, upload_to_app_store) you may already recognize.

lane :beta do ... end and lane :release do ... end are named, runnable pipelines (fastlane beta / fastlane release from the CLI). match(type: "appstore") pulls signing certificates from a shared repo instead of managing provisioning profiles by hand; build_app(scheme:) archives the project; upload_to_testflight/upload_to_app_store ship the resulting build — automatic_release: false on the release lane means the build reaches App Store Connect but a human still flips the switch to actually publish it after review.

TestFlight builds expire after 90 days. A build sitting untested for three months silently stops being installable — worth a calendar reminder if a beta cycle stalls, since testers won't get an obvious error, the build just disappears as an option.
19 — Concurrency

Async & the Task bridge

Button actions in SwiftUI are strictly synchronous closures — you can't mark one async directly. Task {} is the bridge into an async context.

Flutter
onPressed: () async { await cubit.fetch(); }

Dart lets you mark the callback itself as async — no wrapper needed.

SwiftUI
Button("Load") { Task { await vm.fetch() } }

Swift needs an explicit sandbox to open the async lane inside a synchronous closure.

ProfileView.swift — auto-fire on appear
struct ProfileView: View {
    @State private var viewModel = ProfileViewModel()

    var body: some View {
        Text("Profile Screen Layout")
            .task {
                // Fires automatically on appear.
                // Auto-cancels if the user navigates away mid-request.
                await viewModel.fetchProfileData()
            }
    }
}
Explanation

.task { await viewModel.fetchProfileData() } is SwiftUI's "run this async work when the view appears" modifier — the closest match is firing a Cubit's initial load inside initState(). The difference is that .task also gets cancellation for free: if the user navigates away while the request is still in flight, the underlying Task is cancelled automatically, no manual bookkeeping required.

This is also the standard way to invoke an async function at all inside a SwiftUI view body, since — unlike Dart — a view's lifecycle hooks aren't async themselves; .task {} is one of the few places (along with wrapping a button action in Task {}) where SwiftUI opens an async context for you automatically.

.task { } is the closest match to firing a Cubit's initial load inside a screen's initState() — except it also handles cancellation for free.

20 — Lifecycle

Memory & lifecycle

No more manual bloc.close(). Swift's ARC handles teardown automatically the moment a view leaves the navigation stack.

BehaviorFlutter (Dart)SwiftUI (Swift)
Memory modelGarbage Collector (GC)Automatic Reference Counting (ARC)
Cleanup triggerManual bloc.close()Automatic — pointer count hits zero on pop
Screen mountinitState().onAppear { }
Screen unmountdispose().onDisappear { }
Explicit destructorn/adeinit { }
Use deinit {} only to verify teardown or clean up persistent items like open sockets and tracking loops — not for routine state cleanup, which ARC already handles.
21 — Tooling

File types in an Xcode project

Relevant if bridging into an existing Objective-C codebase (e.g. a legacy UIKit module) rather than a pure SwiftUI app.

File typeExtensionUsed for
SwiftUI view.swiftLayout + logic in one file — live Canvas preview
Storyboard.storyboardLegacy visual XML layout — still standard for Objective-C/UIKit
XIB.xibSingle-screen visual layout, same era as storyboards
Objective-C.h / .mHeader + implementation — requires UIKit, not SwiftUI-compatible directly

Storyboards persist mainly in large legacy or Objective-C-only codebases — merge conflicts on the generated XML and slower Xcode load times are the usual reasons teams migrate off them.

22 — Worked example

Full code reference

One cohesive, production-style screen: model, @Observable ViewModel, async loading states, a recycled list with swipe-to-delete, and routed navigation.

TodoItem.swift & FetchState — data layer
Models
struct TodoItem: Identifiable {
    let id = UUID()          // auto-managed row-tracking id
    let title: String
    var isCompleted: Bool
}

/// Maps directly to a Bloc/Cubit state enum.
enum FetchState {
    case idle
    case loading
    case success
    case error(message: String)   // associated value, like a Dart sealed class field
}
Explanation

The data layer for the full worked reference screen. struct TodoItem: Identifiable mirrors a plain Dart model class, except let id = UUID() auto-generates a unique row identifier at construction time — comparable to assigning a key: yourself for a ListView.builder, except SwiftUI's List/ForEach require Identifiable conformance (or an explicit id: keypath) to diff rows efficiently, so this is done once, here.

enum FetchState is the state-machine enum this whole reference screen branches on — .idle / .loading / .success / .error(message: String) is the direct equivalent of a Dart sealed class (or a Freezed union) representing a Cubit's possible states, with .error carrying an associated message value the same way a sealed class's error variant would carry a field.

TodoViewModel.swift — the Cubit
ViewModel · @MainActor + @Observable
@MainActor
@Observable class TodoViewModel {
    var tasks: [TodoItem] = []
    var loadingState: FetchState = .idle
    var taskInputBuffer: String = ""

    func loadRemoteTasks() async {
        loadingState = .loading

        do {
            try await Task.sleep(nanoseconds: 1_200_000_000) // simulated network delay

            tasks = [
                TodoItem(title: "Learn SwiftUI Basics", isCompleted: true),
                TodoItem(title: "Master State & ViewModels", isCompleted: true),
                TodoItem(title: "Build Scrollable Lists & Feeds", isCompleted: false),
                TodoItem(title: "Implement Navigation Architecture", isCompleted: false)
            ]
            loadingState = .success
        } catch {
            loadingState = .error(message: "Failed to download cloud records.")
        }
    }

    func addNewTask() {
        guard !taskInputBuffer.trimmingCharacters(in: .whitespaces).isEmpty else { return }
        tasks.append(TodoItem(title: taskInputBuffer, isCompleted: false))
        taskInputBuffer = ""
    }

    func deleteItems(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
    }

    deinit {
        print("ViewModel cleared from memory — zero manual close() calls.")
    }
}
Explanation

This is the complete Cubit-equivalent for the reference screen: @MainActor @Observable class TodoViewModel holds three plain var properties as its entire state — tasks, loadingState, and taskInputBuffer — with no separate immutable state object and no emit() calls anywhere.

loadRemoteTasks() simulates a network round trip with try await Task.sleep(nanoseconds: 1_200_000_000) — Swift's equivalent of await Future.delayed(Duration(milliseconds: 1200)) — then flips loadingState through the same .loading → .success/.error sequence you'd see in a real Cubit method. addNewTask() and deleteItems(at:) mutate tasks directly (array .append / .remove(atOffsets:)) rather than rebuilding a new immutable list via copyWith. Finally, deinit { print(...) } is a destructor that fires the instant ARC drops the last reference to this ViewModel — proof there's no manual bloc.close() step to remember; it just happens.

ProfileView.swift — root screen
Root view · state machine + list + form
struct ProfileView: View {
    @State private var viewModel = TodoViewModel()

    var body: some View {
        NavigationStack {
            VStack {
                // --- input row ---
                VStack(spacing: 8) {
                    @Bindable var bindableModel = viewModel
                    HStack {
                        TextField("Create a new task...", text: $bindableModel.taskInputBuffer)
                            .textFieldStyle(.roundedBorder)
                        Button(action: { viewModel.addNewTask() }) {
                            Image(systemName: "plus.circle.fill").font(.title2)
                        }
                    }
                }
                .padding()

                // --- state machine (BlocBuilder equivalent) ---
                Group {
                    switch viewModel.loadingState {
                    case .idle:
                        ContentUnavailableView("No Sync Active",
                            systemImage: "tray.and.arrow.down",
                            description: Text("Trigger sync below."))

                    case .loading:
                        ProgressView("Loading...").padding()

                    case .error(let message):
                        ContentUnavailableView("Connection Failed",
                            systemImage: "wifi.exclamationmark",
                            description: Text(message))
                            .foregroundColor(.red)

                    case .success:
                        List {
                            ForEach(viewModel.tasks.indices, id: \.self) { index in
                                HStack {
                                    Button {
                                        withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) {
                                            viewModel.tasks[index].isCompleted.toggle()
                                        }
                                    } label: {
                                        Image(systemName: viewModel.tasks[index].isCompleted
                                              ? "checkmark.circle.fill" : "circle")
                                            .foregroundColor(viewModel.tasks[index].isCompleted ? .green : .gray)
                                    }
                                    .buttonStyle(.plain)

                                    NavigationLink(destination: TodoItemDetailView(todoItem: viewModel.tasks[index])) {
                                        Text(viewModel.tasks[index].title)
                                            .strikethrough(viewModel.tasks[index].isCompleted)
                                    }
                                }
                            }
                            .onDelete { viewModel.deleteItems(at: $0) }
                        }
                    }
                }
                .animation(.easeInOut(duration: 0.25), value: viewModel.tasks.count)

                Button(action: { Task { await viewModel.loadRemoteTasks() } }) {
                    Label("Sync Core Database", systemImage: "arrow.clockwise")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .padding()
            }
            .navigationTitle("Dashboard")
        }
    }
}

struct TodoItemDetailView: View {
    let todoItem: TodoItem
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: todoItem.isCompleted ? "doc.plaintext.fill" : "doc.plaintext")
                .font(.system(size: 60))
                .foregroundColor(todoItem.isCompleted ? .green : .orange)
            Text(todoItem.title).font(.title).bold()
            Spacer()
        }
        .padding(.top, 50)
        .navigationTitle("Verification Tracker")
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    ProfileView()
}
Explanation

The root screen tying the whole reference example together. @Bindable var bindableModel = viewModel is a small but important trick: viewModel itself is @State-owned (created once by this view), but to get $-prefixed two-way bindings into its properties for the TextField, it's re-wrapped locally as @Bindable right where it's needed — same instance, just viewed through a different property wrapper for binding purposes.

Group { switch viewModel.loadingState { ... } } is the BlocBuilder-equivalent state machine: each case of FetchState renders different UI — an empty state, a spinner, an error view, or the real list — exactly like a Flutter BlocBuilder's builder callback switching on the current state. Inside .success, ForEach(viewModel.tasks.indices, id: \.self) iterates by index rather than by value, because ForEach loops normally hand you copies of value-type structs — iterating indices is what lets viewModel.tasks[index].isCompleted.toggle() actually write back into the source array. .onDelete { viewModel.deleteItems(at: $0) } wires up native swipe-to-delete, the built-in equivalent of a Dismissible wrapping each row in a ListView.builder. NavigationLink(destination: TodoItemDetailView(...)) at the bottom pushes a detail screen per row using the inline-destination pattern from the navigation section.

23 — Checklist

6 translation rules

Keep these in your head while porting any Bloc/Cubit screen over.

  1. 01
    The ViewModel is your Cubit.No separate State classes — properties on the @Observable class are the state; methods are your Cubit methods.
  2. 02
    SwiftUI is your BlocBuilder.Reading viewModel.tasks.count inside a view body auto-creates a targeted observer — no wrapper widget needed.
  3. 03
    No manual disposes.ARC drops the ViewModel from memory the instant a screen leaves the navigation stack.
  4. 04
    Use Task {} for async hooks.Button closures are synchronous — wrap async calls explicitly, or use .task {} for auto-fire-on-appear.
  5. 05
    Declare navigation tree-side.Instead of Navigator.push() in a callback, wrap the destination inline: NavigationLink(destination:) { ... }.
  6. 06
    Use indices to mutate list items.ForEach loops pass copies — iterate .indices when you need direct array write-back.