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
        }
    }
}
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()
    }
}
@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
        }
    }
}

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
    }
}

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)
    }
}

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)
        }
    }
}
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
}
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
        }
    }
}
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.")
        }
    }
}
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
    }
}
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: ""))
                }
            }
    }
}
@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() }
    }
}
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)
    }
}
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") }
    }
}
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
    }
}
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)
    }
}
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()
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")
    }
}
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
        }
    }
}

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)")
    }
}
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() }
        }
    }
}
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
                }
            }
    }
}
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")
    }
}
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())
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
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()
            }
    }
}

.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
}
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.")
    }
}
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()
}
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.