Flutter⇄SwiftUI
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.
◈ 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, andResultsObserver/HistoryObserverfor observing changes outside a View. These ship with iOS 27, which was still in public beta as of this writing — the@Queryexample 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.XCTestremains 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.
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.
| Concept | Flutter (Dart) | SwiftUI (Swift) |
|---|---|---|
| Foundational building block | Widget | View (struct) |
| Render method | Widget build(context) | var body: some View |
| Vertical stack | Column(children:[...]) | VStack { ... } |
| Horizontal stack | Row(children:[...]) | HStack { ... } |
| Z-axis overlay | Stack(children:[...]) | ZStack { ... } |
| Recycled scroll list | ListView.builder() | List { ForEach(...) } |
| Local UI state | StatefulWidget + setState() | @State private var x |
| Extracted logic layer | Cubit<State> | @Observable class ViewModel |
| Scoped provider | BlocProvider(create:...) | @State var model = ViewModel() |
| Deep tree access | BlocProvider.of<T>(context) | @Environment(T.self) var model |
| Async execution | Future<T> / async-await | Task { await ... } |
| Screen navigation | Navigator.push() / GoRouter | NavigationStack + NavigationLink |
Project structure
lib/ becomes conceptual "Groups" in Xcode. This is the standard, production-ready MVVM mapping.
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
YourAppName/
├── YourAppNameApp.swift # entry point
├── Models/
│ └── TodoItem.swift
├── ViewModels/ # logic layer
│ └── TodoViewModel.swift
├── Views/
│ ├── ProfileView.swift
│ └── DetailView.swift
└── Resources/
└── Assets.xcassets
pubspec.yaml. Add dependencies via File → Add Package Dependencies in Xcode — no text file to hand-edit.New project creation
No CLI equivalent to memorize — Xcode's New Project wizard replaces flutter create.
flutter create my_app cd my_app flutter pub get flutter run # targeting a device flutter devices flutter run -d "iPhone 15"
File → New → Project → iOS → App → Interface: SwiftUI → Language: Swift → Storage: None / SwiftData ⌘R to run on Simulator
| Setting | Flutter | SwiftUI (Xcode wizard) |
|---|---|---|
| App identifier | applicationId in build.gradle / Info.plist | "Bundle Identifier" field at project creation |
| Min OS version | minSdkVersion / iOS deployment target in Podfile | "Minimum Deployments" dropdown |
| Run on simulator | flutter run -d | ⌘R with a Simulator selected in the toolbar |
| Hot reload | Automatic on save (r in terminal) | SwiftUI #Preview canvas updates live; full rebuild for logic changes |
| App entry file | lib/main.dart | YourAppNameApp.swift — marked @main |
import SwiftUI
@main
struct YourAppNameApp: App {
var body: some Scene {
WindowGroup {
ProfileView() // like runApp(MyApp()) in main.dart
}
}
}@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.
Info.plist — you rarely hand-edit that file directly.Packages & dependencies
pub.dev has one clear winner. Swift has three coexisting systems — know which one a library expects.
| Concept | Flutter | SwiftUI / Xcode |
|---|---|---|
| Package registry | pub.dev | Swift Package Manager (SPM) — GitHub URLs, no central index |
| Manifest file | pubspec.yaml | Package.swift / hidden Package.resolved lockfile |
| Install command | flutter pub get | File → Add Package Dependencies (paste repo URL) |
| Legacy alternative | n/a | CocoaPods — 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 package | http or dio | Usually none needed — URLSession is built in |
| JSON parsing package | json_serializable | Built in — Codable protocol, no codegen step |
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
provider: ^6.1.0
cached_network_image: ^3.3.0
dev_dependencies:
flutter_test:
sdk: flutter
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.
http, JSON codegen, state management) have no SwiftUI equivalent to install — URLSession, Codable, and @Observable ship in the standard library.Property wrappers
SwiftUI coordinates state through dedicated wrappers instead of manual emit() / notifyListeners() calls.
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.
StatefulWidget, mutated via setState().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.
onChanged: (val) {} callback.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.
Cubit<State> class — but no manual emit() required.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.
Invisible structural wrapper
Zero layout impact. Lets you attach modifiers to switch/if blocks, and bypasses the 10-child ViewBuilder compiler limit.
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.
@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▸
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()
}
}@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▸
// 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
}
}
}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▸
// @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
}
}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.
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)
}
}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.
@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)
}
}
}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.
TextField or Toggle inside that subview needs to write back to it, the property must be marked @Bindable, or the build fails.Networking & HTTP
URLSession + Codable replace http/dio + json_serializable — both are built into the standard library, no package install required.
| Concept | Flutter | Swift |
|---|---|---|
| HTTP client | http / Dio package | URLSession — built in, no import needed |
| JSON model mapping | hand-written fromJson/toJson or codegen | Codable protocol — auto-synthesized, zero codegen |
| Decode a response | jsonDecode(res.body) | JSONDecoder().decode(Type.self, from: data) |
| Error handling | try/catch around http.get() | do/catch around try await URLSession... |
| Loading images from URL | Image.network() / cached_network_image | AsyncImage(url:) — built in, no caching by default |
| Interceptors / auth headers | Dio interceptors | URLRequest.setValue(_:forHTTPHeaderField:) per call, or a wrapping service |
Worked examples
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');
}
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▸
// 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
}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.
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
}
}
}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.
@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.")
}
}
}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.
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.Local storage
Three separate Flutter packages map to three built-in Apple frameworks — pick by data shape, not habit.
| Data shape | Flutter | SwiftUI / Swift |
|---|---|---|
| Small key-value flags | SharedPreferences | UserDefaults.standard |
| Structured local database | sqflite / Hive / Isar | SwiftData (iOS 17+) or Core Data (older targets) |
| Secrets / tokens | flutter_secure_storage | Keychain via Security framework |
| Files / cache directory | path_provider + dart:io | FileManager.default + URL.documentsDirectory |
| Reactive persisted state | Hive box listener + ValueListenableBuilder | @Query property wrapper (SwiftData) — auto-refreshes the view |
Worked examples
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarded', true);
final seen = prefs.getBool('onboarded') ?? false;
// 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)▸
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
}
}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).
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: ""))
}
}
}
}@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.
SwiftData once you're modeling real entities.Animations & gestures
SwiftUI animates value changes, not widget swaps — you wrap the mutation, not the container.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Implicit animation | AnimatedContainer | .animation(_:value:) modifier |
| Explicit / triggered animation | AnimationController + Tween | withAnimation { ... } around a state mutation |
| Shared element transition | Hero widget | .matchedGeometryEffect(id:in:) |
| Tap gesture | GestureDetector(onTap:) | .onTapGesture { } |
| Drag gesture | GestureDetector(onPanUpdate:) | DragGesture() + .gesture() |
| Physics-based spring | SpringSimulation | .spring(response:dampingFraction:) — built into the curve type |
Worked examples
Implicit vs explicit — the two animation styles▸
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() }
}
}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.
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)
}
}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▸
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") }
}
}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.
Forms & validation
No Form widget package to reach for — Form is a built-in container, and focus/keyboard control is its own property wrapper.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Form container | Form + GlobalKey<FormState> | Form { } — grouped, styled sections built in |
| Field validation | TextFormField(validator:) | No built-in validator — check on submit or with .onChange(of:) |
| Keyboard type | keyboardType: TextInputType.emailAddress | .keyboardType(.emailAddress) |
| Focus control | FocusNode + FocusScope | @FocusState property wrapper |
| Secure field | obscureText: true | SecureField("Password", text:) |
| Submit on return key | onFieldSubmitted: | .onSubmit { } |
A login form — focus chaining + inline validation▸
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
}
}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.
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.Theming & dark mode
No central ThemeData object — colors live in the Asset Catalog and adapt automatically per color scheme.
| Concept | Flutter | SwiftUI |
|---|---|---|
| App-wide theme object | ThemeData on MaterialApp | No single object — colors/fonts are referenced per-view or via Assets |
| Light/dark aware color | Theme.of(context).colorScheme.primary | Named color set in Assets.xcassets ("Any/Dark" variants) |
| Read current mode | MediaQuery.platformBrightnessOf(context) | @Environment(\.colorScheme) var scheme |
| Force a mode | ThemeMode.dark on MaterialApp | .preferredColorScheme(.dark) |
| Text style scale | TextTheme | .font(.title), .headline — semantic Dynamic Type styles |
| Reusable custom style | Extension methods on BuildContext | Custom ViewModifier + a .myStyle() extension on View |
Worked examples
Reacting to dark mode without hardcoding colors▸
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)
}
}@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▸
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()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.
Color("BrandSurface")) everywhere — no if (isDark) branching scattered through the UI.Localization
String Catalogs replace intl + .arb files — one JSON-backed .xcstrings file with a dedicated Xcode editor, no codegen step.
| Concept | Flutter | SwiftUI / Swift |
|---|---|---|
| Translation package | intl + flutter gen-l10n | Built in — no package, no codegen |
| Translation file | app_en.arb, app_es.arb (one per locale) | Localizable.xcstrings — one file, all locales, in Xcode's editor UI |
| Usage in a widget | AppLocalizations.of(context)!.greeting | Text("greeting") — SwiftUI resolves it implicitly |
| Usage outside a View | Same accessor via context | String(localized: "greeting") |
| Pluralization rules | ICU plural syntax in the .arb file | Built into the String Catalog editor per-key, per-locale |
| Auto-extraction from code | Manual — you add every key to the .arb file | Automatic — Xcode scans Text/Button/etc. on every build |
Adding a String Catalog and using it▸
lib/l10n/
app_en.arb
app_es.arb
// app_en.arb
{
"greeting": "Hello, {name}!",
"@greeting": {
"placeholders": { "name": {} }
}
}
// usage
Text(AppLocalizations.of(context)!
.greeting(userName))
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.
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.Accessibility
SwiftUI infers a lot from your layout already — most work is fixing a handful of ambiguous cases, not annotating every view.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Screen reader label | Semantics(label: '...') | .accessibilityLabel("...") |
| Extra context / hint | Semantics(hint: '...') | .accessibilityHint("...") |
| Group children into one stop | MergeSemantics() | .accessibilityElement(children: .combine) |
| Hide decorative content | ExcludeSemantics() | .accessibilityHidden(true) |
| Dynamic Type / text scaling | Manual — scale with MediaQuery.textScaleFactor | Automatic for semantic fonts (.body, .title, etc.) |
| Announce a live change | SemanticsService.announce() | AccessibilityNotification.Announcement |
| Test with a screen reader | TalkBack (Android) / VoiceOver (iOS simulator) | VoiceOver — ⌘F5 on the simulator/device |
Merging a custom row into one VoiceOver stop▸
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")
}
}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.
Button containing only an SF Symbol has no readable label by default — always pair it with .accessibilityLabel("Delete") or VoiceOver announces nothing useful.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.
| Permission | Flutter | SwiftUI / Swift |
|---|---|---|
| Request flow | permission_handler package | Per-framework request call — no unified package |
| Camera | Permission.camera.request() | AVCaptureDevice.requestAccess(for: .video) |
| Photo library | Permission.photos.request() | PHPhotoLibrary.requestAuthorization(for:) |
| Location | Permission.location.request() | CLLocationManager().requestWhenInUseAuthorization() |
| Push notifications | firebase_messaging request | UNUserNotificationCenter.requestAuthorization(options:) |
| Declare the "why" text | AndroidManifest.xml + Info.plist usage strings | Info.plist keys only — e.g. NSCameraUsageDescription |
Camera permission — the full request + check flow▸
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
}
}
}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
| Step | Flutter | Swift |
|---|---|---|
| Register for remote notifications | FirebaseMessaging.instance.requestPermission() | UIApplication.shared.registerForRemoteNotifications() |
| Get the device token | FirebaseMessaging.instance.getToken() | didRegisterForRemoteNotificationsWithDeviceToken delegate callback |
| Handle a tap on the notification | FirebaseMessaging.onMessageOpenedApp | UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:) |
| Foreground presentation | Handled automatically by the plugin | willPresent delegate method — you choose banner/sound/badge explicitly |
| Backend delivery | Firebase 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▸
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)")
}
}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.
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() }
}
}
}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.
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..apns file).App lifecycle
One environment value replaces the observer pattern — no subscribe/unsubscribe dance.
| Concept | Flutter | SwiftUI |
|---|---|---|
| Observe app state changes | WidgetsBindingObserver + didChangeAppLifecycleState | @Environment(\.scenePhase) |
| States available | resumed / inactive / paused / detached | .active / .inactive / .background |
| React to a transition | Override the observer method, check the enum | .onChange(of: scenePhase) { old, new in } |
| Background task (finish work after backgrounding) | flutter_background_service / native platform channel | BGTaskScheduler — register + schedule a BGAppRefreshTask |
| React to memory warnings | didHaveMemoryPressure | UIApplication.didReceiveMemoryWarningNotification |
Pausing work when the app backgrounds▸
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
}
}
}
}@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: .active ≈ resumed (resume syncing here), .background ≈ paused (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.
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.Dependency injection
No DI container package required for most apps — @Environment plus plain initializer injection covers the common cases.
| Concept | Flutter | SwiftUI |
|---|---|---|
| DI container package | get_it / riverpod / provider | Usually none — @Environment + initializers |
| App-wide singleton service | GetIt.instance.registerSingleton() | A plain static let shared, or injected via .environment() |
| Inject into a screen | Constructor param, or context.read<T>() | Constructor param, or @Environment(T.self) |
| Swap real vs. mock for tests | Register a fake in GetIt before the test | Pass a protocol-typed mock into the initializer directly |
| Scoped to one feature only | ProxyProvider further down the tree | .environment() attached at a lower view, not the root |
Protocol-based service + environment injection (the testable pattern)▸
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")
}
}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.
@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())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.
Testing
Apple's newer Testing framework (Swift 6) reads a lot like flutter_test — XCTest is still the safe default for wider tooling support.
| Concept | Flutter | Swift |
|---|---|---|
| Unit test target | test/ + flutter_test | YourAppNameTests/ + XCTest or Testing |
| Assertion | expect(value, equals(x)) | XCTAssertEqual(value, x) / #expect(value == x) |
| Async test | test('...', () async { await ... }) | func test...() async throws { ... } |
| UI / widget test | testWidgets() + WidgetTester | XCUITest — separate YourAppNameUITests target |
| Run tests | flutter test | ⌘U in Xcode, or xcodebuild test |
Worked example
test('addNewTask adds a task', () {
final cubit = TodoCubit();
cubit.taskInputBuffer = 'Buy milk';
cubit.addNewTask();
expect(cubit.state.tasks.length, 1);
});
import Testing
@testable import YourAppName
@Test func addNewTaskAddsATask() {
let vm = TodoViewModel()
vm.taskInputBuffer = "Buy milk"
vm.addNewTask()
#expect(vm.tasks.count == 1)
}
async and awaiting the call — the compiler will tell you exactly where.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.
| Concept | Flutter | SwiftUI / Xcode |
|---|---|---|
| Build for release | flutter build ipa | Product → Archive in Xcode, or xcodebuild archive |
| Signing | Fastlane match, or manual provisioning profiles | Automatic signing in Xcode, or Fastlane match — same tool works for both |
| Beta distribution | Firebase App Distribution / TestFlight (via Codemagic etc.) | TestFlight — builds expire after 90 days |
| CI/CD | Codemagic, GitHub Actions + custom runners | Xcode Cloud (native, 25 free compute hrs/mo) or Fastlane + GitHub Actions on a macOS runner |
| Store listing config | Google Play Console + App Store Connect (two consoles) | App Store Connect only |
| Privacy declarations | Play Data Safety form | App Privacy "nutrition label" + a Privacy Manifest per third-party SDK |
| Review turnaround | Google Play — hours to a few days | Apple App Review — ~90% within 24 hours as of 2026, longer for complex/flagged apps |
The submission checklist
- 01Enroll 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.
- 02Register a Bundle ID and create the App RecordIn App Store Connect — this is the one-time setup step that minor updates skip.
- 03Build 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.
- 04Archive 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.
- 05Declare 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.
- 06Fill in App Privacy details and the age rating questionnaireBoth are checked against your actual app behavior during review, not just taken at face value.
- 07Submit 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.
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
)
endThis 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.
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.
onPressed: () async { await cubit.fetch(); }
Dart lets you mark the callback itself as async — no wrapper needed.
Button("Load") { Task { await vm.fetch() } }
Swift needs an explicit sandbox to open the async lane inside a synchronous closure.
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 { 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.
Memory & lifecycle
No more manual bloc.close(). Swift's ARC handles teardown automatically the moment a view leaves the navigation stack.
| Behavior | Flutter (Dart) | SwiftUI (Swift) |
|---|---|---|
| Memory model | Garbage Collector (GC) | Automatic Reference Counting (ARC) |
| Cleanup trigger | Manual bloc.close() | Automatic — pointer count hits zero on pop |
| Screen mount | initState() | .onAppear { } |
| Screen unmount | dispose() | .onDisappear { } |
| Explicit destructor | n/a | deinit { } |
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.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 type | Extension | Used for |
|---|---|---|
| SwiftUI view | .swift | Layout + logic in one file — live Canvas preview |
| Storyboard | .storyboard | Legacy visual XML layout — still standard for Objective-C/UIKit |
| XIB | .xib | Single-screen visual layout, same era as storyboards |
| Objective-C | .h / .m | Header + 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.
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▸
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
}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▸
@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.")
}
}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▸
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()
}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.
6 translation rules
Keep these in your head while porting any Bloc/Cubit screen over.
- 01The ViewModel is your Cubit.No separate State classes — properties on the
@Observableclass are the state; methods are your Cubit methods. - 02SwiftUI is your BlocBuilder.Reading
viewModel.tasks.countinside a view body auto-creates a targeted observer — no wrapper widget needed. - 03No manual disposes.ARC drops the ViewModel from memory the instant a screen leaves the navigation stack.
- 04Use
Task {}for async hooks.Button closures are synchronous — wrap async calls explicitly, or use.task {}for auto-fire-on-appear. - 05Declare navigation tree-side.Instead of
Navigator.push()in a callback, wrap the destination inline:NavigationLink(destination:) { ... }. - 06Use indices to mutate list items.
ForEachloops pass copies — iterate.indiceswhen you need direct array write-back.